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
2889/// the query string (which commonly carries API keys/tokens). Host and
2890/// path stay visible for diagnosability. Fragments are never echoed: a
2891/// fragment (OAuth2 callback tokens such as `#access_token=...`) is
2892/// dropped and replaced with the `#[redacted]` sentinel in both the
2893/// parsed arm and the unparseable arm. Fail-closed: when the parse fails
2894/// and the `//`-authority window contains `@`, only the `[redacted]`
2895/// sentinel is returned. Every `//` window is scanned: each window starts
2896/// after the `//` plus any run of extra slashes (so evaders like
2897/// `scheme:////user:pass@evil/` cannot hide a `@` behind a slash run) and
2898/// ends at the next `/`, `?`, or `#`; scanning all windows keeps later
2899/// `//user:pass@` substrings from hiding behind a benign first window.
2900/// The same window mask is applied to the parsed arm's rendered string
2901/// (`mask_rendered_windows`), because rust-url can park later-window
2902/// userinfo bytes in the path (`https://h//user:pass@evil/`).
2903/// Everything from the earliest `?` or `#` is dropped; the sentinels
2904/// compose: each distinct introducer character (`?` and/or `#`) that
2905/// occurs anywhere in the raw string appends its matching
2906/// `?[redacted]` / `#[redacted]` sentinel in first-occurrence order, in
2907/// both arms. Otherwise the raw string stays visible, and the result is
2908/// capped at 256 bytes on a UTF-8 char boundary.
2909pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
2910    const MAX_URL_LOG_LEN: usize = 256;
2911    match url::Url::parse(raw) {
2912        Ok(mut u) => {
2913            // Fail closed when an authority marker was accepted but no
2914            // host was stored: userinfo-shaped bytes can hide in the path
2915            // behind the marker, and empty-host schemes (`file:///us@r/x`,
2916            // `unix:///@socket`) can put a `@` in that window too. Such
2917            // inputs are sentineled wholesale — deliberate fail-closed
2918            // over-redaction per ADR-0051.
2919            if !u.cannot_be_a_base() && u.host_str().is_none() && window_has_at_sign(raw) {
2920                return "[redacted]".to_string();
2921            }
2922            if !u.username().is_empty() || u.password().is_some() {
2923                let _ = u.set_username("***");
2924                let _ = u.set_password(None);
2925            }
2926            let had_query = u.query().is_some();
2927            let had_fragment = u.fragment().is_some();
2928            u.set_query(None);
2929            u.set_fragment(None);
2930            let mut s = u.to_string();
2931            while s.ends_with('?') || s.ends_with('#') {
2932                s.pop();
2933            }
2934            // The parser can park later-window userinfo bytes in the path
2935            // (`https://h//user:pass@evil/`); the accessor mask above only
2936            // covers the real authority. Apply the same window surgery the
2937            // string-based redactors use, before the sentinels are
2938            // appended.
2939            let mut s = mask_rendered_windows(&s);
2940            // Reserve the sentinel bytes before truncating so the cap never
2941            // splits an appended sentinel (e_gpt stage-4).
2942            let sentinel_total = usize::from(had_query) * 11 + usize::from(had_fragment) * 11;
2943            if sentinel_total > 0 {
2944                truncate_utf8_safe(&mut s, MAX_URL_LOG_LEN - sentinel_total);
2945            }
2946            if had_query {
2947                s.push_str("?[redacted]");
2948            }
2949            if had_fragment {
2950                s.push_str("#[redacted]");
2951            }
2952            truncate_utf8_safe(&mut s, MAX_URL_LOG_LEN);
2953            s
2954        }
2955        Err(_) => {
2956            // Fail closed: an unparseable string with `@` inside its
2957            // authority window may carry credentials the parser never
2958            // validated, so nothing of it is rendered.
2959            if window_has_at_sign(raw) {
2960                return "[redacted]".to_string();
2961            }
2962            let mut s = raw.to_string();
2963            if let Some(i) = raw.find(['?', '#']) {
2964                // Compose-both: one sentinel per distinct introducer found
2965                // in the raw string, in first-occurrence order.
2966                let query_pos = raw.find('?');
2967                let fragment_pos = raw.find('#');
2968                s.truncate(i);
2969                // Reserve the sentinel bytes before truncating so the cap
2970                // never splits an appended sentinel (e_gpt stage-4).
2971                let sentinel_total = match (query_pos, fragment_pos) {
2972                    (Some(_), Some(_)) => 22,
2973                    (Some(_), None) | (None, Some(_)) => 11,
2974                    (None, None) => 0,
2975                };
2976                if sentinel_total > 0 {
2977                    truncate_utf8_safe(&mut s, MAX_URL_LOG_LEN - sentinel_total);
2978                }
2979                match (query_pos, fragment_pos) {
2980                    (Some(q), Some(f)) if f < q => s.push_str("#[redacted]?[redacted]"),
2981                    (Some(_), Some(_)) => s.push_str("?[redacted]#[redacted]"),
2982                    (Some(_), None) => s.push_str("?[redacted]"),
2983                    (None, Some(_)) => s.push_str("#[redacted]"),
2984                    (None, None) => {}
2985                }
2986            }
2987            truncate_utf8_safe(&mut s, MAX_URL_LOG_LEN);
2988            s
2989        }
2990    }
2991}
2992
2993/// Window-masking surgery on an already-rendered URL string, mirroring the
2994/// string-based redactors in camel-config and camel-jms (cross-crate
2995/// sharing is deliberately avoided): collect every `//` window — each
2996/// starts after the `//` plus any run of extra slashes and ends at the
2997/// next `/`, `?`, or `#` — dedup windows that share one slash run, and
2998/// replace the bytes from window start through the LAST `@` with `***`, in
2999/// reverse offset order (over-masking is safe, under-masking is not).
3000/// Idempotent: an already-masked `***@host` window rewrites to itself.
3001fn mask_rendered_windows(s: &str) -> String {
3002    let bytes = s.as_bytes();
3003    let mut out = s.to_string();
3004    let mut windows: Vec<(usize, usize)> = Vec::new();
3005    for (idx, _) in s.match_indices("//") {
3006        let mut start = idx + 2;
3007        while bytes.get(start) == Some(&b'/') {
3008            start += 1;
3009        }
3010        let end = s[start..]
3011            .find(['/', '?', '#'])
3012            .map_or(s.len(), |offset| start + offset);
3013        windows.push((start, end));
3014    }
3015    windows.sort_unstable();
3016    windows.dedup();
3017    for (start, end) in windows.into_iter().rev() {
3018        if let Some(at) = out[start..end].rfind('@') {
3019            out.replace_range(start..start + at, "***");
3020        }
3021    }
3022    out
3023}
3024
3025/// Whether a `@` appears in any `//`-authority-style window of `raw`.
3026/// Each window starts after a `//` plus any run of extra slashes (evaders
3027/// hide a `@` behind `scheme:////...`) and ends at the next `/`, `?`, or
3028/// `#`. Every `//` occurrence is scanned, so credentials cannot hide in a
3029/// later window behind a benign first one (`http://h/a//user:pass@e/`).
3030fn window_has_at_sign(raw: &str) -> bool {
3031    let bytes = raw.as_bytes();
3032    for (idx, _) in raw.match_indices("//") {
3033        let mut start = idx + 2;
3034        while bytes.get(start) == Some(&b'/') {
3035            start += 1;
3036        }
3037        let end = raw[start..]
3038            .find(['/', '?', '#'])
3039            .map_or(raw.len(), |offset| start + offset);
3040        if raw[start..end].contains('@') {
3041            return true;
3042        }
3043    }
3044    false
3045}
3046
3047/// Truncate `s` to at most `max` bytes, walking the cut down to the nearest
3048/// UTF-8 char boundary so a multibyte character straddling the cap cannot
3049/// panic.
3050fn truncate_utf8_safe(s: &mut String, max: usize) {
3051    if s.len() <= max {
3052        return;
3053    }
3054    let mut cut = max;
3055    while !s.is_char_boundary(cut) {
3056        cut -= 1;
3057    }
3058    s.truncate(cut);
3059}
3060
3061/// Maximum bytes of an upstream error response body embedded into
3062/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
3063/// malicious or compromised upstream), so it is truncated and lossy-decoded to
3064/// bound log injection / DLQ payload size.
3065const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3066
3067fn truncate_error_body(body: &[u8]) -> String {
3068    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3069        String::from_utf8_lossy(body).into_owned()
3070    } else {
3071        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3072        s.push_str("...[truncated]");
3073        s
3074    }
3075}
3076
3077impl HttpProducer {
3078    /// Whether the HTTP method is entity-enclosing (may carry a request
3079    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
3080    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
3081    /// §9.3.1/§9.3.2).
3082    fn is_entity_enclosing(method: &str) -> bool {
3083        matches!(method, "POST" | "PUT" | "PATCH")
3084    }
3085}
3086
3087impl Service<Exchange> for HttpProducer {
3088    type Response = Exchange;
3089    type Error = CamelError;
3090    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3091
3092    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3093        Poll::Ready(Ok(()))
3094    }
3095
3096    fn call(&mut self, exchange: Exchange) -> Self::Future {
3097        let config = self.config.clone();
3098        let shared_client = self.client.clone();
3099        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3100        let http_config = self.http_config.clone();
3101        let component_metrics = self.runtime.component_metrics();
3102
3103        Box::pin(async move {
3104            let mut exchange = exchange;
3105            let outcome = async {
3106                let method_str = HttpProducer::resolve_method(&exchange, &config);
3107                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
3108                // and PATCH may carry a request body. Any other resolved method
3109                // drops the exchange body before the request is built (Apache
3110                // Camel `HttpMethods` parity).
3111                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3112                let url = HttpProducer::resolve_url(&exchange, &config)?;
3113
3114                // SECURITY: Validate URL for SSRF
3115                ssrf::validate_url_for_ssrf(&url, &config)?;
3116
3117                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
3118                // (L-H2). When the URL uses a domain name and SSRF protection is active,
3119                // reuse the endpoint's cached DNS-pinned client for that validated
3120                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
3121                // repeated requests keep one connection pool without re-resolving DNS.
3122                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
3123                // URLs use the endpoint's unpinned shared client.
3124                let resolved =
3125                    ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
3126                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3127                    pinned_cache
3128                        .get_or_build(host.as_str(), addrs, || {
3129                            build_client(&http_config, Some((host.as_str(), addrs)))
3130                        })
3131                        .await
3132                } else {
3133                    shared_client.clone()
3134                };
3135
3136                debug!(
3137                    correlation_id = %exchange.correlation_id(),
3138                    method = %method_str,
3139                    url = %redact_url_for_diagnostics(&url),
3140                    "HTTP request"
3141                );
3142
3143                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3144                    CamelError::ProcessorError(format!(
3145                        "Invalid HTTP method '{}': {}",
3146                        method_str, e
3147                    ))
3148                })?;
3149
3150                // Collect headers for potential redirect replay
3151                let mut collected_headers: Vec<(
3152                    reqwest::header::HeaderName,
3153                    reqwest::header::HeaderValue,
3154                )> = Vec::new();
3155
3156                if let Some(user_agent) = &config.user_agent
3157                    && !config.bridge_endpoint
3158                {
3159                    match constructed_header("user-agent", user_agent) {
3160                        Ok((_, val)) => {
3161                            collected_headers.push((reqwest::header::USER_AGENT, val));
3162                        }
3163                        Err(drop) => debug!(
3164                            correlation_id = %exchange.correlation_id(),
3165                            header = %drop.name,
3166                            "outbound header dropped: {}",
3167                            drop.reason
3168                        ),
3169                    }
3170                }
3171
3172                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
3173                #[cfg(feature = "otel")]
3174                let should_inject_otel = !config.bridge_endpoint;
3175                #[cfg(feature = "otel")]
3176                if should_inject_otel {
3177                    let mut otel_headers = HashMap::new();
3178                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3179                    for (k, v) in otel_headers {
3180                        match constructed_header(&k, &v) {
3181                            Ok((name, val)) => collected_headers.push((name, val)),
3182                            Err(drop) => debug!(
3183                                correlation_id = %exchange.correlation_id(),
3184                                header = %drop.name,
3185                                "outbound header dropped: {}",
3186                                drop.reason
3187                            ),
3188                        }
3189                    }
3190                }
3191
3192                let conn_tokens = header_policy::connection_tokens(
3193                    exchange
3194                        .input
3195                        .headers
3196                        .iter()
3197                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3198                        .filter_map(|(_, v)| v.as_str()),
3199                );
3200
3201                let outbound = select_outbound_headers(
3202                    &exchange.input.headers,
3203                    &config.skip_request_headers,
3204                    &conn_tokens,
3205                );
3206                for drop in &outbound.drops {
3207                    if let Some(value_kind) = drop.value_kind {
3208                        debug!(
3209                            correlation_id = %exchange.correlation_id(),
3210                            header = %drop.name,
3211                            value_kind = value_kind,
3212                            "outbound header dropped: {}",
3213                            drop.reason
3214                        );
3215                    } else {
3216                        debug!(
3217                            correlation_id = %exchange.correlation_id(),
3218                            header = %drop.name,
3219                            "outbound header dropped: {}",
3220                            drop.reason
3221                        );
3222                    }
3223                }
3224                collected_headers.extend(outbound.accepted);
3225
3226                // Auth headers
3227                if !config.bridge_endpoint {
3228                    match &config.auth {
3229                        HttpAuth::None => {}
3230                        HttpAuth::Basic { username, password } => {
3231                            use base64::Engine;
3232                            // allow-secret: credentials combined for base64 Basic auth header
3233                            let credentials = format!("{username}:{password}");
3234                            let encoded =
3235                                base64::engine::general_purpose::STANDARD.encode(credentials);
3236                            // Base64 output is always header-safe; the guard is kept
3237                            // for uniformity with Bearer.
3238                            match constructed_header("authorization", &format!("Basic {encoded}")) {
3239                                Ok((_, val)) => {
3240                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3241                                }
3242                                Err(drop) => debug!(
3243                                    correlation_id = %exchange.correlation_id(),
3244                                    header = %drop.name,
3245                                    "outbound header dropped: {}",
3246                                    drop.reason
3247                                ),
3248                            }
3249                        }
3250                        HttpAuth::Bearer { token } => {
3251                            // allow-secret: Bearer token in Authorization header
3252                            let bearer = format!("Bearer {token}");
3253                            match constructed_header("authorization", &bearer) {
3254                                Ok((_, val)) => {
3255                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3256                                }
3257                                Err(drop) => debug!(
3258                                    correlation_id = %exchange.correlation_id(),
3259                                    header = %drop.name,
3260                                    "outbound header dropped: {}",
3261                                    drop.reason
3262                                ),
3263                            }
3264                        }
3265                    }
3266
3267                    if config.connection_close {
3268                        collected_headers.push((
3269                            reqwest::header::CONNECTION,
3270                            reqwest::header::HeaderValue::from_static("close"),
3271                        ));
3272                    }
3273                }
3274
3275                // Materialize body
3276                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3277                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3278                    if suppress_body {
3279                        // A stream body dropped under a non-entity-enclosing
3280                        // method always warns (its emptiness is unknowable) and
3281                        // stays consumed (mem::take). The stream attach arm below
3282                        // still runs its outer flag check, but the inner `if let
3283                        // Body::Stream` re-match fails on the now-Empty body, so
3284                        // no stream is attached and no AlreadyConsumed error can
3285                        // fire.
3286                        std::mem::take(&mut exchange.input.body);
3287                        // log-policy: handler-owned
3288                        tracing::warn!(
3289                            correlation_id = %exchange.correlation_id(),
3290                            method = %method_str,
3291                            "dropping request body for non-entity-enclosing HTTP method"
3292                        );
3293                    }
3294                    None // Streams can't be replayed on redirect
3295                } else {
3296                    let body = std::mem::take(&mut exchange.input.body);
3297                    let bytes = body.into_bytes(config.max_body_size).await?;
3298                    if bytes.is_empty() {
3299                        // Empty body: nothing to send and nothing to warn about.
3300                        None
3301                    } else if suppress_body {
3302                        // log-policy: handler-owned
3303                        tracing::warn!(
3304                            correlation_id = %exchange.correlation_id(),
3305                            method = %method_str,
3306                            "dropping request body for non-entity-enclosing HTTP method"
3307                        );
3308                        None
3309                    } else {
3310                        Some(bytes.to_vec())
3311                    }
3312                };
3313
3314                let response = if config.follow_redirects && !is_stream_body {
3315                    // Use manual redirect loop with per-hop SSRF validation.
3316                    // `client` is the pinned-or-shared binding for the initial
3317                    // request (a hostname initial request keeps its DNS-pinned
3318                    // client); `shared_client` is the unpinned endpoint client
3319                    // reused by IP-literal redirect hops.
3320                    ssrf::send_with_ssrf_safe_redirects(
3321                        &client,
3322                        &shared_client,
3323                        &pinned_cache,
3324                        &http_config,
3325                        &config,
3326                        method,
3327                        &url,
3328                        collected_headers,
3329                        materialized_body,
3330                        config.max_redirects,
3331                        config.response_timeout,
3332                    )
3333                    .await?
3334                } else {
3335                    // Direct send (no redirect following, or streaming body)
3336                    let mut request = client.request(method, &url);
3337
3338                    if let Some(timeout) = config.response_timeout {
3339                        request = request.timeout(timeout);
3340                    }
3341
3342                    for (name, value) in &collected_headers {
3343                        request = request.header(name, value);
3344                    }
3345
3346                    if is_stream_body {
3347                        if let Body::Stream(ref s) = exchange.input.body {
3348                            let mut stream_lock = s.stream.lock().await;
3349                            if let Some(stream) = stream_lock.take() {
3350                                request = request.body(reqwest::Body::wrap_stream(stream));
3351                            } else {
3352                                return Err(CamelError::AlreadyConsumed);
3353                            }
3354                        }
3355                    } else if let Some(ref body_bytes) = materialized_body {
3356                        request = request.body(body_bytes.clone());
3357                    }
3358
3359                    request.send().await.map_err(|e| {
3360                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3361                    })?
3362                };
3363
3364                let status_code = response.status().as_u16();
3365                let status_text = response
3366                    .status()
3367                    .canonical_reason()
3368                    .unwrap_or("Unknown")
3369                    .to_string();
3370
3371                for (key, value) in response.headers() {
3372                    if config
3373                        .skip_response_headers
3374                        .iter()
3375                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3376                    {
3377                        continue;
3378                    }
3379                    if let Ok(val_str) = value.to_str() {
3380                        exchange.input.set_header(
3381                            title_case_header(key.as_str()),
3382                            serde_json::Value::String(val_str.to_string()),
3383                        );
3384                    }
3385                }
3386
3387                exchange.input.set_header(
3388                    "CamelHttpResponseCode",
3389                    serde_json::Value::Number(status_code.into()),
3390                );
3391                exchange.input.set_header(
3392                    "CamelHttpResponseText",
3393                    serde_json::Value::String(status_text.clone()),
3394                );
3395
3396                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
3397                let read_timeout = Duration::from_millis(config.read_timeout_ms);
3398                let response_body = tokio::time::timeout(read_timeout, async {
3399                    // Check Content-Length header before allocating
3400                    if let Some(content_len) = response.content_length()
3401                        && content_len > config.max_response_bytes as u64
3402                    {
3403                        return Err(CamelError::ProcessorError(format!(
3404                            "Response body too large: {} bytes exceeds limit of {} bytes",
3405                            content_len, config.max_response_bytes
3406                        )));
3407                    }
3408                    // Use bytes_stream() for lazy streaming with size guard
3409                    use futures::TryStreamExt;
3410                    let mut stream = response.bytes_stream();
3411                    let mut total: usize = 0;
3412                    let mut collected = Vec::new();
3413                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
3414                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3415                    })? {
3416                        total += chunk.len();
3417                        if total > config.max_response_bytes {
3418                            return Err(CamelError::ProcessorError(format!(
3419                                "Response body too large: {} bytes exceeds limit of {} bytes",
3420                                total, config.max_response_bytes
3421                            )));
3422                        }
3423                        collected.push(chunk);
3424                    }
3425                    let mut result = bytes::BytesMut::with_capacity(total);
3426                    for chunk in collected {
3427                        result.extend_from_slice(&chunk);
3428                    }
3429                    Ok::<bytes::Bytes, CamelError>(result.freeze())
3430                })
3431                .await
3432                .map_err(|_| {
3433                    CamelError::ProcessorError(format!(
3434                        "Read timeout after {}ms",
3435                        config.read_timeout_ms
3436                    ))
3437                })??;
3438
3439                if config.throw_exception_on_failure
3440                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3441                {
3442                    return Err(CamelError::HttpOperationFailed {
3443                        method: method_str,
3444                        // ADR-0051 redact-by-construction: never embed
3445                        // userinfo/query credentials in the error value.
3446                        url: redact_url_for_diagnostics(&url),
3447                        status_code,
3448                        status_text,
3449                        response_body: Some(truncate_error_body(&response_body)),
3450                    });
3451                }
3452
3453                if !response_body.is_empty() {
3454                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3455                }
3456
3457                debug!(
3458                    correlation_id = %exchange.correlation_id(),
3459                    status = status_code,
3460                    url = %redact_url_for_diagnostics(&url),
3461                    "HTTP response"
3462                );
3463                Ok(exchange)
3464            }
3465            .await;
3466            // ("http","request") facade (dashboard-observability 4.3): the
3467            // request boundary is the full client round-trip — SSRF checks,
3468            // send, response read, and (with throwExceptionOnFailure) the
3469            // status gate. http runs no retry_async and the producer
3470            // previously emitted nothing, so no label collides with
3471            // e:http:request.
3472            component_metrics.observe("http", "request", outcome.is_err());
3473            outcome
3474        })
3475    }
3476}
3477
3478/// Serializes tests that mutate or depend on the global `ServerRegistry`.
3479///
3480/// `ServerRegistry::global()` is a process-wide singleton that persists
3481/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
3482/// with another test that has a live server on a fixed port (e.g. 9991),
3483/// the registry entry is removed while the OS socket is still bound, so
3484/// the next `get_or_spawn` call on that port fails with "Address already
3485/// in use". Holding this mutex for the full body of each affected test
3486/// prevents the race without requiring `--test-threads=1`.
3487#[cfg(test)]
3488pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3489
3490/// Poison-recovering acquire of REGISTRY_TEST_MUTEX (httpflake).
3491///
3492/// The mutex guards test SERIALIZATION only - the registry own data is
3493/// protected by its inner lock - so a sibling test that panics while
3494/// holding the guard must not poison the mutex and cascade failures
3495/// into every other holder. Recovery via into_inner is therefore safe
3496/// and keeps one failing test failing as ONE test.
3497#[cfg(test)]
3498pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3499    REGISTRY_TEST_MUTEX
3500        .lock()
3501        .unwrap_or_else(|poisoned| poisoned.into_inner())
3502}
3503
3504/// Map a pipeline error to an HTTP reply.
3505///
3506/// Extracted from the inline `match` in `dispatch_handler` for unit
3507/// testability (rc-1dk4). Client-fault errors map to their 4xx codes
3508/// with a structured JSON error body: `TypeConversionFailed`/
3509/// `ValidationError` → 400, `UnsupportedMediaType` → 415 (media
3510/// negotiation gate, REST lowering), `NotAcceptable` → 406 (same
3511/// gate); `Unauthenticated`/`Unauthorized` keep their `401`/`403`
3512/// mappings; all other errors map to `500 Internal Server Error`.
3513fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3514    match e {
3515        CamelError::Unauthenticated(msg) => {
3516            tracing::warn!(error = %msg, path = %path, "Authentication failed");
3517            HttpReply {
3518                status: 401,
3519                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3520                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3521            }
3522        }
3523        CamelError::Unauthorized(msg) => {
3524            tracing::warn!(error = %msg, path = %path, "Authorization failed");
3525            HttpReply {
3526                status: 403,
3527                headers: vec![],
3528                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3529            }
3530        }
3531        CamelError::TypeConversionFailed(msg) => {
3532            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3533            json_error_reply(400, "bad_request", msg)
3534        }
3535        CamelError::ValidationError(msg) => {
3536            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3537            json_error_reply(400, "validation_error", msg)
3538        }
3539        CamelError::ConsumerStopping => {
3540            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3541            HttpReply {
3542                status: 503,
3543                headers: vec![],
3544                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3545            }
3546        }
3547        CamelError::UnsupportedMediaType { consumed, declared } => {
3548            tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3549            json_error_reply(
3550                415,
3551                "unsupported_media_type",
3552                format!("consumed {consumed}, declared {declared}"),
3553            )
3554        }
3555        CamelError::NotAcceptable { accept, produced } => {
3556            tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3557            json_error_reply(
3558                406,
3559                "not_acceptable",
3560                format!("accept {accept}, produced {produced}"),
3561            )
3562        }
3563        e => {
3564            // log-policy: handler-owned
3565            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3566            HttpReply {
3567                status: 500,
3568                headers: vec![],
3569                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3570            }
3571        }
3572    }
3573}
3574
3575/// Build a JSON error reply with the given status, error code, and message.
3576///
3577/// Shared by the `TypeConversionFailed`/`ValidationError` (400),
3578/// `UnsupportedMediaType` (415), and `NotAcceptable` (406) arms of
3579/// `pipeline_error_to_reply` so the four replies cannot drift apart. The
3580/// `unwrap_or_else(|_| "{}".to_string())` fallback keeps the reply valid
3581/// JSON even if serialization fails.
3582fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3583    let body = serde_json::to_string(&serde_json::json!({
3584        "error": code,
3585        "message": message,
3586    }))
3587    .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3588    HttpReply {
3589        status,
3590        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3591        body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3592    }
3593}
3594
3595/// Lowercase kind name for a JSON value, used in drop diagnostics so log
3596/// readers see *why* a header had no scalar string form without the value
3597/// itself ever entering diagnostics.
3598const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3599    match v {
3600        serde_json::Value::Null => "null",
3601        serde_json::Value::Bool(_) => "bool",
3602        serde_json::Value::Number(_) => "number",
3603        serde_json::Value::String(_) => "string",
3604        serde_json::Value::Array(_) => "array",
3605        serde_json::Value::Object(_) => "object",
3606    }
3607}
3608
3609/// Scalar string form of a JSON value: strings pass through, `Number` and
3610/// `Bool` are stringified, everything else has no single-value form.
3611/// Shared by the consumer reply finaliser and the producer outbound filter
3612/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
3613fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3614    match v {
3615        serde_json::Value::String(s) => Some(s.clone()),
3616        serde_json::Value::Number(n) => Some(n.to_string()),
3617        serde_json::Value::Bool(b) => Some(b.to_string()),
3618        _ => None,
3619    }
3620}
3621
3622/// Select the HTTP response headers emitted by the consumer reply finaliser
3623/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
3624/// `dispatch_handler` for unit testability.
3625///
3626/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
3627/// server-owned headers, plus `content-length`/`content-type` (re-derived),
3628/// and any header named by a `Connection` token. Scalar non-string values
3629/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3630/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
3631/// and arrays have no single-value form and are dropped. Every drop is
3632/// logged at DEBUG with the header name and reason — names only, never
3633/// values, so credentials cannot leak into diagnostics (ADR-0051).
3634/// Appends a single `Content-Type` from `user_content_type` falling back to
3635/// `inferred_content_type` when either is present.
3636fn select_response_headers(
3637    headers: &HashMap<String, serde_json::Value>,
3638    user_content_type: Option<String>,
3639    inferred_content_type: Option<String>,
3640) -> Vec<(String, String)> {
3641    let conn_tokens = header_policy::connection_tokens(
3642        headers
3643            .iter()
3644            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3645            .filter_map(|(_, v)| v.as_str()),
3646    );
3647    let mut selected: Vec<(String, String)> = Vec::new();
3648    for (k, v) in headers {
3649        if k.starts_with("Camel") {
3650            debug!(header = %k, "reply header dropped: Camel namespace");
3651            continue;
3652        }
3653        if header_policy::excluded_response(k, &conn_tokens) {
3654            debug!(header = %k, "reply header dropped: emission policy");
3655            continue;
3656        }
3657        match scalar_string_form(v) {
3658            Some(s) => selected.push((k.clone(), s)),
3659            None => debug!(
3660                header = %k,
3661                value_kind = json_value_kind(v),
3662                "reply header dropped: no scalar string form"
3663            ),
3664        }
3665    }
3666    if let Some(ct) = user_content_type.or(inferred_content_type) {
3667        selected.push(("Content-Type".to_string(), ct));
3668    }
3669    selected
3670}
3671
3672/// One outbound header drop: the exchange header name, a stable reason
3673/// string, and — when the drop was caused by the value having no scalar
3674/// string form — the JSON value kind. Names and kinds only, never values
3675/// (ADR-0051).
3676#[derive(Debug)]
3677struct OutboundHeaderDrop<'a> {
3678    name: &'a str,
3679    reason: &'static str,
3680    value_kind: Option<&'static str>,
3681}
3682
3683/// Outbound exchange-header selection result: headers accepted for the
3684/// wire plus drop records for call-site DEBUG logging.
3685struct OutboundHeaderSelection<'a> {
3686    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3687    drops: Vec<OutboundHeaderDrop<'a>>,
3688}
3689
3690/// Select the exchange headers the HTTP producer forwards on the outbound
3691/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
3692/// `HttpProducer::call` for unit testability.
3693///
3694/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
3695/// hop-by-hop/framing and connection-token-named headers excluded by the
3696/// outbound emission policy, and headers whose name or stringified value
3697/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
3698/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3699/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
3700/// and arrays have no single-value form and are dropped. Drops are returned
3701/// rather than logged so the call site can attach the correlation id; log
3702/// consumers see names and kinds only, never values (ADR-0051).
3703fn select_outbound_headers<'a>(
3704    headers: &'a HashMap<String, serde_json::Value>,
3705    skip_request_headers: &[String],
3706    conn_tokens: &[String],
3707) -> OutboundHeaderSelection<'a> {
3708    let mut accepted = Vec::new();
3709    let mut drops = Vec::new();
3710    for (key, value) in headers {
3711        if key.starts_with("Camel") {
3712            drops.push(OutboundHeaderDrop {
3713                name: key,
3714                reason: "Camel namespace",
3715                value_kind: None,
3716            });
3717            continue;
3718        }
3719        if skip_request_headers
3720            .iter()
3721            .any(|h| h.eq_ignore_ascii_case(key))
3722        {
3723            drops.push(OutboundHeaderDrop {
3724                name: key,
3725                reason: "skip_request_headers",
3726                value_kind: None,
3727            });
3728            continue;
3729        }
3730        if header_policy::excluded_outbound(key, conn_tokens) {
3731            drops.push(OutboundHeaderDrop {
3732                name: key,
3733                reason: "outbound emission policy",
3734                value_kind: None,
3735            });
3736            continue;
3737        }
3738        let Some(val_str) = scalar_string_form(value) else {
3739            drops.push(OutboundHeaderDrop {
3740                name: key,
3741                reason: "no scalar string form",
3742                value_kind: Some(json_value_kind(value)),
3743            });
3744            continue;
3745        };
3746        match constructed_header(key, &val_str) {
3747            Ok((name, val)) => accepted.push((name, val)),
3748            Err(drop) => drops.push(drop),
3749        }
3750    }
3751    OutboundHeaderSelection { accepted, drops }
3752}
3753
3754/// Construct a wire-ready `(HeaderName, HeaderValue)` pair for one outbound
3755/// header, or a drop record when the name or value fails construction
3756/// (rc-jbs1v). Drop records carry name and reason only, never values
3757/// (ADR-0051).
3758fn constructed_header<'a>(
3759    name: &'a str,
3760    value: &str,
3761) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3762    let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3763        Ok(header_name) => header_name,
3764        Err(_) => {
3765            return Err(OutboundHeaderDrop {
3766                name,
3767                reason: "invalid header name",
3768                value_kind: None,
3769            });
3770        }
3771    };
3772    let header_value = match reqwest::header::HeaderValue::from_str(value) {
3773        Ok(header_value) => header_value,
3774        Err(_) => {
3775            return Err(OutboundHeaderDrop {
3776                name,
3777                reason: "invalid header value",
3778                value_kind: None,
3779            });
3780        }
3781    };
3782    Ok((header_name, header_value))
3783}
3784
3785#[cfg(test)]
3786mod tests {
3787    use camel_component_api::test_support::NoopRuntimeObservability;
3788
3789    // Producer/consumer tests drive the component-ops facade on every
3790    // call (dashboard-observability 4.3), so even non-observability tests
3791    // must supply a collector-returning runtime — Noop everywhere.
3792    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3793        std::sync::Arc::new(NoopRuntimeObservability)
3794    }
3795    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3796        std::sync::Arc::new(NoopRuntimeObservability)
3797    }
3798    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3799        std::sync::Arc::new(NoopRuntimeObservability)
3800    }
3801
3802    use super::*;
3803    use crate::rest_match::PathSegment;
3804    use camel_component_api::{Message, NoOpComponentContext};
3805    use std::sync::Arc;
3806    use std::time::Duration;
3807
3808    fn test_producer_ctx() -> ProducerContext {
3809        ProducerContext::new()
3810    }
3811
3812    // -----------------------------------------------------------------------
3813    // Security: credential redaction (audit 2026-08-31, finding F3-1)
3814    // -----------------------------------------------------------------------
3815
3816    #[test]
3817    fn redact_url_drops_oauth2_fragment_access_token() {
3818        let redacted =
3819            redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
3820        assert!(
3821            !redacted.contains("SECRET"),
3822            "fragment access token leaked: {redacted}"
3823        );
3824        assert!(
3825            !redacted.contains("access_token"),
3826            "fragment key leaked: {redacted}"
3827        );
3828        assert!(
3829            redacted.ends_with("#[redacted]"),
3830            "fragment must be replaced with the sentinel: {redacted}"
3831        );
3832    }
3833
3834    #[test]
3835    fn redact_url_drops_oauth2_fragment_id_token() {
3836        let redacted =
3837            redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
3838        assert!(
3839            !redacted.contains("eyJhbG"),
3840            "id token payload leaked: {redacted}"
3841        );
3842        assert!(
3843            !redacted.contains("id_token"),
3844            "id token key leaked: {redacted}"
3845        );
3846        assert!(
3847            !redacted.contains("SECRET"),
3848            "id token signature leaked: {redacted}"
3849        );
3850        assert!(
3851            redacted.ends_with("#[redacted]"),
3852            "fragment must be replaced with the sentinel: {redacted}"
3853        );
3854    }
3855
3856    #[test]
3857    fn redact_url_drops_generic_fragment_kv() {
3858        let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
3859        assert!(
3860            !redacted.contains("abc123"),
3861            "fragment value leaked: {redacted}"
3862        );
3863        assert!(
3864            !redacted.contains("session="),
3865            "fragment key leaked: {redacted}"
3866        );
3867        assert!(
3868            redacted.contains("#[redacted]"),
3869            "fragment must be replaced with the sentinel: {redacted}"
3870        );
3871    }
3872
3873    #[test]
3874    fn redact_url_query_and_fragment_sentinels_compose() {
3875        let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
3876        assert_eq!(
3877            redacted, "https://h.example/p?[redacted]#[redacted]",
3878            "query and fragment sentinels must compose: {redacted}"
3879        );
3880    }
3881
3882    #[test]
3883    fn redact_url_drops_benign_fragment_too() {
3884        // Fragments never reach the wire, so nothing in them is diagnostic:
3885        // strictest-wins drops benign fragments too.
3886        let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
3887        assert_eq!(
3888            redacted, "https://h.example/docs#[redacted]",
3889            "benign fragment must still be dropped: {redacted}"
3890        );
3891    }
3892
3893    #[test]
3894    fn redact_url_unparseable_fragment_credentials_dropped() {
3895        let raw = "ht tps://app.example/cb#access_token=SECRET";
3896        assert!(
3897            url::Url::parse(raw).is_err(),
3898            "fixture must be unparseable: {raw}"
3899        );
3900        let redacted = redact_url_for_diagnostics(raw);
3901        assert!(
3902            !redacted.contains("SECRET"),
3903            "unparseable fragment token leaked: {redacted}"
3904        );
3905        assert!(
3906            !redacted.contains("access_token"),
3907            "unparseable fragment bytes leaked: {redacted}"
3908        );
3909        assert!(
3910            redacted.contains("#[redacted]"),
3911            "unparseable fragment must end in the sentinel: {redacted}"
3912        );
3913    }
3914
3915    #[test]
3916    fn redact_url_double_slash_evader_sentinel() {
3917        // url::Url::parse accepts this (empty host allowed for non-special
3918        // schemes), parking userinfo-shaped bytes in the opaque path.
3919        let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
3920        assert_eq!(
3921            redacted, "[redacted]",
3922            "double-slash evader must fail closed: {redacted}"
3923        );
3924    }
3925
3926    #[test]
3927    fn redact_url_triple_slash_evader_sentinel() {
3928        let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
3929        assert_eq!(
3930            redacted, "[redacted]",
3931            "triple-slash evader must fail closed: {redacted}"
3932        );
3933    }
3934
3935    #[test]
3936    fn redact_url_bare_protocol_relative_userinfo_sentinel() {
3937        let redacted = redact_url_for_diagnostics("//user:pass@evil");
3938        assert_eq!(
3939            redacted, "[redacted]",
3940            "protocol-relative userinfo must fail closed: {redacted}"
3941        );
3942    }
3943
3944    #[test]
3945    fn redact_url_empty_host_userinfo_sentinel() {
3946        // url::Url::parse rejects this with EmptyHost; the failure arm must
3947        // fail closed without panicking on the empty host.
3948        let redacted = redact_url_for_diagnostics("scheme://user@");
3949        assert_eq!(
3950            redacted, "[redacted]",
3951            "empty-host userinfo must fail closed: {redacted}"
3952        );
3953    }
3954
3955    #[test]
3956    fn redact_url_unparseable_slash_run_evader_sentinel() {
3957        // Unlike `scheme:////user:pass@evil/` (parses Ok, host=None, and
3958        // hits the parsed-arm guard), the space in the scheme forces the
3959        // parse to fail, driving the failure arm's slash-run skip directly.
3960        let raw = "schem e:////user:pass@evil/";
3961        assert!(
3962            url::Url::parse(raw).is_err(),
3963            "fixture must be unparseable: {raw}"
3964        );
3965        let redacted = redact_url_for_diagnostics(raw);
3966        assert_eq!(
3967            redacted, "[redacted]",
3968            "unparseable slash-run evader must fail closed: {redacted}"
3969        );
3970    }
3971
3972    #[test]
3973    fn redact_url_unparseable_later_window_userinfo_sentinel() {
3974        // The first `//` window ("ho st") carries no `@`, but a later
3975        // `//user:pass@evil/` window does. The scan must consider every
3976        // `//` window, not just the first, or the credentials echo.
3977        let raw = "http://ho st/a//user:pass@evil/";
3978        assert!(
3979            url::Url::parse(raw).is_err(),
3980            "fixture must be unparseable: {raw}"
3981        );
3982        let redacted = redact_url_for_diagnostics(raw);
3983        assert_eq!(
3984            redacted, "[redacted]",
3985            "userinfo in a later // window must fail closed: {redacted}"
3986        );
3987    }
3988
3989    #[test]
3990    fn redact_url_parsed_later_window_userinfo_masked() {
3991        // rust-url accepts this with host `h` and parks the userinfo bytes
3992        // in the path, so the accessor mask never fires. The parsed arm
3993        // must apply the same window-masking surgery as the string-based
3994        // redactors or the later window renders verbatim.
3995        let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
3996        assert!(
3997            !redacted.contains("user:pass"),
3998            "parsed later-window userinfo leaked: {redacted}"
3999        );
4000        assert!(
4001            redacted.contains("h//***@evil/"),
4002            "later window must be masked in place: {redacted}"
4003        );
4004    }
4005
4006    #[test]
4007    fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4008        // Real userinfo is masked by the accessor step; the window surgery
4009        // on the rendered string must not double-mask it (`***@h` stays),
4010        // and the later `x@y` path window must still be masked.
4011        let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4012        assert!(
4013            redacted.contains("***@h"),
4014            "accessor mask must survive the window surgery: {redacted}"
4015        );
4016        assert!(
4017            !redacted.contains("user:pass"),
4018            "real userinfo leaked: {redacted}"
4019        );
4020        assert!(
4021            !redacted.contains("x@y"),
4022            "later path window leaked: {redacted}"
4023        );
4024    }
4025
4026    #[test]
4027    fn redact_url_backslash_authority_ruling() {
4028        // Probe outcome: url::Url::parse accepts this input. http is a
4029        // special scheme, so backslashes normalize to slashes and the
4030        // credentials land in real userinfo
4031        // (`http://user:pass@evil/path`). The parsed arm must mask them
4032        // like any other userinfo.
4033        let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4034        assert!(
4035            redacted.contains("***@"),
4036            "backslash authority must be userinfo-masked: {redacted}"
4037        );
4038        assert!(
4039            !redacted.contains("user:pass"),
4040            "backslash authority must not leak credentials: {redacted}"
4041        );
4042    }
4043
4044    #[test]
4045    fn redact_url_masks_userinfo_and_query() {
4046        let redacted =
4047            redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4048        assert!(
4049            !redacted.contains("secretpass"),
4050            "password must be masked: {redacted}"
4051        );
4052        assert!(
4053            !redacted.contains("token=abc123"),
4054            "query must be masked: {redacted}"
4055        );
4056        assert!(
4057            !redacted.contains("user@"),
4058            "username must be masked: {redacted}"
4059        );
4060        assert!(
4061            redacted.contains("internal.example"),
4062            "host stays visible: {redacted}"
4063        );
4064        assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4065    }
4066
4067    #[test]
4068    fn redact_url_keeps_clean_urls_visible() {
4069        let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4070        assert_eq!(redacted, "https://api.example.com/v1/items");
4071    }
4072
4073    #[test]
4074    fn redact_url_masks_password_only_userinfo() {
4075        let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4076        assert!(
4077            !redacted.contains("pwsecret"),
4078            "password-only userinfo leaked: {redacted}"
4079        );
4080        assert_eq!(redacted, "http://***@host.example/");
4081
4082        let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4083        assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4084        assert_eq!(redacted, "http://***@host.example/api");
4085
4086        let redacted = redact_url_for_diagnostics("http://host.example/api");
4087        assert_eq!(redacted, "http://host.example/api");
4088    }
4089
4090    #[test]
4091    fn redact_url_truncates_unparseable() {
4092        let long = "x".repeat(1000);
4093        let redacted = redact_url_for_diagnostics(&long);
4094        assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4095    }
4096
4097    /// e_gpt stage-4: the 256-byte cap must not split an appended sentinel.
4098    /// The base is truncated at `256 - sentinel_len` BEFORE the sentinel is
4099    /// appended, so the sentinel always renders intact and the total stays
4100    /// ≤ 256. Both arms (parsed and unparseable) are exercised.
4101    #[test]
4102    fn redact_url_keeps_sentinels_intact_under_256_cap() {
4103        // Parsed arm: base (scheme+host+path) is 250 bytes, so byte 256
4104        // lands inside the appended `?[redacted]` (starts at 250) pre-fix.
4105        let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4106        assert!(
4107            url::Url::parse(&parsed).is_ok(),
4108            "fixture must parse: {parsed}"
4109        );
4110        let redacted = redact_url_for_diagnostics(&parsed);
4111        assert!(redacted.len() <= 256, "len={}", redacted.len());
4112        assert!(
4113            redacted.ends_with("?[redacted]"),
4114            "parsed-arm sentinel must render intact: {redacted}"
4115        );
4116
4117        // Unparseable arm: base is 249 bytes, so byte 256 lands inside the
4118        // appended `?[redacted]` (starts at 249) pre-fix.
4119        let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4120        assert!(
4121            url::Url::parse(&unparseable).is_err(),
4122            "fixture must not parse: {unparseable}"
4123        );
4124        let redacted = redact_url_for_diagnostics(&unparseable);
4125        assert!(redacted.len() <= 256, "len={}", redacted.len());
4126        assert!(
4127            redacted.ends_with("?[redacted]"),
4128            "unparseable-arm sentinel must render intact: {redacted}"
4129        );
4130    }
4131
4132    #[test]
4133    fn redact_url_suppresses_unparseable_authority_credentials() {
4134        let fixtures = [
4135            "http://u:secretpw@/x",
4136            "http://u:secretpw@host:99999/x",
4137            "http://u:secretpw@host:99999",
4138            "//u:secretpw@h/x",
4139        ];
4140        for fixture in fixtures {
4141            assert!(
4142                url::Url::parse(fixture).is_err(),
4143                "fixture must be unparseable: {fixture}"
4144            );
4145            let redacted = redact_url_for_diagnostics(fixture);
4146            assert_eq!(
4147                redacted, "[redacted]",
4148                "credential-bearing authority must be suppressed: {fixture}"
4149            );
4150        }
4151    }
4152
4153    #[test]
4154    fn redact_url_bd_repro_never_leaks_credentials() {
4155        let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4156        assert!(
4157            !redacted.contains("user:pa%ss"),
4158            "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4159        );
4160        assert!(
4161            !redacted.contains("pa%ss"),
4162            "bd rc-2i5c5 repro leaked password: {redacted}"
4163        );
4164    }
4165
4166    #[test]
4167    fn redact_url_unparseable_query_redacted_short_and_long() {
4168        let short = "http://host:99999/path?token=shortsecret";
4169        assert!(
4170            url::Url::parse(short).is_err(),
4171            "fixture must be unparseable: {short}"
4172        );
4173        let redacted = redact_url_for_diagnostics(short);
4174        assert_eq!(
4175            redacted, "http://host:99999/path?[redacted]",
4176            "short unparseable query must end with the suffix: {redacted}"
4177        );
4178
4179        let mut long = String::from("http://host:99999/");
4180        long.push_str(&"a".repeat(300));
4181        long.push_str("?token=longsecret");
4182        assert!(
4183            url::Url::parse(&long).is_err(),
4184            "fixture must be unparseable: {long}"
4185        );
4186        let redacted = redact_url_for_diagnostics(&long);
4187        assert!(
4188            !redacted.contains("longsecret"),
4189            "long unparseable query leaked a query byte: {redacted}"
4190        );
4191        assert!(
4192            redacted.len() <= 256,
4193            "long unparseable query must be capped: {} bytes",
4194            redacted.len()
4195        );
4196    }
4197
4198    #[test]
4199    fn redact_url_unparseable_sentinels_compose_both() {
4200        // Compose-both rule: one sentinel per distinct introducer found in
4201        // the raw string, in first-occurrence order.
4202        let raw = "ht tp://h.example/p?a=1#tok=x";
4203        assert!(
4204            url::Url::parse(raw).is_err(),
4205            "fixture must be unparseable: {raw}"
4206        );
4207        assert_eq!(
4208            redact_url_for_diagnostics(raw),
4209            "ht tp://h.example/p?[redacted]#[redacted]",
4210            "query and fragment sentinels must compose: {raw}"
4211        );
4212    }
4213
4214    #[test]
4215    fn redact_url_unparseable_sentinels_compose_fragment_first() {
4216        let raw = "ht tp://h.example/p#tok=x?a=1";
4217        assert!(
4218            url::Url::parse(raw).is_err(),
4219            "fixture must be unparseable: {raw}"
4220        );
4221        assert_eq!(
4222            redact_url_for_diagnostics(raw),
4223            "ht tp://h.example/p#[redacted]?[redacted]",
4224            "sentinels must follow the introducers' first-occurrence order: {raw}"
4225        );
4226    }
4227
4228    #[test]
4229    fn redact_url_unparseable_utf8_straddle_no_panic() {
4230        let fixture = format!("a{}", "é".repeat(200));
4231        let redacted = redact_url_for_diagnostics(&fixture);
4232        assert!(
4233            redacted.len() <= 256,
4234            "straddle fixture must be capped: {} bytes",
4235            redacted.len()
4236        );
4237        assert!(
4238            redacted.len() >= 253,
4239            "straddle fixture must not over-truncate: {} bytes",
4240            redacted.len()
4241        );
4242        assert!(
4243            fixture.is_char_boundary(redacted.len()),
4244            "cut must land on a UTF-8 char boundary: {} bytes",
4245            redacted.len()
4246        );
4247    }
4248
4249    #[test]
4250    fn redact_url_at_sign_outside_authority_window_visible() {
4251        let at_sign_in_path = "http://host:99999/x@y";
4252        assert!(
4253            url::Url::parse(at_sign_in_path).is_err(),
4254            "fixture must be unparseable: {at_sign_in_path}"
4255        );
4256        assert_eq!(
4257            redact_url_for_diagnostics(at_sign_in_path),
4258            at_sign_in_path,
4259            "at-sign in path must not be suppressed"
4260        );
4261        // mailto parses as a cannot-be-a-base URL (no is_err precondition).
4262        assert_eq!(
4263            redact_url_for_diagnostics("mailto:user@example.com"),
4264            "mailto:user@example.com",
4265            "at-sign in mailto must round-trip byte-identically"
4266        );
4267    }
4268
4269    #[test]
4270    fn truncate_error_body_caps_attacker_body() {
4271        let big = vec![b'A'; 10 * 1024 * 1024];
4272        let truncated = truncate_error_body(&big);
4273        assert!(
4274            truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
4275            "body must be capped near {} bytes, got {}",
4276            MAX_ERROR_RESPONSE_BODY_BYTES,
4277            truncated.len()
4278        );
4279        assert!(truncated.ends_with("...[truncated]"));
4280    }
4281
4282    #[test]
4283    fn truncate_error_body_keeps_small_body() {
4284        assert_eq!(truncate_error_body(b"boom"), "boom");
4285    }
4286
4287    #[test]
4288    fn test_http_config_defaults() {
4289        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
4290        assert_eq!(config.base_url, "http://localhost:8080/api");
4291        assert!(config.http_method.is_none());
4292        assert!(config.throw_exception_on_failure);
4293        assert_eq!(config.ok_status_code_range, (200, 299));
4294        assert!(config.response_timeout.is_none());
4295        assert!(matches!(config.auth, HttpAuth::None));
4296        assert!(!config.bridge_endpoint);
4297        assert!(!config.connection_close);
4298    }
4299
4300    #[test]
4301    fn test_http_config_scheme() {
4302        // UriConfig trait method returns "http" as primary scheme
4303        assert_eq!(HttpEndpointConfig::scheme(), "http");
4304    }
4305
4306    #[test]
4307    fn test_http_config_from_components() {
4308        // Test from_components directly (trait method)
4309        let components = camel_component_api::UriComponents {
4310            scheme: "https".to_string(),
4311            path: "//api.example.com/v1".to_string(),
4312            params: std::collections::HashMap::from([(
4313                "httpMethod".to_string(),
4314                "POST".to_string(),
4315            )]),
4316            raw_query: None,
4317        };
4318        let config = HttpEndpointConfig::from_components(components).unwrap();
4319        assert_eq!(config.base_url, "https://api.example.com/v1");
4320        assert_eq!(config.http_method, Some("POST".to_string()));
4321    }
4322
4323    #[test]
4324    fn test_http_config_with_options() {
4325        let config = HttpEndpointConfig::from_uri(
4326            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
4327        ).unwrap();
4328        assert_eq!(config.base_url, "https://api.example.com/v1");
4329        assert_eq!(config.http_method, Some("PUT".to_string()));
4330        assert!(!config.throw_exception_on_failure);
4331        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
4332    }
4333
4334    #[test]
4335    fn test_http_endpoint_config_auth_and_headers_options() {
4336        let config = HttpEndpointConfig::from_uri(
4337            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
4338        )
4339        .unwrap();
4340
4341        assert!(matches!(
4342            config.auth,
4343            HttpAuth::Basic { username, password } if username == "u" && password == "p"
4344        ));
4345        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
4346        assert!(config.bridge_endpoint);
4347        assert!(config.connection_close);
4348        assert_eq!(
4349            config.skip_request_headers,
4350            vec!["authorization".to_string(), "x-secret".to_string()]
4351        );
4352        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
4353    }
4354
4355    #[test]
4356    fn test_http_endpoint_config_bearer_auth() {
4357        let config = HttpEndpointConfig::from_uri(
4358            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
4359        )
4360        .unwrap();
4361        assert!(matches!(
4362            config.auth,
4363            HttpAuth::Bearer { token } if token == "t"
4364        ));
4365    }
4366
4367    #[test]
4368    fn rejects_cookie_handling_inmemory() {
4369        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
4370        match result {
4371            Err(CamelError::InvalidUri(msg)) => {
4372                assert!(
4373                    msg.contains("cookieHandling is not supported"),
4374                    "expected rejection message, got: {msg}"
4375                );
4376            }
4377            other => panic!("expected InvalidUri error, got: {other:?}"),
4378        }
4379    }
4380
4381    #[test]
4382    fn rejects_cookie_handling_disabled() {
4383        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
4384        match result {
4385            Err(CamelError::InvalidUri(msg)) => {
4386                assert!(
4387                    msg.contains("cookieHandling is not supported"),
4388                    "expected rejection message, got: {msg}"
4389                );
4390            }
4391            other => panic!("expected InvalidUri error, got: {other:?}"),
4392        }
4393    }
4394
4395    #[test]
4396    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
4397        let config = HttpConfig::default()
4398            .with_response_timeout_ms(999)
4399            .with_allow_internal(true)
4400            .with_blocked_hosts(vec!["evil.com".to_string()])
4401            .with_max_body_size(12345);
4402        let endpoint =
4403            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4404        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4405        assert!(endpoint.allow_internal);
4406        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4407        assert_eq!(endpoint.max_body_size, 12345);
4408    }
4409
4410    #[test]
4411    fn test_from_uri_with_defaults_uri_overrides_config() {
4412        let config = HttpConfig::default()
4413            .with_response_timeout_ms(999)
4414            .with_allow_internal(true)
4415            .with_blocked_hosts(vec!["evil.com".to_string()])
4416            .with_max_body_size(12345);
4417        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4418            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4419            &config,
4420        )
4421        .unwrap();
4422        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4423        assert!(!endpoint.allow_internal);
4424        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4425        assert_eq!(endpoint.max_body_size, 99);
4426    }
4427
4428    #[test]
4429    fn test_http_config_ok_status_range() {
4430        let config =
4431            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4432        assert_eq!(config.ok_status_code_range, (200, 204));
4433    }
4434
4435    #[test]
4436    fn test_http_config_wrong_scheme() {
4437        let result = HttpEndpointConfig::from_uri("file:/tmp");
4438        assert!(result.is_err());
4439    }
4440
4441    #[test]
4442    fn test_http_component_scheme() {
4443        let component = HttpComponent::new();
4444        assert_eq!(component.scheme(), "http");
4445    }
4446
4447    #[test]
4448    fn test_https_component_scheme() {
4449        let component = HttpsComponent::new();
4450        assert_eq!(component.scheme(), "https");
4451    }
4452
4453    #[test]
4454    fn test_http_endpoint_creates_consumer() {
4455        let component = HttpComponent::new();
4456        let ctx = NoOpComponentContext;
4457        let endpoint = component
4458            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
4459            .unwrap();
4460        assert!(endpoint.create_consumer(rt()).is_ok());
4461    }
4462
4463    #[test]
4464    fn test_https_endpoint_creates_consumer_errors_without_tls() {
4465        let component = HttpsComponent::new();
4466        let ctx = NoOpComponentContext;
4467        let endpoint = component
4468            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
4469            .unwrap();
4470        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
4471        assert!(endpoint.create_consumer(rt()).is_err());
4472    }
4473
4474    #[test]
4475    fn test_http_endpoint_creates_producer() {
4476        let ctx = test_producer_ctx();
4477        let component = HttpComponent::new();
4478        let endpoint_ctx = NoOpComponentContext;
4479        let endpoint = component
4480            .create_endpoint("http://localhost/api", &endpoint_ctx)
4481            .unwrap();
4482        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
4483    }
4484
4485    // -----------------------------------------------------------------------
4486    // Producer tests
4487    // -----------------------------------------------------------------------
4488
4489    #[tokio::test]
4490    async fn test_producer_with_token_provider() {
4491        use camel_auth::oauth2::TokenProvider;
4492        use tower::ServiceExt;
4493
4494        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
4495            Arc::new(std::sync::Mutex::new(None));
4496        let captured_clone = Arc::clone(&captured_auth);
4497
4498        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4499        let port = listener.local_addr().unwrap().port();
4500
4501        let _handle = tokio::spawn(async move {
4502            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4503            if let Ok((mut stream, _)) = listener.accept().await {
4504                let mut buf = vec![0u8; 8192];
4505                let n = stream.read(&mut buf).await.unwrap_or(0);
4506                let request = String::from_utf8_lossy(&buf[..n]).to_string();
4507                let auth = request
4508                    .lines()
4509                    .find(|l| l.to_lowercase().starts_with("authorization:"))
4510                    .map(|l| {
4511                        l.split(':')
4512                            .nth(1)
4513                            .map(|s| s.trim().to_string())
4514                            .unwrap_or_default()
4515                    });
4516                *captured_clone.lock().unwrap() = auth;
4517                let body = r#"{"echo":"ok"}"#;
4518                let resp = format!(
4519                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4520                    body.len(),
4521                    body
4522                );
4523                let _ = stream.write_all(resp.as_bytes()).await;
4524            }
4525        });
4526
4527        #[derive(Debug)]
4528        struct StaticProvider;
4529        #[async_trait::async_trait]
4530        impl TokenProvider for StaticProvider {
4531            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
4532                Ok("injected-token".into())
4533            }
4534        }
4535
4536        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
4537        let ctx = test_producer_ctx();
4538        let component = HttpComponent::new();
4539        let endpoint_ctx = NoOpComponentContext;
4540        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
4541        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4542
4543        let exchange = Exchange::new(Message::new("hello"));
4544
4545        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
4546        let mut layered = layer.layer(producer);
4547        let result = layered.ready().await.unwrap().call(exchange).await;
4548        assert!(result.is_ok(), "producer call failed: {:?}", result);
4549
4550        tokio::time::sleep(Duration::from_millis(100)).await;
4551        let auth = captured_auth.lock().unwrap().take();
4552        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4553    }
4554
4555    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4556        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4557        let addr = listener.local_addr().unwrap();
4558        let url = format!("http://127.0.0.1:{}", addr.port());
4559
4560        let handle = tokio::spawn(async move {
4561            loop {
4562                if let Ok((mut stream, _)) = listener.accept().await {
4563                    tokio::spawn(async move {
4564                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4565                        let mut buf = vec![0u8; 4096];
4566                        let n = stream.read(&mut buf).await.unwrap_or(0);
4567                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
4568
4569                        let method = request.split_whitespace().next().unwrap_or("GET");
4570
4571                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4572                        let response = format!(
4573                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4574                            body.len(),
4575                            body
4576                        );
4577                        let _ = stream.write_all(response.as_bytes()).await;
4578                    });
4579                }
4580            }
4581        });
4582
4583        (url, handle)
4584    }
4585
4586    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4587        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4588        let addr = listener.local_addr().unwrap();
4589        let url = format!("http://127.0.0.1:{}", addr.port());
4590
4591        let handle = tokio::spawn(async move {
4592            loop {
4593                if let Ok((mut stream, _)) = listener.accept().await {
4594                    let status = status;
4595                    tokio::spawn(async move {
4596                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4597                        let mut buf = vec![0u8; 4096];
4598                        let _ = stream.read(&mut buf).await;
4599
4600                        let status_text = match status {
4601                            404 => "Not Found",
4602                            500 => "Internal Server Error",
4603                            _ => "Error",
4604                        };
4605                        let body = "error body";
4606                        let response = format!(
4607                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4608                            status,
4609                            status_text,
4610                            body.len(),
4611                            body
4612                        );
4613                        let _ = stream.write_all(response.as_bytes()).await;
4614                    });
4615                }
4616            }
4617        });
4618
4619        (url, handle)
4620    }
4621
4622    async fn start_request_capturing_server() -> (
4623        String,
4624        Arc<std::sync::Mutex<Option<String>>>,
4625        tokio::task::JoinHandle<()>,
4626    ) {
4627        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4628        let port = listener.local_addr().unwrap().port();
4629        let url = format!("http://127.0.0.1:{port}");
4630        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
4631        let captured_clone = Arc::clone(&captured);
4632        let handle = tokio::spawn(async move {
4633            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4634            if let Ok((mut stream, _)) = listener.accept().await {
4635                let mut buf = vec![0u8; 16384];
4636                let n = stream.read(&mut buf).await.unwrap_or(0);
4637                let request = String::from_utf8_lossy(&buf[..n]).to_string();
4638                if request.contains("\r\n\r\n") {
4639                    *captured_clone.lock().unwrap() = Some(request);
4640                }
4641                let body = r#"{"echo":"ok"}"#;
4642                let resp = format!(
4643                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4644                    body.len(),
4645                    body
4646                );
4647                let _ = stream.write_all(resp.as_bytes()).await;
4648            }
4649        });
4650        (url, captured, handle)
4651    }
4652
4653    #[tokio::test]
4654    async fn test_http_producer_get_request() {
4655        use tower::ServiceExt;
4656
4657        let (url, _handle) = start_test_server().await;
4658        let ctx = test_producer_ctx();
4659
4660        let component = HttpComponent::new();
4661        let endpoint_ctx = NoOpComponentContext;
4662        let endpoint = component
4663            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4664            .unwrap();
4665        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4666
4667        let exchange = Exchange::new(Message::default());
4668        let result = producer.oneshot(exchange).await.unwrap();
4669
4670        let status = result
4671            .input
4672            .header("CamelHttpResponseCode")
4673            .and_then(|v| v.as_u64())
4674            .unwrap();
4675        assert_eq!(status, 200);
4676
4677        assert!(!result.input.body.is_empty());
4678    }
4679
4680    #[tokio::test]
4681    async fn producer_excludes_host_and_framing() {
4682        use tower::ServiceExt;
4683
4684        let (url, captured, _handle) = start_request_capturing_server().await;
4685        let ctx = test_producer_ctx();
4686        let component = HttpComponent::new();
4687        let endpoint_ctx = NoOpComponentContext;
4688        let endpoint = component
4689            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4690            .unwrap();
4691        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4692
4693        let mut exchange = Exchange::new(Message::default());
4694        exchange.input.set_header("Host", "localhost");
4695        exchange.input.set_header("Content-Length", "42");
4696        exchange.input.set_header("Connection", "keep-alive");
4697        exchange.input.set_header("Upgrade", "h2c");
4698
4699        let result = producer.oneshot(exchange).await;
4700        assert!(result.is_ok(), "producer call failed: {:?}", result);
4701
4702        tokio::time::sleep(Duration::from_millis(100)).await;
4703        let request = captured
4704            .lock()
4705            .unwrap()
4706            .take()
4707            .expect("no outbound request captured");
4708        let lower = request.to_ascii_lowercase();
4709        assert!(
4710            !lower.contains("\r\nhost: localhost"),
4711            "forwarded Host: localhost must be stripped\n{request}"
4712        );
4713        assert!(
4714            !lower.contains("content-length: 42"),
4715            "exchange Content-Length must not be copied\n{request}"
4716        );
4717        assert!(
4718            !lower.lines().any(|l| l.starts_with("connection:")),
4719            "Connection header must not be forwarded\n{request}"
4720        );
4721        assert!(
4722            !lower.lines().any(|l| l.starts_with("upgrade:")),
4723            "Upgrade header must not be forwarded\n{request}"
4724        );
4725        let host_header = lower
4726            .lines()
4727            .find(|l| l.starts_with("host:"))
4728            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
4729            .expect("outbound Host header must be set by reqwest");
4730        assert!(
4731            host_header.starts_with("127.0.0.1:"),
4732            "outbound Host '{host_header}' must match the capture-server address"
4733        );
4734    }
4735
4736    #[tokio::test]
4737    async fn producer_forwards_request_only_headers() {
4738        use tower::ServiceExt;
4739
4740        let (url, captured, _handle) = start_request_capturing_server().await;
4741        let ctx = test_producer_ctx();
4742        let component = HttpComponent::new();
4743        let endpoint_ctx = NoOpComponentContext;
4744        let endpoint = component
4745            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4746            .unwrap();
4747        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4748
4749        let mut exchange = Exchange::new(Message::default());
4750        exchange.input.set_header("Accept", "application/json");
4751        exchange.input.set_header("User-Agent", "myclient/1.0");
4752
4753        let result = producer.oneshot(exchange).await;
4754        assert!(result.is_ok(), "producer call failed: {:?}", result);
4755
4756        tokio::time::sleep(Duration::from_millis(100)).await;
4757        let request = captured
4758            .lock()
4759            .unwrap()
4760            .take()
4761            .expect("no outbound request captured");
4762        let lower = request.to_ascii_lowercase();
4763        assert!(
4764            lower.contains("accept: application/json"),
4765            "request-only Accept header must be forwarded\n{request}"
4766        );
4767        assert!(
4768            lower.contains("user-agent: myclient/1.0"),
4769            "request-only User-Agent header must be forwarded\n{request}"
4770        );
4771    }
4772
4773    // -----------------------------------------------------------------------
4774    // Configured-header construction failures are surfaced, never silent
4775    // (rc-jbs1v)
4776    // -----------------------------------------------------------------------
4777
4778    /// Build an endpoint whose URI parses normally but whose `user_agent`
4779    /// and `auth` are then overridden programmatically, so CRLF-bearing
4780    /// test values never pass through URI parsing.
4781    fn endpoint_with_config_overrides(
4782        base_url: &str,
4783        user_agent: Option<String>,
4784        auth: HttpAuth,
4785    ) -> HttpEndpoint {
4786        let uri = format!("{base_url}/api/test?allowInternal=true");
4787        let mut config =
4788            HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
4789        config.user_agent = user_agent;
4790        config.auth = auth;
4791        HttpEndpoint {
4792            uri: uri.clone(),
4793            config,
4794            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
4795            client: reqwest::Client::new(),
4796            pinned_cache: Arc::new(PinnedClientCache::new(
4797                PINNED_CLIENT_TTL,
4798                PINNED_CLIENT_MAX_ENTRIES,
4799            )),
4800            http_config: HttpConfig::default(),
4801        }
4802    }
4803
4804    /// A configured user-agent / bearer token that fails `HeaderValue`
4805    /// construction must be dropped with a DEBUG record (name + reason
4806    /// only, never the value — ADR-0051) and reach the wire absent, while
4807    /// a valid config passes through unchanged.
4808    #[tracing_test::traced_test]
4809    #[tokio::test]
4810    async fn producer_invalid_configured_headers_surfaced() {
4811        use tower::ServiceExt;
4812
4813        let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
4814        let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
4815        let ctx = test_producer_ctx();
4816
4817        let bad_producer = endpoint_with_config_overrides(
4818            &bad_url,
4819            Some("bad\r\nua".to_string()),
4820            HttpAuth::Bearer {
4821                token: "tok\r\nen".to_string(),
4822            },
4823        )
4824        .create_producer(rt(), &ctx)
4825        .unwrap();
4826        let ok_producer = endpoint_with_config_overrides(
4827            &ok_url,
4828            Some("httpsweep-ok/1".to_string()),
4829            HttpAuth::Bearer {
4830                token: "valid-token".to_string(),
4831            },
4832        )
4833        .create_producer(rt(), &ctx)
4834        .unwrap();
4835
4836        let bad_exchange = Exchange::new(Message::default());
4837        let ok_exchange = Exchange::new(Message::default());
4838        let bad_cid = bad_exchange.correlation_id().to_string();
4839        let ok_cid = ok_exchange.correlation_id().to_string();
4840
4841        let bad_result = bad_producer.oneshot(bad_exchange).await;
4842        assert!(
4843            bad_result.is_ok(),
4844            "invalid-config producer call failed: {bad_result:?}"
4845        );
4846        let ok_result = ok_producer.oneshot(ok_exchange).await;
4847        assert!(
4848            ok_result.is_ok(),
4849            "valid-config producer call failed: {ok_result:?}"
4850        );
4851
4852        tokio::time::sleep(Duration::from_millis(100)).await;
4853        let bad_request = bad_captured
4854            .lock()
4855            .unwrap()
4856            .take()
4857            .expect("no outbound request captured");
4858        let ok_request = ok_captured
4859            .lock()
4860            .unwrap()
4861            .take()
4862            .expect("no outbound request captured");
4863
4864        // Invalid config: neither header reaches the wire. Value-absence,
4865        // not "any UA" — reqwest may inject a default user-agent.
4866        let bad_lower = bad_request.to_ascii_lowercase();
4867        assert!(
4868            !bad_lower.lines().any(|l| l.starts_with("authorization:")),
4869            "invalid Bearer token must not reach the wire\n{bad_request}"
4870        );
4871        assert!(
4872            !bad_request.contains("bad\r\nua"),
4873            "invalid configured user-agent must not reach the wire\n{bad_request}"
4874        );
4875
4876        logs_assert(|lines: &[&str]| {
4877            let drops: Vec<&&str> = lines
4878                .iter()
4879                .filter(|l| {
4880                    l.contains("outbound header dropped")
4881                        && l.contains(&format!("correlation_id={bad_cid}"))
4882                })
4883                .collect();
4884            if drops.len() != 2 {
4885                return Err(format!(
4886                    "expected exactly 2 drop records for {bad_cid}, found {}",
4887                    drops.len()
4888                ));
4889            }
4890            let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
4891            let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
4892            let reason_ok = drops
4893                .iter()
4894                .all(|l| l.contains("outbound header dropped: invalid header value"));
4895            match (has_ua, has_auth, reason_ok) {
4896                (true, true, true) => Ok(()),
4897                _ => Err(format!(
4898                    "drop records mismatched: user-agent={has_ua} \
4899                     authorization={has_auth} reason-ok={reason_ok}"
4900                )),
4901            }
4902        });
4903        logs_assert(|lines: &[&str]| {
4904            if lines
4905                .iter()
4906                .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
4907            {
4908                Err("sentinel CRLF values leaked into logs".to_string())
4909            } else {
4910                Ok(())
4911            }
4912        });
4913
4914        // Valid config: both headers reach the wire exactly as configured,
4915        // with zero drop records.
4916        let ok_lower = ok_request.to_ascii_lowercase();
4917        assert!(
4918            ok_lower.contains("user-agent: httpsweep-ok/1"),
4919            "valid configured user-agent must reach the wire\n{ok_request}"
4920        );
4921        assert!(
4922            ok_lower.contains("authorization: bearer valid-token"),
4923            "valid Bearer token must reach the wire\n{ok_request}"
4924        );
4925        logs_assert(|lines: &[&str]| {
4926            let hits = lines
4927                .iter()
4928                .filter(|l| {
4929                    l.contains("outbound header dropped")
4930                        && l.contains(&format!("correlation_id={ok_cid}"))
4931                })
4932                .count();
4933            match hits {
4934                0 => Ok(()),
4935                n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
4936            }
4937        });
4938    }
4939
4940    #[tokio::test]
4941    async fn producer_honours_skip_request_headers() {
4942        use tower::ServiceExt;
4943
4944        let (url, captured, _handle) = start_request_capturing_server().await;
4945        let ctx = test_producer_ctx();
4946        let component = HttpComponent::new();
4947        let endpoint_ctx = NoOpComponentContext;
4948        let endpoint = component
4949            .create_endpoint(
4950                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
4951                &endpoint_ctx,
4952            )
4953            .unwrap();
4954        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4955
4956        let mut exchange = Exchange::new(Message::default());
4957        exchange.input.set_header("Authorization", "Bearer x");
4958
4959        let result = producer.oneshot(exchange).await;
4960        assert!(result.is_ok(), "producer call failed: {:?}", result);
4961
4962        tokio::time::sleep(Duration::from_millis(100)).await;
4963        let request = captured
4964            .lock()
4965            .unwrap()
4966            .take()
4967            .expect("no outbound request captured");
4968        assert!(
4969            !request.to_ascii_lowercase().contains("authorization"),
4970            "Authorization must be stripped by skipRequestHeaders\n{request}"
4971        );
4972    }
4973
4974    #[tokio::test]
4975    async fn producer_stringifies_scalar_header_values_on_wire() {
4976        use tower::ServiceExt;
4977
4978        let (url, captured, _handle) = start_request_capturing_server().await;
4979        let ctx = test_producer_ctx();
4980        let component = HttpComponent::new();
4981        let endpoint_ctx = NoOpComponentContext;
4982        let endpoint = component
4983            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4984            .unwrap();
4985        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4986
4987        let mut exchange = Exchange::new(Message::default());
4988        exchange.input.set_header("X-Retries", serde_json::json!(3));
4989        exchange
4990            .input
4991            .set_header("X-Enabled", serde_json::json!(true));
4992        exchange
4993            .input
4994            .set_header("X-Obj", serde_json::json!({"a": 1}));
4995
4996        let result = producer.oneshot(exchange).await;
4997        assert!(result.is_ok(), "producer call failed: {:?}", result);
4998
4999        tokio::time::sleep(Duration::from_millis(100)).await;
5000        let request = captured
5001            .lock()
5002            .unwrap()
5003            .take()
5004            .expect("no outbound request captured");
5005        let lower = request.to_ascii_lowercase();
5006        assert!(
5007            lower.contains("x-retries: 3"),
5008            "numeric header must reach the wire stringified\n{request}"
5009        );
5010        assert!(
5011            lower.contains("x-enabled: true"),
5012            "bool header must reach the wire stringified\n{request}"
5013        );
5014        assert!(
5015            !lower.contains("x-obj:"),
5016            "object header has no single-value form and must not reach the wire\n{request}"
5017        );
5018    }
5019
5020    #[tokio::test]
5021    async fn test_http_producer_post_with_body() {
5022        use tower::ServiceExt;
5023
5024        let (url, _handle) = start_test_server().await;
5025        let ctx = test_producer_ctx();
5026
5027        let component = HttpComponent::new();
5028        let endpoint_ctx = NoOpComponentContext;
5029        let endpoint = component
5030            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
5031            .unwrap();
5032        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5033
5034        let exchange = Exchange::new(Message::new("request body"));
5035        let result = producer.oneshot(exchange).await.unwrap();
5036
5037        let status = result
5038            .input
5039            .header("CamelHttpResponseCode")
5040            .and_then(|v| v.as_u64())
5041            .unwrap();
5042        assert_eq!(status, 200);
5043    }
5044
5045    #[tokio::test]
5046    async fn test_http_producer_method_from_header() {
5047        use tower::ServiceExt;
5048
5049        let (url, _handle) = start_test_server().await;
5050        let ctx = test_producer_ctx();
5051
5052        let component = HttpComponent::new();
5053        let endpoint_ctx = NoOpComponentContext;
5054        let endpoint = component
5055            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5056            .unwrap();
5057        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5058
5059        let mut exchange = Exchange::new(Message::default());
5060        exchange.input.set_header(
5061            "CamelHttpMethod",
5062            serde_json::Value::String("DELETE".to_string()),
5063        );
5064
5065        let result = producer.oneshot(exchange).await.unwrap();
5066        let status = result
5067            .input
5068            .header("CamelHttpResponseCode")
5069            .and_then(|v| v.as_u64())
5070            .unwrap();
5071        assert_eq!(status, 200);
5072    }
5073
5074    #[tokio::test]
5075    async fn test_http_producer_forced_method() {
5076        use tower::ServiceExt;
5077
5078        let (url, _handle) = start_test_server().await;
5079        let ctx = test_producer_ctx();
5080
5081        let component = HttpComponent::new();
5082        let endpoint_ctx = NoOpComponentContext;
5083        let endpoint = component
5084            .create_endpoint(
5085                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
5086                &endpoint_ctx,
5087            )
5088            .unwrap();
5089        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5090
5091        let exchange = Exchange::new(Message::default());
5092        let result = producer.oneshot(exchange).await.unwrap();
5093
5094        let status = result
5095            .input
5096            .header("CamelHttpResponseCode")
5097            .and_then(|v| v.as_u64())
5098            .unwrap();
5099        assert_eq!(status, 200);
5100    }
5101
5102    #[tokio::test]
5103    async fn test_http_producer_throw_exception_on_failure() {
5104        use tower::ServiceExt;
5105
5106        let (url, _handle) = start_status_server(404).await;
5107        let ctx = test_producer_ctx();
5108
5109        let component = HttpComponent::new();
5110        let endpoint_ctx = NoOpComponentContext;
5111        let endpoint = component
5112            .create_endpoint(
5113                &format!("{url}/not-found?allowInternal=true"),
5114                &endpoint_ctx,
5115            )
5116            .unwrap();
5117        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5118
5119        let exchange = Exchange::new(Message::default());
5120        let result = producer.oneshot(exchange).await;
5121        assert!(result.is_err());
5122
5123        match result.unwrap_err() {
5124            CamelError::HttpOperationFailed { status_code, .. } => {
5125                assert_eq!(status_code, 404);
5126            }
5127            e => panic!("Expected HttpOperationFailed, got: {e}"),
5128        }
5129    }
5130
5131    #[tokio::test]
5132    async fn test_http_producer_no_throw_on_failure() {
5133        use tower::ServiceExt;
5134
5135        let (url, _handle) = start_status_server(500).await;
5136        let ctx = test_producer_ctx();
5137
5138        let component = HttpComponent::new();
5139        let endpoint_ctx = NoOpComponentContext;
5140        let endpoint = component
5141            .create_endpoint(
5142                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
5143                &endpoint_ctx,
5144            )
5145            .unwrap();
5146        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5147
5148        let exchange = Exchange::new(Message::default());
5149        let result = producer.oneshot(exchange).await.unwrap();
5150
5151        let status = result
5152            .input
5153            .header("CamelHttpResponseCode")
5154            .and_then(|v| v.as_u64())
5155            .unwrap();
5156        assert_eq!(status, 500);
5157    }
5158
5159    #[tokio::test]
5160    async fn test_http_producer_uri_override() {
5161        use tower::ServiceExt;
5162
5163        let (url, _handle) = start_test_server().await;
5164        let ctx = test_producer_ctx();
5165
5166        let component = HttpComponent::new();
5167        let endpoint_ctx = NoOpComponentContext;
5168        let endpoint = component
5169            .create_endpoint(
5170                "http://localhost:1/does-not-exist?allowInternal=true",
5171                &endpoint_ctx,
5172            )
5173            .unwrap();
5174        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5175
5176        let mut exchange = Exchange::new(Message::default());
5177        exchange.input.set_header(
5178            "CamelHttpUri",
5179            serde_json::Value::String(format!("{url}/api")),
5180        );
5181
5182        let result = producer.oneshot(exchange).await.unwrap();
5183        let status = result
5184            .input
5185            .header("CamelHttpResponseCode")
5186            .and_then(|v| v.as_u64())
5187            .unwrap();
5188        assert_eq!(status, 200);
5189    }
5190
5191    #[tokio::test]
5192    async fn test_http_producer_response_headers_mapped() {
5193        use tower::ServiceExt;
5194
5195        let (url, _handle) = start_test_server().await;
5196        let ctx = test_producer_ctx();
5197
5198        let component = HttpComponent::new();
5199        let endpoint_ctx = NoOpComponentContext;
5200        let endpoint = component
5201            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5202            .unwrap();
5203        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5204
5205        let exchange = Exchange::new(Message::default());
5206        let result = producer.oneshot(exchange).await.unwrap();
5207
5208        assert!(
5209            result.input.header("Content-Type").is_some(),
5210            "Response should have Content-Type header"
5211        );
5212        assert!(result.input.header("CamelHttpResponseText").is_some());
5213    }
5214
5215    // -----------------------------------------------------------------------
5216    // Bug fix tests: Client configuration per-endpoint
5217    // -----------------------------------------------------------------------
5218
5219    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
5220        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5221        let addr = listener.local_addr().unwrap();
5222        let url = format!("http://127.0.0.1:{}", addr.port());
5223
5224        let handle = tokio::spawn(async move {
5225            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5226            loop {
5227                if let Ok((mut stream, _)) = listener.accept().await {
5228                    tokio::spawn(async move {
5229                        let mut buf = vec![0u8; 4096];
5230                        let n = stream.read(&mut buf).await.unwrap_or(0);
5231                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
5232
5233                        // Check if this is a request to /final
5234                        if request.contains("GET /final") {
5235                            let body = r#"{"status":"final"}"#;
5236                            let response = format!(
5237                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5238                                body.len(),
5239                                body
5240                            );
5241                            let _ = stream.write_all(response.as_bytes()).await;
5242                        } else {
5243                            // Redirect to /final
5244                            // Connection: close stops the client pooling the
5245                            // connection the server drops right after this
5246                            // response (pooled-race, rc-u3aw class).
5247                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5248                            let _ = stream.write_all(response.as_bytes()).await;
5249                        }
5250                    });
5251                }
5252            }
5253        });
5254
5255        (url, handle)
5256    }
5257
5258    struct CapturedRequest {
5259        method: String,
5260        path: String,
5261        body: Vec<u8>,
5262        content_length: Option<String>,
5263        transfer_encoding: Option<String>,
5264    }
5265
5266    /// Parse a request head plus its Content-Length-driven body from a freshly
5267    /// accepted connection. Returns `None` if the client closes before sending
5268    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
5269    /// keep-alive connections and never sends FIN) and does NOT rely on a
5270    /// single fixed-size read (a segmented small body would flake).
5271    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
5272        use tokio::io::AsyncReadExt;
5273
5274        // Read the request head (up to and including the terminating CRLF CRLF).
5275        let mut buf: Vec<u8> = Vec::new();
5276        let mut chunk = [0u8; 4096];
5277        let head_end: usize;
5278        loop {
5279            let n = stream.read(&mut chunk).await.unwrap_or(0);
5280            if n == 0 {
5281                return None;
5282            }
5283            buf.extend_from_slice(&chunk[..n]);
5284            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
5285                head_end = pos + 4;
5286                break;
5287            }
5288        }
5289
5290        // Parse the request head.
5291        let head = String::from_utf8_lossy(&buf[..head_end]);
5292        let mut lines = head.split("\r\n");
5293        let request_line = lines.next().unwrap_or("");
5294        let mut parts = request_line.split_whitespace();
5295        let method = parts.next().unwrap_or("").to_string();
5296        let path = parts.next().unwrap_or("").to_string();
5297
5298        let mut content_length: Option<String> = None;
5299        let mut transfer_encoding: Option<String> = None;
5300        for line in lines {
5301            if let Some((name, value)) = line.split_once(':') {
5302                let name = name.trim().to_ascii_lowercase();
5303                let value = value.trim().to_string();
5304                if name == "content-length" {
5305                    content_length = Some(value);
5306                } else if name == "transfer-encoding" {
5307                    transfer_encoding = Some(value);
5308                }
5309            }
5310        }
5311
5312        // Content-Length-driven exact read. A missing header means a 0-length body.
5313        let body_len: usize = content_length
5314            .as_deref()
5315            .and_then(|v| v.parse::<usize>().ok())
5316            .unwrap_or(0);
5317
5318        let mut body: Vec<u8> = buf[head_end..].to_vec();
5319        while body.len() < body_len {
5320            let n = stream.read(&mut chunk).await.unwrap_or(0);
5321            if n == 0 {
5322                break;
5323            }
5324            body.extend_from_slice(&chunk[..n]);
5325        }
5326        body.truncate(body_len);
5327
5328        Some(CapturedRequest {
5329            method,
5330            path,
5331            body,
5332            content_length,
5333            transfer_encoding,
5334        })
5335    }
5336
5337    /// A raw-TCP capture server. Each connection parses the request head, then
5338    /// performs a Content-Length-driven exact read of the body (see
5339    /// [`capture_request`]). Each connection is dropped after the response so
5340    /// every hop opens a fresh connection.
5341    async fn start_capture_server() -> (
5342        String,
5343        tokio::task::JoinHandle<()>,
5344        Arc<Mutex<Vec<CapturedRequest>>>,
5345    ) {
5346        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5347        let addr = listener.local_addr().unwrap();
5348        let url = format!("http://127.0.0.1:{}", addr.port());
5349
5350        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5351        let captured_for_return = Arc::clone(&captured);
5352
5353        let handle = tokio::spawn(async move {
5354            use tokio::io::AsyncWriteExt;
5355            loop {
5356                if let Ok((mut stream, _)) = listener.accept().await {
5357                    let captured = Arc::clone(&captured);
5358                    tokio::spawn(async move {
5359                        let Some(req) = capture_request(&mut stream).await else {
5360                            return;
5361                        };
5362                        captured.lock().unwrap().push(req);
5363
5364                        // 200 OK with Content-Length: 0 and no body, then drop
5365                        // the stream so the client opens a fresh connection.
5366                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
5367                        let _ = stream.write_all(response.as_bytes()).await;
5368                    });
5369                }
5370            }
5371        });
5372
5373        (url, handle, captured_for_return)
5374    }
5375
5376    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
5377    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
5378    /// whose `/final` path answers `200 OK` with an empty body. Every hop
5379    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
5380    /// the connection after responding so each hop is a fresh connection.
5381    async fn start_redirect_capture_server() -> (
5382        String,
5383        tokio::task::JoinHandle<()>,
5384        Arc<Mutex<Vec<CapturedRequest>>>,
5385    ) {
5386        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5387        let addr = listener.local_addr().unwrap();
5388        let url = format!("http://127.0.0.1:{}", addr.port());
5389
5390        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5391        let captured_for_return = Arc::clone(&captured);
5392
5393        let handle = tokio::spawn(async move {
5394            use tokio::io::AsyncWriteExt;
5395            loop {
5396                if let Ok((mut stream, _)) = listener.accept().await {
5397                    let captured = Arc::clone(&captured);
5398                    tokio::spawn(async move {
5399                        let Some(req) = capture_request(&mut stream).await else {
5400                            return;
5401                        };
5402                        let path = req.path.clone();
5403                        captured.lock().unwrap().push(req);
5404
5405                        let (status_line, location) = match path.as_str() {
5406                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5407                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5408                            "/final" => ("HTTP/1.1 200 OK", None),
5409                            _ => ("HTTP/1.1 404 Not Found", None),
5410                        };
5411
5412                        let response = match location {
5413                            // Connection: close stops the client pooling the
5414                            // connection this handler drops right after the
5415                            // response (pooled-race, rc-u3aw class).
5416                            Some(loc) => format!(
5417                                "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5418                            ),
5419                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5420                        };
5421                        let _ = stream.write_all(response.as_bytes()).await;
5422                    });
5423                }
5424            }
5425        });
5426
5427        (url, handle, captured_for_return)
5428    }
5429
5430    #[tokio::test]
5431    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5432        use tower::ServiceExt;
5433
5434        let (url, _handle, captured) = start_capture_server().await;
5435        let ctx = test_producer_ctx();
5436
5437        let component = HttpComponent::with_config(HttpConfig::default());
5438        let endpoint_ctx = NoOpComponentContext;
5439        let endpoint = component
5440            .create_endpoint(
5441                &format!("{url}?httpMethod=GET&allowInternal=true"),
5442                &endpoint_ctx,
5443            )
5444            .unwrap();
5445        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5446
5447        let mut exchange = Exchange::new(Message::default());
5448        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5449
5450        let result = producer.oneshot(exchange).await.unwrap();
5451
5452        let status = result
5453            .input
5454            .header("CamelHttpResponseCode")
5455            .and_then(|v| v.as_u64())
5456            .unwrap();
5457        assert_eq!(status, 200);
5458
5459        let captured = captured.lock().unwrap();
5460        assert_eq!(captured.len(), 1, "expected exactly one captured request");
5461        let req = &captured[0];
5462        assert_eq!(req.method, "GET");
5463        // `httpMethod`/`allowInternal` are URI options, not request-target
5464        // query params, so the origin-form target is just "/".
5465        assert_eq!(req.path, "/");
5466        assert!(req.body.is_empty(), "GET must not carry a body");
5467        assert!(
5468            req.content_length.is_none(),
5469            "suppressed request must not carry Content-Length"
5470        );
5471        assert!(
5472            req.transfer_encoding.is_none(),
5473            "suppressed request must not carry Transfer-Encoding"
5474        );
5475
5476        // The exchange body is consumed by the producer (std::mem::take).
5477        assert!(
5478            result.input.body.is_empty(),
5479            "exchange body must be consumed"
5480        );
5481    }
5482
5483    #[tokio::test]
5484    async fn test_head_with_body_suppressed_via_header() {
5485        use tower::ServiceExt;
5486
5487        let (url, _handle, captured) = start_capture_server().await;
5488        let ctx = test_producer_ctx();
5489
5490        let component = HttpComponent::with_config(HttpConfig::default());
5491        let endpoint_ctx = NoOpComponentContext;
5492        let endpoint = component
5493            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5494            .unwrap();
5495        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5496
5497        let mut exchange = Exchange::new(Message::default());
5498        exchange.input.set_header(
5499            "CamelHttpMethod",
5500            serde_json::Value::String("HEAD".to_string()),
5501        );
5502        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5503
5504        let result = producer.oneshot(exchange).await.unwrap();
5505        let status = result
5506            .input
5507            .header("CamelHttpResponseCode")
5508            .and_then(|v| v.as_u64())
5509            .unwrap();
5510        assert_eq!(status, 200);
5511
5512        let captured = captured.lock().unwrap();
5513        assert_eq!(captured.len(), 1);
5514        let req = &captured[0];
5515        assert_eq!(req.method, "HEAD");
5516        assert!(req.body.is_empty(), "HEAD must not carry a body");
5517    }
5518
5519    #[tokio::test]
5520    async fn test_delete_options_trace_with_body_suppressed() {
5521        use tower::ServiceExt;
5522
5523        let (url, _handle, captured) = start_capture_server().await;
5524        let ctx = test_producer_ctx();
5525        let component = HttpComponent::with_config(HttpConfig::default());
5526        let endpoint_ctx = NoOpComponentContext;
5527
5528        for method in ["DELETE", "OPTIONS", "TRACE"] {
5529            let endpoint = component
5530                .create_endpoint(
5531                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5532                    &endpoint_ctx,
5533                )
5534                .unwrap();
5535            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5536
5537            let mut exchange = Exchange::new(Message::default());
5538            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5539
5540            let result = producer.oneshot(exchange).await.unwrap();
5541            let status = result
5542                .input
5543                .header("CamelHttpResponseCode")
5544                .and_then(|v| v.as_u64())
5545                .unwrap();
5546            assert_eq!(status, 200, "method {method} should succeed");
5547        }
5548
5549        let captured = captured.lock().unwrap();
5550        assert_eq!(captured.len(), 3, "expected three captured requests");
5551        for method in ["DELETE", "OPTIONS", "TRACE"] {
5552            let req = captured
5553                .iter()
5554                .find(|r| r.method == method)
5555                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5556            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5557        }
5558    }
5559
5560    #[tokio::test]
5561    async fn test_post_put_patch_with_body_still_sent() {
5562        use tower::ServiceExt;
5563
5564        let (url, _handle, captured) = start_capture_server().await;
5565        let ctx = test_producer_ctx();
5566        let component = HttpComponent::with_config(HttpConfig::default());
5567        let endpoint_ctx = NoOpComponentContext;
5568
5569        for method in ["POST", "PUT", "PATCH"] {
5570            let endpoint = component
5571                .create_endpoint(
5572                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5573                    &endpoint_ctx,
5574                )
5575                .unwrap();
5576            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5577
5578            let payload = format!("body-for-{method}");
5579            let mut exchange = Exchange::new(Message::default());
5580            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5581
5582            let result = producer.oneshot(exchange).await.unwrap();
5583            let status = result
5584                .input
5585                .header("CamelHttpResponseCode")
5586                .and_then(|v| v.as_u64())
5587                .unwrap();
5588            assert_eq!(status, 200, "method {method} should succeed");
5589        }
5590
5591        let captured = captured.lock().unwrap();
5592        assert_eq!(captured.len(), 3, "expected three captured requests");
5593        for method in ["POST", "PUT", "PATCH"] {
5594            let req = captured
5595                .iter()
5596                .find(|r| r.method == method)
5597                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5598            let expected = format!("body-for-{method}");
5599            assert!(!req.body.is_empty(), "{method} must still carry its body");
5600            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5601        }
5602    }
5603
5604    /// A GET with a stream body must not attach the stream: the entity-enclosing
5605    /// gate drops the stream (mem::take) before the request is built, leaving
5606    /// the exchange body Empty instead of a partially-consumed Body::Stream.
5607    #[tokio::test]
5608    async fn test_stream_body_under_get_not_attached() {
5609        use tower::ServiceExt;
5610
5611        let (url, _handle, captured) = start_capture_server().await;
5612        let ctx = test_producer_ctx();
5613
5614        let component = HttpComponent::with_config(HttpConfig::default());
5615        let endpoint_ctx = NoOpComponentContext;
5616        let endpoint = component
5617            .create_endpoint(
5618                &format!("{url}?httpMethod=GET&allowInternal=true"),
5619                &endpoint_ctx,
5620            )
5621            .unwrap();
5622        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5623
5624        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5625            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
5626        let stream = Box::pin(futures::stream::iter(chunks));
5627        let mut exchange = Exchange::new(Message::default());
5628        exchange.input.body = Body::Stream(StreamBody {
5629            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5630            metadata: StreamMetadata::default(),
5631        });
5632
5633        let result = producer.oneshot(exchange).await.unwrap();
5634
5635        let status = result
5636            .input
5637            .header("CamelHttpResponseCode")
5638            .and_then(|v| v.as_u64())
5639            .unwrap();
5640        assert_eq!(status, 200);
5641
5642        let captured = captured.lock().unwrap();
5643        assert_eq!(captured.len(), 1, "expected exactly one captured request");
5644        assert!(
5645            captured[0].body.is_empty(),
5646            "GET must not carry a stream body"
5647        );
5648        assert!(
5649            captured[0].transfer_encoding.is_none(),
5650            "suppressed request must not carry Transfer-Encoding"
5651        );
5652        assert!(
5653            captured[0].content_length.is_none(),
5654            "suppressed request must not carry Content-Length"
5655        );
5656        assert!(
5657            result.input.body.is_empty(),
5658            "exchange body must be consumed to Empty, not left as a stream"
5659        );
5660    }
5661
5662    /// A suppressed body must never be replayed across 307/308 redirect hops:
5663    /// the gate empties `materialized_body` before the redirect loop runs, so
5664    /// neither the first hop nor the final hop carries the body.
5665    #[tokio::test]
5666    async fn test_redirect_hops_never_replay_suppressed_body() {
5667        use tower::ServiceExt;
5668
5669        let (url, _handle, captured) = start_redirect_capture_server().await;
5670        let ctx = test_producer_ctx();
5671
5672        let component =
5673            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5674        let endpoint_ctx = NoOpComponentContext;
5675
5676        for path in ["/hop307", "/hop308"] {
5677            let endpoint = component
5678                .create_endpoint(
5679                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
5680                    &endpoint_ctx,
5681                )
5682                .unwrap();
5683            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5684
5685            let mut exchange = Exchange::new(Message::default());
5686            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5687
5688            let result = producer.oneshot(exchange).await.unwrap();
5689            let status = result
5690                .input
5691                .header("CamelHttpResponseCode")
5692                .and_then(|v| v.as_u64())
5693                .unwrap();
5694            assert_eq!(
5695                status, 200,
5696                "redirect chain for {path} should end at /final"
5697            );
5698        }
5699
5700        // Two chains (307 and 308), each with two hops (redirect + final).
5701        let captured = captured.lock().unwrap();
5702        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
5703        for req in captured.iter() {
5704            assert!(
5705                req.body.is_empty(),
5706                "hop {} {} must not carry a body",
5707                req.method,
5708                req.path
5709            );
5710        }
5711    }
5712
5713    /// The warn! emitted on a suppressed body renders three distinguishable
5714    /// substrings in the log line (tracing-subscriber default field format):
5715    ///   - the message:       "dropping request body ..."
5716    ///   - `method = %method_str`            → `method=GET`
5717    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
5718    /// The closure matches all three so exactly one warn per suppressed
5719    /// request is required (the "HTTP request" debug! also carries
5720    /// `method=GET` and the same `correlation_id=`, but not the message).
5721    #[tracing_test::traced_test]
5722    #[tokio::test]
5723    async fn test_suppressed_body_logs_exactly_one_warn() {
5724        use tower::ServiceExt;
5725
5726        let (url, _handle, _captured) = start_capture_server().await;
5727        let ctx = test_producer_ctx();
5728
5729        let component = HttpComponent::with_config(HttpConfig::default());
5730        let endpoint_ctx = NoOpComponentContext;
5731        let endpoint = component
5732            .create_endpoint(
5733                &format!("{url}?httpMethod=GET&allowInternal=true"),
5734                &endpoint_ctx,
5735            )
5736            .unwrap();
5737        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5738
5739        let mut exchange = Exchange::new(Message::default());
5740        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5741        let correlation_id = exchange.correlation_id().to_string();
5742
5743        let result = producer.oneshot(exchange).await.unwrap();
5744        let status = result
5745            .input
5746            .header("CamelHttpResponseCode")
5747            .and_then(|v| v.as_u64())
5748            .unwrap();
5749        assert_eq!(status, 200);
5750
5751        logs_assert(|lines: &[&str]| {
5752            let hits = lines
5753                .iter()
5754                .filter(|l| {
5755                    l.contains("dropping request body")
5756                        && l.contains("method=GET")
5757                        && l.contains(&format!("correlation_id={correlation_id}"))
5758                })
5759                .count();
5760            match hits {
5761                1 => Ok(()),
5762                n => Err(format!("expected exactly one body-drop warn, found {n}")),
5763            }
5764        });
5765    }
5766
5767    #[tracing_test::traced_test]
5768    #[tokio::test]
5769    async fn test_empty_body_get_emits_no_warn() {
5770        use tower::ServiceExt;
5771
5772        let (url, _handle, _captured) = start_capture_server().await;
5773        let ctx = test_producer_ctx();
5774
5775        let component = HttpComponent::with_config(HttpConfig::default());
5776        let endpoint_ctx = NoOpComponentContext;
5777        let endpoint = component
5778            .create_endpoint(
5779                &format!("{url}?httpMethod=GET&allowInternal=true"),
5780                &endpoint_ctx,
5781            )
5782            .unwrap();
5783        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5784
5785        let exchange = Exchange::new(Message::default());
5786        let result = producer.oneshot(exchange).await.unwrap();
5787        let status = result
5788            .input
5789            .header("CamelHttpResponseCode")
5790            .and_then(|v| v.as_u64())
5791            .unwrap();
5792        assert_eq!(status, 200);
5793
5794        logs_assert(|lines: &[&str]| {
5795            let hits = lines
5796                .iter()
5797                .filter(|l| l.contains("dropping request body"))
5798                .count();
5799            match hits {
5800                0 => Ok(()),
5801                n => Err(format!("expected no body-drop warn, found {n}")),
5802            }
5803        });
5804    }
5805
5806    #[tokio::test]
5807    async fn test_follow_redirects_false_does_not_follow() {
5808        use tower::ServiceExt;
5809
5810        let (url, _handle) = start_redirect_server().await;
5811        let ctx = test_producer_ctx();
5812
5813        let component =
5814            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
5815        let endpoint_ctx = NoOpComponentContext;
5816        let endpoint = component
5817            .create_endpoint(
5818                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
5819                &endpoint_ctx,
5820            )
5821            .unwrap();
5822        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5823
5824        let exchange = Exchange::new(Message::default());
5825        let result = producer.oneshot(exchange).await.unwrap();
5826
5827        // Should get 302, NOT follow redirect to 200
5828        let status = result
5829            .input
5830            .header("CamelHttpResponseCode")
5831            .and_then(|v| v.as_u64())
5832            .unwrap();
5833        assert_eq!(
5834            status, 302,
5835            "Should NOT follow redirect when followRedirects=false"
5836        );
5837    }
5838
5839    #[tokio::test]
5840    async fn test_follow_redirects_true_follows_redirect() {
5841        use tower::ServiceExt;
5842
5843        let (url, _handle) = start_redirect_server().await;
5844        let ctx = test_producer_ctx();
5845
5846        let component =
5847            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5848        let endpoint_ctx = NoOpComponentContext;
5849        let endpoint = component
5850            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5851            .unwrap();
5852        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5853
5854        let exchange = Exchange::new(Message::default());
5855        let result = producer.oneshot(exchange).await.unwrap();
5856
5857        // Should follow redirect and get 200
5858        let status = result
5859            .input
5860            .header("CamelHttpResponseCode")
5861            .and_then(|v| v.as_u64())
5862            .unwrap();
5863        assert_eq!(
5864            status, 200,
5865            "Should follow redirect when followRedirects=true"
5866        );
5867    }
5868
5869    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
5870    /// This verifies the manual redirect loop executes correctly.
5871    #[tokio::test]
5872    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
5873        use tower::ServiceExt;
5874
5875        // Use the existing redirect server which redirects to /final on the same server
5876        let (url, _handle) = start_redirect_server().await;
5877        let ctx = test_producer_ctx();
5878
5879        let component =
5880            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5881        let endpoint_ctx = NoOpComponentContext;
5882        let endpoint = component
5883            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5884            .unwrap();
5885        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5886
5887        let exchange = Exchange::new(Message::default());
5888        let result = producer.oneshot(exchange).await;
5889
5890        // With allowInternal=true, the redirect should succeed
5891        assert!(
5892            result.is_ok(),
5893            "Redirect should succeed with allowInternal=true, got: {:?}",
5894            result
5895        );
5896        let exchange = result.unwrap();
5897        let status = exchange
5898            .input
5899            .header("CamelHttpResponseCode")
5900            .and_then(|v| v.as_u64())
5901            .unwrap();
5902        assert_eq!(status, 200, "Should follow redirect to /final");
5903    }
5904
5905    /// With allowInternal=true, redirects to private IPs should be followed.
5906    #[tokio::test]
5907    async fn test_redirect_to_private_ip_allowed_when_configured() {
5908        use tower::ServiceExt;
5909
5910        // Start a server that redirects to /final on the same server (127.0.0.1)
5911        let (url, _handle) = start_redirect_server().await;
5912        let ctx = test_producer_ctx();
5913
5914        let component =
5915            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5916        let endpoint_ctx = NoOpComponentContext;
5917        let endpoint = component
5918            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5919            .unwrap();
5920        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5921
5922        let exchange = Exchange::new(Message::default());
5923        let result = producer.oneshot(exchange).await.unwrap();
5924
5925        let status = result
5926            .input
5927            .header("CamelHttpResponseCode")
5928            .and_then(|v| v.as_u64())
5929            .unwrap();
5930        assert_eq!(
5931            status, 200,
5932            "Should follow redirect to private IP when allowInternal=true"
5933        );
5934    }
5935
5936    /// Integration test: with allowInternal=false (default), a redirect to a
5937    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
5938    #[tokio::test]
5939    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
5940        use tower::ServiceExt;
5941
5942        // Server that redirects to the AWS metadata endpoint (link-local private IP)
5943        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5944        let addr = listener.local_addr().unwrap();
5945        let url = format!("http://127.0.0.1:{}", addr.port());
5946
5947        let handle = tokio::spawn(async move {
5948            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5949            loop {
5950                if let Ok((mut stream, _)) = listener.accept().await {
5951                    tokio::spawn(async move {
5952                        let mut buf = vec![0u8; 4096];
5953                        let _ = stream.read(&mut buf).await;
5954                        // Always redirect to the metadata endpoint
5955                        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";
5956                        let _ = stream.write_all(response.as_bytes()).await;
5957                    });
5958                }
5959            }
5960        });
5961
5962        let ctx = test_producer_ctx();
5963        let component =
5964            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5965        let endpoint_ctx = NoOpComponentContext;
5966        // allowInternal=false is the default — do NOT set it
5967        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
5968        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5969
5970        let exchange = Exchange::new(Message::default());
5971        let result = producer.oneshot(exchange).await;
5972
5973        // Must be an error — SSRF guard blocks the redirect target
5974        assert!(
5975            result.is_err(),
5976            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
5977        );
5978        let err = result.unwrap_err().to_string();
5979        assert!(
5980            err.contains("blocked IP")
5981                || err.contains("private IP")
5982                || err.contains("SSRF")
5983                || err.contains("not allowed"),
5984            "Error should mention SSRF/IP blocking, got: {err}"
5985        );
5986
5987        handle.abort();
5988    }
5989
5990    /// Integration test: exceeding maxRedirects produces a clear error.
5991    #[tokio::test]
5992    async fn test_too_many_redirects_returns_error() {
5993        use tower::ServiceExt;
5994
5995        // Server that always redirects to itself (infinite loop)
5996        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5997        let addr = listener.local_addr().unwrap();
5998        let url = format!("http://127.0.0.1:{}", addr.port());
5999
6000        let handle = tokio::spawn(async move {
6001            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6002            loop {
6003                if let Ok((mut stream, _)) = listener.accept().await {
6004                    tokio::spawn(async move {
6005                        let mut buf = vec![0u8; 4096];
6006                        let _ = stream.read(&mut buf).await;
6007                        // Always redirect to /loop
6008                        // Connection: close stops the client pooling the
6009                        // connection the server drops right after this
6010                        // response (pooled-race, rc-u3aw).
6011                        let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6012                        let _ = stream.write_all(response.as_bytes()).await;
6013                    });
6014                }
6015            }
6016        });
6017
6018        let ctx = test_producer_ctx();
6019        let component =
6020            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6021        let endpoint_ctx = NoOpComponentContext;
6022        let endpoint = component
6023            .create_endpoint(
6024                &format!("{url}?allowInternal=true&maxRedirects=2"),
6025                &endpoint_ctx,
6026            )
6027            .unwrap();
6028        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6029
6030        let exchange = Exchange::new(Message::default());
6031        let result = producer.oneshot(exchange).await;
6032
6033        // With the fix, exceeding max redirects returns the redirect response
6034        // as-is instead of erroring. The 302 redirect response is returned
6035        // after followRedirects exhausts the allowed redirect count (2).
6036        // Disable throwExceptionOnFailure to inspect the raw response status.
6037        //
6038        // Old behavior: Err("Too many redirects (max 2)")
6039        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
6040        match result {
6041            Err(e) => {
6042                // If throw_exception_on_failure is on, we get HttpOperationFailed
6043                let msg = e.to_string();
6044                assert!(
6045                    msg.contains("HTTP operation failed") || msg.contains("302"),
6046                    "expected redirect-after-exhaustion error, got: {msg}"
6047                );
6048            }
6049            Ok(ex) => {
6050                let response_code = ex
6051                    .input
6052                    .header("CamelHttpResponseCode")
6053                    .and_then(|v| v.as_u64());
6054                assert_eq!(
6055                    response_code,
6056                    Some(302),
6057                    "expected 302 after exhausting redirects"
6058                );
6059            }
6060        }
6061
6062        handle.abort();
6063    }
6064
6065    #[tokio::test]
6066    async fn test_query_params_forwarded_to_http_request() {
6067        use tower::ServiceExt;
6068
6069        let (url, _handle) = start_test_server().await;
6070        let ctx = test_producer_ctx();
6071
6072        let component = HttpComponent::new();
6073        let endpoint_ctx = NoOpComponentContext;
6074        // apiKey is NOT a Camel option, should be forwarded as query param
6075        let endpoint = component
6076            .create_endpoint(
6077                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
6078                &endpoint_ctx,
6079            )
6080            .unwrap();
6081        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6082
6083        let exchange = Exchange::new(Message::default());
6084        let result = producer.oneshot(exchange).await.unwrap();
6085
6086        // The test server returns the request info in response
6087        // We just verify it succeeds (the query param was sent)
6088        let status = result
6089            .input
6090            .header("CamelHttpResponseCode")
6091            .and_then(|v| v.as_u64())
6092            .unwrap();
6093        assert_eq!(status, 200);
6094    }
6095
6096    #[test]
6097    fn test_non_camel_query_params_are_forwarded() {
6098        // Authored pairs ride raw_query (the sole carrier); query_params is
6099        // programmatic-only (http-query-wire-fidelity).
6100        let config = HttpEndpointConfig::from_uri(
6101            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
6102        )
6103        .unwrap();
6104
6105        // apiKey and token are NOT camel-http options: the authored bytes
6106        // (including the interleaved httpMethod) ride raw_query verbatim.
6107        assert_eq!(
6108            config.raw_query.as_deref(),
6109            Some("apiKey=secret123&httpMethod=GET&token=abc456")
6110        );
6111        assert!(config.query_params.is_empty());
6112    }
6113
6114    #[test]
6115    fn test_authored_query_bytes_survive_resolve_url() {
6116        let config =
6117            HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
6118        let exchange = Exchange::new(Message::default());
6119
6120        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
6121
6122        // Authored bytes ride verbatim: `%20` stays `%20` (never re-encoded
6123        // to `+` or double-encoded) and `+` stays `+`.
6124        assert!(url.contains("q=hello%20world"), "url was: {url}");
6125        assert!(url.contains("tag=a+b"), "url was: {url}");
6126    }
6127
6128    // -----------------------------------------------------------------------
6129    // Timeout tests (HTTP-004)
6130    // -----------------------------------------------------------------------
6131
6132    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
6133        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6134        let addr = listener.local_addr().unwrap();
6135        let url = format!("http://127.0.0.1:{}", addr.port());
6136
6137        let handle = tokio::spawn(async move {
6138            loop {
6139                if let Ok((mut stream, _)) = listener.accept().await {
6140                    let delay = delay_ms;
6141                    tokio::spawn(async move {
6142                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6143                        let mut buf = vec![0u8; 4096];
6144                        let _ = stream.read(&mut buf).await;
6145                        // Send headers immediately (no Content-Length → chunked)
6146                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
6147                        let _ = stream.write_all(headers.as_bytes()).await;
6148                        // Delay before sending body chunk
6149                        tokio::time::sleep(Duration::from_millis(delay)).await;
6150                        let body = r#"{"status":"slow"}"#;
6151                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
6152                        let _ = stream.write_all(chunk.as_bytes()).await;
6153                    });
6154                }
6155            }
6156        });
6157
6158        (url, handle)
6159    }
6160
6161    #[tokio::test]
6162    async fn test_http_producer_timeout() {
6163        use tower::ServiceExt;
6164
6165        // Server delays 500ms, client timeout is 100ms → should timeout
6166        let (url, _handle) = start_slow_server(500).await;
6167        let ctx = test_producer_ctx();
6168
6169        let component = HttpComponent::with_config(
6170            HttpConfig::default()
6171                .with_read_timeout_ms(100)
6172                .with_response_timeout_ms(30_000), // generous response timeout
6173        );
6174        let endpoint_ctx = NoOpComponentContext;
6175        let endpoint = component
6176            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
6177            .unwrap();
6178        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6179
6180        let exchange = Exchange::new(Message::default());
6181        let result = producer.oneshot(exchange).await;
6182
6183        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
6184        let err = result.unwrap_err().to_string();
6185        assert!(
6186            err.contains("Read timeout") || err.contains("timeout"),
6187            "Error should mention timeout, got: {}",
6188            err
6189        );
6190    }
6191
6192    #[tokio::test]
6193    async fn test_http_producer_no_timeout_when_fast() {
6194        use tower::ServiceExt;
6195
6196        let (url, _handle) = start_test_server().await;
6197        let ctx = test_producer_ctx();
6198
6199        let component =
6200            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
6201        let endpoint_ctx = NoOpComponentContext;
6202        let endpoint = component
6203            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6204            .unwrap();
6205        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6206
6207        let exchange = Exchange::new(Message::default());
6208        let result = producer.oneshot(exchange).await.unwrap();
6209
6210        let status = result
6211            .input
6212            .header("CamelHttpResponseCode")
6213            .and_then(|v| v.as_u64())
6214            .unwrap();
6215        assert_eq!(status, 200);
6216    }
6217
6218    // -----------------------------------------------------------------------
6219    // SSRF Protection tests
6220    // -----------------------------------------------------------------------
6221
6222    #[tokio::test]
6223    async fn test_http_producer_blocks_metadata_endpoint() {
6224        use tower::ServiceExt;
6225
6226        let ctx = test_producer_ctx();
6227        let component = HttpComponent::new();
6228        let endpoint_ctx = NoOpComponentContext;
6229        let endpoint = component
6230            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
6231            .unwrap();
6232        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6233
6234        let mut exchange = Exchange::new(Message::default());
6235        exchange.input.set_header(
6236            "CamelHttpUri",
6237            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
6238        );
6239
6240        let result = producer.oneshot(exchange).await;
6241        assert!(result.is_err(), "Should block AWS metadata endpoint");
6242
6243        let err = result.unwrap_err();
6244        assert!(
6245            err.to_string().contains("Private IP"),
6246            "Error should mention private IP blocking, got: {}",
6247            err
6248        );
6249    }
6250
6251    #[test]
6252    fn test_ssrf_config_defaults() {
6253        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
6254        assert!(
6255            !config.allow_internal,
6256            "Private IPs should be blocked by default"
6257        );
6258        assert!(
6259            config.blocked_hosts.is_empty(),
6260            "Blocked hosts should be empty by default"
6261        );
6262    }
6263
6264    #[test]
6265    fn test_ssrf_config_allow_internal() {
6266        let config =
6267            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
6268        assert!(
6269            config.allow_internal,
6270            "Private IPs should be allowed when explicitly set"
6271        );
6272    }
6273
6274    #[test]
6275    fn test_ssrf_config_blocked_hosts() {
6276        let config = HttpEndpointConfig::from_uri(
6277            "http://example.com/api?blockedHosts=evil.com,malware.net",
6278        )
6279        .unwrap();
6280        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
6281    }
6282
6283    #[tokio::test]
6284    async fn test_http_producer_blocks_localhost() {
6285        use tower::ServiceExt;
6286
6287        let ctx = test_producer_ctx();
6288        let component = HttpComponent::new();
6289        let endpoint_ctx = NoOpComponentContext;
6290        let endpoint = component
6291            .create_endpoint("http://example.com/api", &endpoint_ctx)
6292            .unwrap();
6293        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6294
6295        let mut exchange = Exchange::new(Message::default());
6296        exchange.input.set_header(
6297            "CamelHttpUri",
6298            serde_json::Value::String("http://localhost:8080/internal".to_string()),
6299        );
6300
6301        let result = producer.oneshot(exchange).await;
6302        assert!(result.is_err(), "Should block localhost");
6303    }
6304
6305    #[tokio::test]
6306    async fn test_http_producer_blocks_loopback_ip() {
6307        use tower::ServiceExt;
6308
6309        let ctx = test_producer_ctx();
6310        let component = HttpComponent::new();
6311        let endpoint_ctx = NoOpComponentContext;
6312        let endpoint = component
6313            .create_endpoint("http://example.com/api", &endpoint_ctx)
6314            .unwrap();
6315        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6316
6317        let mut exchange = Exchange::new(Message::default());
6318        exchange.input.set_header(
6319            "CamelHttpUri",
6320            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
6321        );
6322
6323        let result = producer.oneshot(exchange).await;
6324        assert!(result.is_err(), "Should block loopback IP");
6325    }
6326
6327    #[tokio::test]
6328    async fn test_http_producer_allows_private_ip_when_enabled() {
6329        use tower::ServiceExt;
6330
6331        let ctx = test_producer_ctx();
6332        let component = HttpComponent::new();
6333        let endpoint_ctx = NoOpComponentContext;
6334        // With allowInternal=true, the validation should pass
6335        // (actual connection will fail, but that's expected)
6336        let endpoint = component
6337            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
6338            .unwrap();
6339        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6340
6341        let exchange = Exchange::new(Message::default());
6342
6343        // The request will fail because we can't connect, but it should NOT fail
6344        // due to SSRF protection
6345        let result = producer.oneshot(exchange).await;
6346        // We expect connection error, not SSRF error
6347        if let Err(ref e) = result {
6348            let err_str = e.to_string();
6349            assert!(
6350                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
6351                "Should not be SSRF error, got: {}",
6352                err_str
6353            );
6354        }
6355    }
6356
6357    // -----------------------------------------------------------------------
6358    // HttpServerConfig tests
6359    // -----------------------------------------------------------------------
6360
6361    #[test]
6362    fn test_http_server_config_parse() {
6363        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
6364        assert_eq!(cfg.host, "0.0.0.0");
6365        assert_eq!(cfg.port, 8080);
6366        assert_eq!(cfg.path, "/orders");
6367        assert_eq!(cfg.max_inflight_requests, 1024);
6368    }
6369
6370    #[test]
6371    fn test_http_server_config_scheme() {
6372        // UriConfig trait method returns "http" as primary scheme
6373        assert_eq!(HttpServerConfig::scheme(), "http");
6374    }
6375
6376    #[test]
6377    fn test_http_server_config_from_components() {
6378        // Test from_components directly (trait method)
6379        let components = camel_component_api::UriComponents {
6380            scheme: "https".to_string(),
6381            path: "//0.0.0.0:8443/api".to_string(),
6382            params: std::collections::HashMap::from([
6383                ("maxRequestBody".to_string(), "5242880".to_string()),
6384                ("maxInflightRequests".to_string(), "7".to_string()),
6385            ]),
6386            raw_query: None,
6387        };
6388        let cfg = HttpServerConfig::from_components(components).unwrap();
6389        assert_eq!(cfg.host, "0.0.0.0");
6390        assert_eq!(cfg.port, 8443);
6391        assert_eq!(cfg.path, "/api");
6392        assert_eq!(cfg.max_request_body, 5242880);
6393        assert_eq!(cfg.max_inflight_requests, 7);
6394    }
6395
6396    #[test]
6397    fn test_http_server_config_default_path() {
6398        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
6399        assert_eq!(cfg.path, "/");
6400    }
6401
6402    #[test]
6403    fn test_http_server_config_wrong_scheme() {
6404        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6405    }
6406
6407    #[test]
6408    fn test_http_server_config_invalid_port() {
6409        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6410    }
6411
6412    #[test]
6413    fn test_http_server_config_default_port_by_scheme() {
6414        // HTTP without explicit port should default to 80
6415        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
6416        assert_eq!(cfg_http.port, 80);
6417
6418        // HTTPS without explicit port should default to 443
6419        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
6420        assert_eq!(cfg_https.port, 443);
6421    }
6422
6423    #[test]
6424    fn test_request_envelope_and_reply_are_send() {
6425        fn assert_send<T: Send>() {}
6426        assert_send::<RequestEnvelope>();
6427        assert_send::<HttpReply>();
6428    }
6429
6430    // -----------------------------------------------------------------------
6431    // ServerRegistry tests
6432    // -----------------------------------------------------------------------
6433
6434    #[test]
6435    fn test_server_registry_global_is_singleton() {
6436        let r1 = ServerRegistry::global();
6437        let r2 = ServerRegistry::global();
6438        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
6439    }
6440
6441    #[allow(clippy::await_holding_lock)]
6442    #[tokio::test]
6443    async fn test_concurrent_get_or_spawn_returns_same_registry() {
6444        let _guard = lock_registry_test_mutex();
6445        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6446        let port = listener.local_addr().unwrap().port();
6447        drop(listener);
6448
6449        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
6450            Arc::new(std::sync::Mutex::new(Vec::new()));
6451
6452        let mut handles = Vec::new();
6453        for _ in 0..4 {
6454            let results = results.clone();
6455            handles.push(tokio::spawn(async move {
6456                let registry = ServerRegistry::global()
6457                    .get_or_spawn(
6458                        "127.0.0.1",
6459                        port,
6460                        2 * 1024 * 1024,
6461                        10 * 1024 * 1024,
6462                        1024,
6463                        test_rt(),
6464                        "test-route".into(),
6465                        None,
6466                    )
6467                    .await
6468                    .unwrap();
6469                results.lock().unwrap().push(registry);
6470            }));
6471        }
6472
6473        for h in handles {
6474            h.await.unwrap();
6475        }
6476
6477        let registries = results.lock().unwrap();
6478        assert_eq!(registries.len(), 4);
6479        for i in 1..registries.len() {
6480            assert!(
6481                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
6482                "all concurrent callers should get same route registry"
6483            );
6484        }
6485    }
6486
6487    #[test]
6488    fn test_server_registry_distinguishes_host_and_port() {
6489        let _guard = lock_registry_test_mutex();
6490        let rt = tokio::runtime::Runtime::new().expect("runtime");
6491        rt.block_on(async {
6492            let registry = ServerRegistry::global();
6493            // Use two distinct host values with same configured port key.
6494            // Port 0 is acceptable here because the registry key uses the configured
6495            // tuple, not the OS-assigned ephemeral port.
6496            let d1 = registry
6497                .get_or_spawn(
6498                    "127.0.0.1",
6499                    0,
6500                    1024 * 1024,
6501                    10 * 1024 * 1024,
6502                    1024,
6503                    test_rt(),
6504                    "test-route-1".into(),
6505                    None,
6506                )
6507                .await;
6508            let d2 = registry
6509                .get_or_spawn(
6510                    "0.0.0.0",
6511                    0,
6512                    1024 * 1024,
6513                    10 * 1024 * 1024,
6514                    1024,
6515                    test_rt(),
6516                    "test-route-2".into(),
6517                    None,
6518                )
6519                .await;
6520            assert!(d1.is_ok());
6521            assert!(d2.is_ok());
6522            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
6523        });
6524    }
6525
6526    #[allow(clippy::await_holding_lock)]
6527    #[tokio::test]
6528    async fn test_shared_server_max_request_body_policy_is_deterministic() {
6529        let _guard = lock_registry_test_mutex();
6530        let registry = ServerRegistry::global();
6531        // First registration: maxRequestBody = 1 MB
6532        let d1 = registry
6533            .get_or_spawn(
6534                "127.0.0.1",
6535                9991,
6536                1024 * 1024,
6537                10 * 1024 * 1024,
6538                1024,
6539                test_rt(),
6540                "test-route".into(),
6541                None,
6542            )
6543            .await;
6544        assert!(d1.is_ok());
6545
6546        // Second registration on same (host,port): maxRequestBody = 2 MB
6547        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
6548        let d2 = registry
6549            .get_or_spawn(
6550                "127.0.0.1",
6551                9991,
6552                2 * 1024 * 1024,
6553                10 * 1024 * 1024,
6554                1024,
6555                test_rt(),
6556                "test-route-2".into(),
6557                None,
6558            )
6559            .await;
6560        assert!(d2.is_err());
6561        let err = d2.unwrap_err();
6562        assert!(
6563            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6564            "Expected incompatible maxRequestBody error, got: {}",
6565            err
6566        );
6567    }
6568
6569    #[test]
6570    fn test_server_registry_reset_clears_entries() {
6571        let _guard = lock_registry_test_mutex();
6572        let rt = tokio::runtime::Runtime::new().expect("runtime");
6573        rt.block_on(async {
6574            // Register something on a unique port
6575            let d1 = ServerRegistry::global()
6576                .get_or_spawn(
6577                    "127.0.0.1",
6578                    9992,
6579                    1024 * 1024,
6580                    10 * 1024 * 1024,
6581                    1024,
6582                    test_rt(),
6583                    "test-route".into(),
6584                    None,
6585                )
6586                .await;
6587            assert!(d1.is_ok());
6588
6589            // Verify entry exists
6590            let guard = ServerRegistry::global().inner.lock().expect("lock");
6591            assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
6592            drop(guard);
6593
6594            // Reset
6595            ServerRegistry::reset();
6596
6597            // Verify cleared
6598            let guard = ServerRegistry::global().inner.lock().expect("lock");
6599            assert!(
6600                guard.entries.is_empty(),
6601                "registry should be empty after reset, has {} entries",
6602                guard.entries.len()
6603            );
6604        });
6605    }
6606
6607    #[allow(clippy::await_holding_lock)]
6608    #[tokio::test]
6609    async fn registry_rejects_tls_on_plain_port() {
6610        // httpflake: this reset previously ran WITHOUT the registry test
6611        // mutex, so it could wipe another test's freshly staged entry
6612        // mid-window (traced 2026-09-14) — spec law: every reset caller
6613        // holds REGISTRY_TEST_MUTEX.
6614        let _guard = lock_registry_test_mutex();
6615        ServerRegistry::reset();
6616        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6617
6618        // First route: plain HTTP
6619        let _r1 = ServerRegistry::global()
6620            .get_or_spawn(
6621                "127.0.0.1",
6622                0,
6623                1024,
6624                1024,
6625                16,
6626                Arc::clone(&rt),
6627                "route-1".into(),
6628                None, // plain
6629            )
6630            .await;
6631
6632        // Second route: TLS on same port → must fail
6633        let result = ServerRegistry::global()
6634            .get_or_spawn(
6635                "127.0.0.1",
6636                0,
6637                1024,
6638                1024,
6639                16,
6640                Arc::clone(&rt),
6641                "route-2".into(),
6642                Some(crate::config::ServerTlsConfig {
6643                    cert_path: "/x.pem".into(),
6644                    key_path: "/y.pem".into(),
6645                }),
6646            )
6647            .await;
6648        assert!(result.is_err(), "must reject TLS on plain port");
6649    }
6650
6651    // -----------------------------------------------------------------------
6652    // D-L10: HTTP monitor_axum_task refcounted shutdown
6653    // -----------------------------------------------------------------------
6654
6655    #[allow(clippy::await_holding_lock)]
6656    #[tokio::test]
6657    async fn test_unregister_last_http_route_keeps_server_alive() {
6658        let _guard = lock_registry_test_mutex();
6659        ServerRegistry::reset();
6660        let registry = ServerRegistry::global();
6661
6662        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6663        let port = listener.local_addr().unwrap().port();
6664        drop(listener); // Release — ServerRegistry will rebind
6665        let rt = test_rt();
6666
6667        // Register 2 routes on the same (host, port) — OnceCell returns the
6668        // same ServerHandle.
6669        let _r1 = registry
6670            .get_or_spawn(
6671                "127.0.0.1",
6672                port,
6673                1024 * 1024,
6674                10 * 1024 * 1024,
6675                16,
6676                rt.clone(),
6677                "test-route-1".into(),
6678                None,
6679            )
6680            .await
6681            .unwrap();
6682        let _r2 = registry
6683            .get_or_spawn(
6684                "127.0.0.1",
6685                port,
6686                1024 * 1024,
6687                10 * 1024 * 1024,
6688                16,
6689                rt,
6690                "test-route-2".into(),
6691                None,
6692            )
6693            .await
6694            .unwrap();
6695
6696        let key = ("127.0.0.1".to_string(), port);
6697        let cell = {
6698            let guard = registry.inner.lock().expect("lock");
6699            guard.entries.get(&key).expect("entry should exist").clone()
6700        };
6701
6702        // Unregister first route -> monitor still alive (count = 1).
6703        registry.unregister("127.0.0.1", port).await;
6704        {
6705            let handle = cell
6706                .get()
6707                .expect("handle should still exist after first unregister");
6708            assert!(
6709                !handle.monitor_task.is_finished(),
6710                "monitor task should still be alive after first unregister"
6711            );
6712        }
6713
6714        // Unregister second route -> server stays alive (process-lifetime).
6715        registry.unregister("127.0.0.1", port).await;
6716        tokio::time::sleep(Duration::from_millis(20)).await;
6717        {
6718            let handle = cell
6719                .get()
6720                .expect("handle should still exist after last unregister");
6721            assert!(
6722                !handle.monitor_task.is_finished(),
6723                "monitor task should still be alive — server is process-lifetime"
6724            );
6725        }
6726
6727        // Entry stays in registry for potential restart.
6728        {
6729            let guard = registry.inner.lock().expect("lock");
6730            assert!(
6731                guard.entries.contains_key(&key),
6732                "entry should remain in registry — server kept alive for restart"
6733            );
6734        }
6735    }
6736
6737    // -----------------------------------------------------------------------
6738    // Staged listeners (itest-bound-ports Task 1)
6739    // -----------------------------------------------------------------------
6740
6741    /// CLONE-FIXTURE: bind a std listener on `127.0.0.1:0`, retain a blocking
6742    /// std clone (`probe`) so the port stays reserved, and hand the original
6743    /// socket to tokio as a non-blocking listener. `tokio::net::TcpListener`
6744    /// has no `try_clone`, so clones come from the std handle.
6745    async fn clone_fixture_listener() -> (
6746        tokio::net::TcpListener,
6747        std::net::TcpListener,
6748        std::net::SocketAddr,
6749    ) {
6750        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
6751        let probe = l.try_clone().expect("clone probe");
6752        l.set_nonblocking(true).expect("set_nonblocking");
6753        let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
6754        let addr = listener.local_addr().expect("local_addr");
6755        (listener, probe, addr)
6756    }
6757
6758    /// Default-limit constants the existing registry tests in this file use.
6759    fn staged_limits() -> (usize, usize, usize) {
6760        (1024 * 1024, 10 * 1024 * 1024, 1024)
6761    }
6762
6763    #[allow(clippy::await_holding_lock)]
6764    #[tokio::test]
6765    async fn staged_listener_first_spawn_serves_without_second_bind() {
6766        let _guard = lock_registry_test_mutex();
6767        ServerRegistry::reset();
6768        let registry = ServerRegistry::global();
6769        let (listener, _probe, addr) = clone_fixture_listener().await;
6770        let port = addr.port();
6771        registry
6772            .stage_listener(listener)
6773            .await
6774            .expect("stage listener");
6775
6776        let (max_req, max_res, max_inflight) = staged_limits();
6777        let routes = registry
6778            .get_or_spawn(
6779                "127.0.0.1",
6780                port,
6781                max_req,
6782                max_res,
6783                max_inflight,
6784                test_rt(),
6785                "staged-first-spawn".into(),
6786                None,
6787            )
6788            .await
6789            .expect("spawn from staged listener must succeed");
6790
6791        assert_eq!(
6792            registry.bound_addr("127.0.0.1", port),
6793            Some(addr),
6794            "served socket must be the staged listener's addr"
6795        );
6796        // The probe clone shares the socket, so service is proven by an HTTP
6797        // response, not by accepting on the probe.
6798        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
6799            .await
6800            .expect("http request against staged listener must connect");
6801        assert!(
6802            resp.status().as_u16() >= 200,
6803            "any status proves the staged socket serves"
6804        );
6805        drop(routes);
6806    }
6807
6808    #[allow(clippy::await_holding_lock)]
6809    #[tokio::test]
6810    async fn staged_entry_reused_by_second_caller() {
6811        let _guard = lock_registry_test_mutex();
6812        ServerRegistry::reset();
6813        let registry = ServerRegistry::global();
6814        let (listener, _probe, addr) = clone_fixture_listener().await;
6815        let port = addr.port();
6816        registry
6817            .stage_listener(listener)
6818            .await
6819            .expect("stage listener");
6820
6821        let (max_req, max_res, max_inflight) = staged_limits();
6822        let first = registry
6823            .get_or_spawn(
6824                "127.0.0.1",
6825                port,
6826                max_req,
6827                max_res,
6828                max_inflight,
6829                test_rt(),
6830                "staged-reuse-1".into(),
6831                None,
6832            )
6833            .await
6834            .expect("first spawn from staged listener");
6835        let second = registry
6836            .get_or_spawn(
6837                "127.0.0.1",
6838                port,
6839                max_req,
6840                max_res,
6841                max_inflight,
6842                test_rt(),
6843                "staged-reuse-2".into(),
6844                None,
6845            )
6846            .await
6847            .expect("second caller must reuse the entry");
6848        assert_eq!(
6849            registry.bound_addr("127.0.0.1", port),
6850            Some(addr),
6851            "entry reused — bound addr unchanged, no second bind"
6852        );
6853        drop(first);
6854        drop(second);
6855    }
6856
6857    #[allow(clippy::await_holding_lock)]
6858    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6859    async fn staged_race_two_callers_single_resolver() {
6860        let _guard = lock_registry_test_mutex();
6861        ServerRegistry::reset();
6862        let registry = ServerRegistry::global();
6863        let (listener, _probe, addr) = clone_fixture_listener().await;
6864        let port = addr.port();
6865        registry
6866            .stage_listener(listener)
6867            .await
6868            .expect("stage listener");
6869
6870        // Two racing callers for the exact staged key: the staged listener
6871        // must be consumed by the single cell-init winner and served to
6872        // both — never leave the winner binding a port the loser still
6873        // holds (EADDRINUSE).
6874        let (max_req, max_res, max_inflight) = staged_limits();
6875        let (first, second) = tokio::join!(
6876            registry.get_or_spawn(
6877                "127.0.0.1",
6878                port,
6879                max_req,
6880                max_res,
6881                max_inflight,
6882                test_rt(),
6883                "staged-race-1".into(),
6884                None,
6885            ),
6886            registry.get_or_spawn(
6887                "127.0.0.1",
6888                port,
6889                max_req,
6890                max_res,
6891                max_inflight,
6892                test_rt(),
6893                "staged-race-2".into(),
6894                None,
6895            ),
6896        );
6897        let first = first.expect("first racing caller must succeed");
6898        let second = second.expect("second racing caller must succeed");
6899        assert_eq!(
6900            registry.bound_addr("127.0.0.1", port),
6901            Some(addr),
6902            "single entry must be served from the staged socket — no EADDRINUSE path"
6903        );
6904        drop(first);
6905        drop(second);
6906    }
6907
6908    #[allow(clippy::await_holding_lock)]
6909    #[tokio::test]
6910    async fn unstaged_spawn_binds_legacy() {
6911        let _guard = lock_registry_test_mutex();
6912        ServerRegistry::reset();
6913        let registry = ServerRegistry::global();
6914        // Fresh port P2: reserve then release — the legacy path rebinds.
6915        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
6916        let port = probe.local_addr().expect("local addr").port();
6917        drop(probe);
6918
6919        let (max_req, max_res, max_inflight) = staged_limits();
6920        registry
6921            .get_or_spawn(
6922                "127.0.0.1",
6923                port,
6924                max_req,
6925                max_res,
6926                max_inflight,
6927                test_rt(),
6928                "legacy-bind".into(),
6929                None,
6930            )
6931            .await
6932            .expect("legacy bind spawn");
6933        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
6934            .await
6935            .expect("connect to freshly bound port must succeed");
6936        assert!(resp.status().as_u16() >= 200);
6937        assert_eq!(
6938            registry.bound_addr("127.0.0.1", port),
6939            Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
6940            "bound addr must be the legacy bound (host, port)"
6941        );
6942    }
6943
6944    #[allow(clippy::await_holding_lock)]
6945    #[tokio::test]
6946    async fn wrong_host_staged_port_fails_deterministically() {
6947        let _guard = lock_registry_test_mutex();
6948        ServerRegistry::reset();
6949        let registry = ServerRegistry::global();
6950        let (listener, _probe, addr) = clone_fixture_listener().await;
6951        let port = addr.port();
6952        registry
6953            .stage_listener(listener)
6954            .await
6955            .expect("stage listener under 127.0.0.1");
6956
6957        let (max_req, max_res, max_inflight) = staged_limits();
6958        let err = registry
6959            .get_or_spawn(
6960                "localhost",
6961                port,
6962                max_req,
6963                max_res,
6964                max_inflight,
6965                test_rt(),
6966                "conflict-probe".into(),
6967                None,
6968            )
6969            .await
6970            .expect_err("wrong host on staged port must fail deterministically");
6971        assert!(
6972            err.to_string().contains("staged listener conflict on port"),
6973            "unexpected error: {err}"
6974        );
6975
6976        // Slot untouched by the failed call: the correct host now consumes it.
6977        registry
6978            .get_or_spawn(
6979                "127.0.0.1",
6980                port,
6981                max_req,
6982                max_res,
6983                max_inflight,
6984                test_rt(),
6985                "conflict-after".into(),
6986                None,
6987            )
6988            .await
6989            .expect("correct host must serve the staged listener");
6990        assert_eq!(
6991            registry.bound_addr("127.0.0.1", port),
6992            Some(addr),
6993            "staged slot must be untouched by the conflicting call"
6994        );
6995    }
6996
6997    #[allow(clippy::await_holding_lock)]
6998    #[tokio::test]
6999    async fn duplicate_stage_same_key_rejected() {
7000        let _guard = lock_registry_test_mutex();
7001        ServerRegistry::reset();
7002        let registry = ServerRegistry::global();
7003        let (listener, probe, addr) = clone_fixture_listener().await;
7004        registry
7005            .stage_listener(listener)
7006            .await
7007            .expect("stage listener A");
7008
7009        // Second tokio handle to the SAME socket: clone the std probe handle.
7010        let dup = probe.try_clone().expect("clone2");
7011        dup.set_nonblocking(true).expect("set_nonblocking2");
7012        let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
7013
7014        let err = registry
7015            .stage_listener(b)
7016            .await
7017            .expect_err("duplicate stage must be rejected");
7018        assert!(
7019            err.to_string().contains("listener already staged"),
7020            "unexpected error: {err}"
7021        );
7022
7023        let (max_req, max_res, max_inflight) = staged_limits();
7024        registry
7025            .get_or_spawn(
7026                "127.0.0.1",
7027                addr.port(),
7028                max_req,
7029                max_res,
7030                max_inflight,
7031                test_rt(),
7032                "dup-stage-after".into(),
7033                None,
7034            )
7035            .await
7036            .expect("spawn from first staged listener");
7037        assert_eq!(
7038            registry.bound_addr("127.0.0.1", addr.port()),
7039            Some(addr),
7040            "first staged listener retained"
7041        );
7042    }
7043
7044    #[allow(clippy::await_holding_lock)]
7045    #[tokio::test]
7046    async fn distinct_keys_stage_independently() {
7047        let _guard = lock_registry_test_mutex();
7048        ServerRegistry::reset();
7049        let registry = ServerRegistry::global();
7050        let (l1, _p1, addr1) = clone_fixture_listener().await;
7051        let (l2, _p2, addr2) = clone_fixture_listener().await;
7052        registry.stage_listener(l1).await.expect("stage P1");
7053        registry.stage_listener(l2).await.expect("stage P2");
7054
7055        let (max_req, max_res, max_inflight) = staged_limits();
7056        registry
7057            .get_or_spawn(
7058                "127.0.0.1",
7059                addr1.port(),
7060                max_req,
7061                max_res,
7062                max_inflight,
7063                test_rt(),
7064                "distinct-1".into(),
7065                None,
7066            )
7067            .await
7068            .expect("spawn P1");
7069        registry
7070            .get_or_spawn(
7071                "127.0.0.1",
7072                addr2.port(),
7073                max_req,
7074                max_res,
7075                max_inflight,
7076                test_rt(),
7077                "distinct-2".into(),
7078                None,
7079            )
7080            .await
7081            .expect("spawn P2");
7082        assert_eq!(
7083            registry.bound_addr("127.0.0.1", addr1.port()),
7084            Some(addr1),
7085            "P1 bound addr must be its own listener"
7086        );
7087        assert_eq!(
7088            registry.bound_addr("127.0.0.1", addr2.port()),
7089            Some(addr2),
7090            "P2 bound addr must be its own listener"
7091        );
7092        let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
7093            .await
7094            .expect("connect P1");
7095        assert!(r1.status().as_u16() >= 200);
7096        let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
7097            .await
7098            .expect("connect P2");
7099        assert!(r2.status().as_u16() >= 200);
7100    }
7101
7102    #[allow(clippy::await_holding_lock)]
7103    #[tokio::test]
7104    async fn tls_prebound_listener_served() {
7105        use camel_component_api::test_support::tls;
7106
7107        // Install rustls crypto provider (aws-lc-rs — matches the existing
7108        // TLS registry tests).
7109        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7110
7111        let _guard = lock_registry_test_mutex();
7112        ServerRegistry::reset();
7113        let registry = ServerRegistry::global();
7114        let (listener, _probe, addr) = clone_fixture_listener().await;
7115        let port = addr.port();
7116
7117        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7118        let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
7119        let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
7120        let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
7121
7122        let (max_req, max_res, max_inflight) = staged_limits();
7123        let routes = registry
7124            .get_or_spawn_with_listener(
7125                listener,
7126                max_req,
7127                max_res,
7128                max_inflight,
7129                test_rt(),
7130                "staged-tls".into(),
7131                Some(crate::config::ServerTlsConfig {
7132                    cert_path: cert_path.to_string_lossy().into_owned(),
7133                    key_path: key_path.to_string_lossy().into_owned(),
7134                }),
7135            )
7136            .await
7137            .expect("spawn TLS server from pre-bound listener");
7138
7139        // Client with CA cert — REAL verification (no danger_accept_invalid),
7140        // same helper pattern as the existing TLS registry tests.
7141        let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
7142        let client = reqwest::Client::builder()
7143            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
7144            .build()
7145            .expect("build tls client");
7146
7147        let resp = client
7148            .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
7149            .send()
7150            .await
7151            .expect("TLS handshake + request must succeed");
7152        assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
7153        assert_eq!(
7154            registry.bound_addr("127.0.0.1", port),
7155            Some(addr),
7156            "bound addr equals the pre-bound listener addr"
7157        );
7158        drop(routes);
7159    }
7160
7161    #[allow(clippy::await_holding_lock)]
7162    #[tokio::test]
7163    async fn with_listener_direct_spawn_keyed_by_actual_addr() {
7164        let _guard = lock_registry_test_mutex();
7165        ServerRegistry::reset();
7166        let registry = ServerRegistry::global();
7167        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7168            .await
7169            .expect("bind un-staged listener");
7170        let addr = listener.local_addr().expect("local addr");
7171        let port = addr.port();
7172
7173        let (max_req, max_res, max_inflight) = staged_limits();
7174        registry
7175            .get_or_spawn_with_listener(
7176                listener,
7177                max_req,
7178                max_res,
7179                max_inflight,
7180                test_rt(),
7181                "with-listener".into(),
7182                None,
7183            )
7184            .await
7185            .expect("direct spawn from un-staged listener");
7186        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
7187            .await
7188            .expect("connect on actual port");
7189        assert!(resp.status().as_u16() >= 200);
7190        assert_eq!(
7191            registry.bound_addr("127.0.0.1", port),
7192            Some(addr),
7193            "registry key is the listener's actual port"
7194        );
7195
7196        registry
7197            .get_or_spawn(
7198                "127.0.0.1",
7199                port,
7200                max_req,
7201                max_res,
7202                max_inflight,
7203                test_rt(),
7204                "with-listener-reuse".into(),
7205                None,
7206            )
7207            .await
7208            .expect("legacy caller must reuse the entry");
7209        assert_eq!(
7210            registry.bound_addr("127.0.0.1", port),
7211            Some(addr),
7212            "entry reused — no second bind"
7213        );
7214    }
7215
7216    // -----------------------------------------------------------------------
7217    // Axum dispatch handler tests
7218    // -----------------------------------------------------------------------
7219
7220    #[tokio::test]
7221    async fn test_dispatch_handler_returns_404_for_unknown_path() {
7222        let registry = HttpRouteRegistry::new();
7223        // Nothing registered in route registry
7224        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7225        let port = listener.local_addr().unwrap().port();
7226        tokio::spawn(run_axum_server(
7227            listener,
7228            registry,
7229            2 * 1024 * 1024,
7230            10 * 1024 * 1024,
7231            Arc::new(tokio::sync::Semaphore::new(1024)),
7232            test_rt(),
7233            "test-route".into(),
7234        ));
7235
7236        // Wait for server to start
7237        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7238
7239        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
7240            .await
7241            .unwrap();
7242        assert_eq!(resp.status().as_u16(), 404);
7243    }
7244
7245    // -----------------------------------------------------------------------
7246    // HttpConsumer tests
7247    // -----------------------------------------------------------------------
7248
7249    #[tokio::test]
7250    async fn test_http_consumer_start_registers_path() {
7251        use camel_component_api::ConsumerContext;
7252
7253        // Get an OS-assigned free port
7254        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7255        let port = listener.local_addr().unwrap().port();
7256        drop(listener); // Release port — ServerRegistry will rebind it
7257
7258        let consumer_cfg = HttpServerConfig {
7259            scheme: "http".to_string(),
7260            host: "127.0.0.1".to_string(),
7261            port,
7262            path: "/ping".to_string(),
7263            max_request_body: 2 * 1024 * 1024,
7264            max_response_body: 10 * 1024 * 1024,
7265            max_inflight_requests: 1024,
7266            method: None,
7267            tls_config: None,
7268        };
7269        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7270
7271        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7272        let token = tokio_util::sync::CancellationToken::new();
7273        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7274
7275        tokio::spawn(async move {
7276            consumer.start(ctx).await.unwrap();
7277        });
7278
7279        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7280
7281        let client = reqwest::Client::new();
7282        let resp_future = client
7283            .post(format!("http://127.0.0.1:{port}/ping"))
7284            .body("hello world")
7285            .send();
7286
7287        let (http_result, _) = tokio::join!(resp_future, async {
7288            if let Some(mut envelope) = rx.recv().await {
7289                // Set a custom status code
7290                envelope.exchange.input.set_header(
7291                    "CamelHttpResponseCode",
7292                    serde_json::Value::Number(201.into()),
7293                );
7294                if let Some(reply_tx) = envelope.reply_tx {
7295                    let _ = reply_tx.send(Ok(envelope.exchange));
7296                }
7297            }
7298        });
7299
7300        let resp = http_result.unwrap();
7301        assert_eq!(resp.status().as_u16(), 201);
7302
7303        token.cancel();
7304    }
7305
7306    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
7307    /// dispatcher's inflight semaphore so the semaphore stays the single
7308    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
7309    #[test]
7310    fn test_envelope_channel_capacity_follows_max_inflight() {
7311        assert_eq!(envelope_channel_capacity(0), 1);
7312        assert_eq!(envelope_channel_capacity(1), 1);
7313        assert_eq!(envelope_channel_capacity(7), 7);
7314        assert_eq!(envelope_channel_capacity(64), 64);
7315        assert_eq!(envelope_channel_capacity(1024), 1024);
7316    }
7317
7318    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
7319    /// configuration. Consumer start must not panic on it (the channel guard)
7320    /// and every request must get 503 from the empty semaphore.
7321    #[tokio::test]
7322    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
7323        use camel_component_api::ConsumerContext;
7324
7325        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7326        let port = listener.local_addr().unwrap().port();
7327        drop(listener);
7328
7329        let consumer_cfg = HttpServerConfig {
7330            scheme: "http".to_string(),
7331            host: "127.0.0.1".to_string(),
7332            port,
7333            path: "/ping".to_string(),
7334            max_request_body: 2 * 1024 * 1024,
7335            max_response_body: 10 * 1024 * 1024,
7336            max_inflight_requests: 0,
7337            method: None,
7338            tls_config: None,
7339        };
7340        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7341
7342        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7343        let token = tokio_util::sync::CancellationToken::new();
7344        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7345
7346        let start_handle = tokio::spawn(async move {
7347            consumer.start(ctx).await.unwrap();
7348        });
7349
7350        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7351
7352        let client = reqwest::Client::new();
7353        let resp = client
7354            .post(format!("http://127.0.0.1:{port}/ping"))
7355            .body("hello world")
7356            .send()
7357            .await
7358            .unwrap();
7359        assert_eq!(resp.status().as_u16(), 503);
7360
7361        token.cancel();
7362        let _ = start_handle.await;
7363    }
7364
7365    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
7366    /// waits for the listener bind before publishing RouteStarted.
7367    #[test]
7368    fn test_http_consumer_startup_mode_is_explicit() {
7369        use camel_component_api::ConsumerStartupMode;
7370        let consumer_cfg = HttpServerConfig {
7371            scheme: "http".to_string(),
7372            host: "127.0.0.1".to_string(),
7373            port: 0,
7374            path: "/x".to_string(),
7375            max_request_body: 2 * 1024 * 1024,
7376            max_response_body: 10 * 1024 * 1024,
7377            max_inflight_requests: 1024,
7378            method: None,
7379            tls_config: None,
7380        };
7381        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
7382        assert_eq!(
7383            consumer.startup_mode(),
7384            ConsumerStartupMode::Explicit,
7385            "HttpConsumer must opt into Explicit startup"
7386        );
7387    }
7388
7389    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
7390    /// + route registration. The StartupSignal resolves Ok only when that
7391    /// happens. Verified here by injecting our own signal pair into the
7392    /// ConsumerContext and asserting the receiver resolves within a bounded
7393    /// window even before any HTTP request is made.
7394    #[allow(clippy::await_holding_lock)]
7395    #[tokio::test]
7396    async fn test_http_consumer_emits_mark_ready_after_bind() {
7397        use camel_component_api::{ConsumerContext, StartupSignal};
7398
7399        let _guard = lock_registry_test_mutex();
7400
7401        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7402        let port = listener.local_addr().unwrap().port();
7403        drop(listener);
7404
7405        let consumer_cfg = HttpServerConfig {
7406            scheme: "http".to_string(),
7407            host: "127.0.0.1".to_string(),
7408            port,
7409            path: "/ready-probe".to_string(),
7410            max_request_body: 2 * 1024 * 1024,
7411            max_response_body: 10 * 1024 * 1024,
7412            max_inflight_requests: 1024,
7413            method: None,
7414            tls_config: None,
7415        };
7416        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7417
7418        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7419        let token = tokio_util::sync::CancellationToken::new();
7420        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
7421
7422        // Inject our own startup signal so we can observe mark_ready.
7423        let (signal, startup_rx) = StartupSignal::pair();
7424        let ctx = ctx.with_startup(signal);
7425
7426        // Spawn start() — it MUST call mark_ready once the listener is bound
7427        // and the path is registered.
7428        tokio::spawn(async move {
7429            let _ = consumer.start(ctx).await;
7430        });
7431
7432        // The receiver MUST resolve Ok within a bounded window — proving
7433        // mark_ready was called by start(). A short timeout catches the
7434        // regression where mark_ready is never called (the old behaviour
7435        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
7436        let result =
7437            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
7438                .await
7439                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
7440        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
7441
7442        // Cancellation tears down the spawned start() loop.
7443        token.cancel();
7444    }
7445
7446    #[tokio::test]
7447    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
7448        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7449
7450        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7451        let port = listener.local_addr().unwrap().port();
7452        drop(listener);
7453
7454        let consumer_cfg = HttpServerConfig {
7455            scheme: "http".to_string(),
7456            host: "127.0.0.1".to_string(),
7457            port,
7458            path: "/saturation".to_string(),
7459            max_request_body: 2 * 1024 * 1024,
7460            max_response_body: 10 * 1024 * 1024,
7461            max_inflight_requests: 1,
7462            method: None,
7463            tls_config: None,
7464        };
7465        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7466
7467        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7468        let token = tokio_util::sync::CancellationToken::new();
7469        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7470        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7471        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7472
7473        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
7474        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
7475
7476        tokio::spawn(async move {
7477            let mut first_seen_tx = Some(first_seen_tx);
7478            let mut unblock_first_rx = Some(unblock_first_rx);
7479
7480            while let Some(envelope) = rx.recv().await {
7481                if let Some(tx) = first_seen_tx.take() {
7482                    let _ = tx.send(());
7483                    if let Some(rx_unblock) = unblock_first_rx.take() {
7484                        let _ = rx_unblock.await;
7485                    }
7486                }
7487
7488                if let Some(reply_tx) = envelope.reply_tx {
7489                    let _ = reply_tx.send(Ok(envelope.exchange));
7490                }
7491            }
7492        });
7493
7494        let client = reqwest::Client::new();
7495        let first_req = {
7496            let client = client.clone();
7497            async move {
7498                client
7499                    .get(format!("http://127.0.0.1:{port}/saturation"))
7500                    .send()
7501                    .await
7502                    .unwrap()
7503            }
7504        };
7505
7506        let first_handle = tokio::spawn(first_req);
7507        first_seen_rx.await.unwrap();
7508
7509        let second_resp = client
7510            .get(format!("http://127.0.0.1:{port}/saturation"))
7511            .send()
7512            .await
7513            .unwrap();
7514
7515        assert_eq!(second_resp.status().as_u16(), 503);
7516
7517        let _ = unblock_first_tx.send(());
7518        let first_resp = first_handle.await.unwrap();
7519        assert_eq!(first_resp.status().as_u16(), 200);
7520
7521        token.cancel();
7522    }
7523
7524    /// Audit 2026-08-31, F2-1: a chunked (no Content-Length) request body must
7525    /// still be capped — the byte limit travels with the stream, so any
7526    /// downstream materialization fails closed past `max_request_body`.
7527    #[tokio::test]
7528    async fn test_http_consumer_chunked_body_is_capped() {
7529        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7530
7531        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7532        let port = listener.local_addr().unwrap().port();
7533        drop(listener);
7534
7535        let consumer_cfg = HttpServerConfig {
7536            scheme: "http".to_string(),
7537            host: "127.0.0.1".to_string(),
7538            port,
7539            path: "/chunked-cap".to_string(),
7540            max_request_body: 1024, // tiny cap for the test
7541            max_response_body: 10 * 1024 * 1024,
7542            max_inflight_requests: 16,
7543            method: None,
7544            tls_config: None,
7545        };
7546        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7547
7548        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7549        let token = tokio_util::sync::CancellationToken::new();
7550        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7551        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7552        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7553
7554        // Chunked body: reqwest streams it without Content-Length.
7555        let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
7556            .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
7557            .collect();
7558        let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
7559
7560        let client = reqwest::Client::new();
7561        let send_fut = client
7562            .post(format!("http://127.0.0.1:{port}/chunked-cap"))
7563            .body(stream_body)
7564            .send();
7565
7566        let (http_result, _) = tokio::join!(send_fut, async {
7567            if let Some(mut envelope) = rx.recv().await {
7568                // The route materializes the body — the cap must fire.
7569                let materialized = envelope
7570                    .exchange
7571                    .input
7572                    .body
7573                    .clone()
7574                    .into_bytes(64 * 1024)
7575                    .await;
7576                assert!(
7577                    materialized.is_err(),
7578                    "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
7579                );
7580                let err = materialized.unwrap_err().to_string();
7581                assert!(
7582                    err.contains("limit") || err.contains("exceeds"),
7583                    "error should mention the limit: {err}"
7584                );
7585                if let Some(reply_tx) = envelope.reply_tx {
7586                    envelope.exchange.input.body =
7587                        camel_component_api::Body::Text("handled".to_string());
7588                    let _ = reply_tx.send(Ok(envelope.exchange));
7589                }
7590            }
7591        });
7592
7593        let resp = http_result.unwrap();
7594        assert_eq!(resp.status().as_u16(), 200);
7595
7596        token.cancel();
7597    }
7598
7599    #[tokio::test]
7600    #[allow(clippy::await_holding_lock)]
7601    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
7602        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7603
7604        let _guard = lock_registry_test_mutex();
7605
7606        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7607        let port = listener.local_addr().unwrap().port();
7608        drop(listener);
7609
7610        let consumer_cfg = HttpServerConfig {
7611            scheme: "http".to_string(),
7612            host: "127.0.0.1".to_string(),
7613            port,
7614            path: "/limit-bytes".to_string(),
7615            max_request_body: 2 * 1024 * 1024,
7616            max_response_body: 16,
7617            max_inflight_requests: 1024,
7618            method: None,
7619            tls_config: None,
7620        };
7621        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7622
7623        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7624        let token = tokio_util::sync::CancellationToken::new();
7625        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7626        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7627        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7628
7629        let client = reqwest::Client::new();
7630        let send_fut = client
7631            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
7632            .send();
7633
7634        let (http_result, _) = tokio::join!(send_fut, async {
7635            if let Some(mut envelope) = rx.recv().await {
7636                envelope.exchange.input.body =
7637                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
7638                if let Some(reply_tx) = envelope.reply_tx {
7639                    let _ = reply_tx.send(Ok(envelope.exchange));
7640                }
7641            }
7642        });
7643
7644        let resp = http_result.unwrap();
7645        assert_eq!(resp.status().as_u16(), 500);
7646        let body = resp.text().await.unwrap();
7647        assert_eq!(body, "Response body exceeds configured limit");
7648        token.cancel();
7649    }
7650
7651    #[tokio::test]
7652    #[allow(clippy::await_holding_lock)]
7653    async fn test_http_consumer_enforces_max_response_body_for_json() {
7654        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7655
7656        let _guard = lock_registry_test_mutex();
7657
7658        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7659        let port = listener.local_addr().unwrap().port();
7660        drop(listener);
7661
7662        let consumer_cfg = HttpServerConfig {
7663            scheme: "http".to_string(),
7664            host: "127.0.0.1".to_string(),
7665            port,
7666            path: "/limit-json".to_string(),
7667            max_request_body: 2 * 1024 * 1024,
7668            max_response_body: 16,
7669            max_inflight_requests: 1024,
7670            method: None,
7671            tls_config: None,
7672        };
7673        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7674
7675        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7676        let token = tokio_util::sync::CancellationToken::new();
7677        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7678        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7679        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7680
7681        let client = reqwest::Client::new();
7682        let send_fut = client
7683            .get(format!("http://127.0.0.1:{port}/limit-json"))
7684            .send();
7685
7686        let (http_result, _) = tokio::join!(send_fut, async {
7687            if let Some(mut envelope) = rx.recv().await {
7688                envelope.exchange.input.body = camel_component_api::Body::Json(
7689                    serde_json::json!({"message":"this response is bigger than sixteen"}),
7690                );
7691                if let Some(reply_tx) = envelope.reply_tx {
7692                    let _ = reply_tx.send(Ok(envelope.exchange));
7693                }
7694            }
7695        });
7696
7697        let resp = http_result.unwrap();
7698        assert_eq!(resp.status().as_u16(), 500);
7699        let body = resp.text().await.unwrap();
7700        assert_eq!(body, "Response body exceeds configured limit");
7701        token.cancel();
7702    }
7703
7704    #[tokio::test]
7705    #[allow(clippy::await_holding_lock)]
7706    async fn test_http_consumer_enforces_max_response_body_for_xml() {
7707        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7708
7709        let _guard = lock_registry_test_mutex();
7710
7711        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7712        let port = listener.local_addr().unwrap().port();
7713        drop(listener);
7714
7715        let consumer_cfg = HttpServerConfig {
7716            scheme: "http".to_string(),
7717            host: "127.0.0.1".to_string(),
7718            port,
7719            path: "/limit-xml".to_string(),
7720            max_request_body: 2 * 1024 * 1024,
7721            max_response_body: 16,
7722            max_inflight_requests: 1024,
7723            method: None,
7724            tls_config: None,
7725        };
7726        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7727
7728        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7729        let token = tokio_util::sync::CancellationToken::new();
7730        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7731        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7732        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7733
7734        let client = reqwest::Client::new();
7735        let send_fut = client
7736            .get(format!("http://127.0.0.1:{port}/limit-xml"))
7737            .send();
7738
7739        let (http_result, _) = tokio::join!(send_fut, async {
7740            if let Some(mut envelope) = rx.recv().await {
7741                envelope.exchange.input.body = camel_component_api::Body::Xml(
7742                    "<root><value>way-too-large</value></root>".into(),
7743                );
7744                if let Some(reply_tx) = envelope.reply_tx {
7745                    let _ = reply_tx.send(Ok(envelope.exchange));
7746                }
7747            }
7748        });
7749
7750        let resp = http_result.unwrap();
7751        assert_eq!(resp.status().as_u16(), 500);
7752        let body = resp.text().await.unwrap();
7753        assert_eq!(body, "Response body exceeds configured limit");
7754        token.cancel();
7755    }
7756
7757    #[tokio::test]
7758    #[allow(clippy::await_holding_lock)]
7759    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
7760        use camel_component_api::{
7761            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
7762        };
7763        use futures::stream;
7764
7765        let _guard = lock_registry_test_mutex();
7766
7767        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
7768        let port = listener.local_addr().unwrap().port();
7769        drop(listener);
7770
7771        let consumer_cfg = HttpServerConfig {
7772            scheme: "http".to_string(),
7773            host: "0.0.0.0".to_string(),
7774            port,
7775            path: "/limit-stream".to_string(),
7776            max_request_body: 2 * 1024 * 1024,
7777            max_response_body: 16,
7778            max_inflight_requests: 1024,
7779            method: None,
7780            tls_config: None,
7781        };
7782        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7783
7784        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7785        let token = tokio_util::sync::CancellationToken::new();
7786        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7787        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7788        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7789
7790        let client = reqwest::Client::new();
7791        let send_fut = client
7792            .get(format!("http://127.0.0.1:{port}/limit-stream"))
7793            .send();
7794
7795        let (http_result, _) = tokio::join!(send_fut, async {
7796            if let Some(mut envelope) = rx.recv().await {
7797                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
7798                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
7799                let stream = Box::pin(stream::iter(chunks));
7800                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
7801                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
7802                    metadata: StreamMetadata {
7803                        size_hint: Some(32),
7804                        content_type: Some("application/octet-stream".into()),
7805                        origin: None,
7806                    },
7807                });
7808                if let Some(reply_tx) = envelope.reply_tx {
7809                    let _ = reply_tx.send(Ok(envelope.exchange));
7810                }
7811            }
7812        });
7813
7814        let resp = http_result.unwrap();
7815        assert_eq!(resp.status().as_u16(), 200);
7816        let body = resp.bytes().await.unwrap();
7817        assert_eq!(body.len(), 32);
7818        token.cancel();
7819    }
7820
7821    // -----------------------------------------------------------------------
7822    // Integration tests
7823    // -----------------------------------------------------------------------
7824
7825    #[tokio::test]
7826    #[allow(clippy::await_holding_lock)]
7827    async fn test_integration_single_consumer_round_trip() {
7828        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7829
7830        // Spawns an HTTP consumer on the global ServerRegistry
7831        // (HttpConsumer::start → get_or_spawn). Serialize against the other
7832        // registry tests so parallel runs do not race on shared global state.
7833        let _guard = lock_registry_test_mutex();
7834
7835        // Get an OS-assigned free port (ephemeral)
7836        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7837        let port = listener.local_addr().unwrap().port();
7838        drop(listener); // Release — ServerRegistry will rebind
7839
7840        let component = HttpComponent::new();
7841        let endpoint_ctx = NoOpComponentContext;
7842        let endpoint = component
7843            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
7844            .unwrap();
7845        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7846
7847        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7848        let token = tokio_util::sync::CancellationToken::new();
7849        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7850
7851        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7852        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7853
7854        let client = reqwest::Client::new();
7855        let send_fut = client
7856            .post(format!("http://127.0.0.1:{port}/echo"))
7857            .header("Content-Type", "text/plain")
7858            .body("ping")
7859            .send();
7860
7861        let (http_result, _) = tokio::join!(send_fut, async {
7862            if let Some(mut envelope) = rx.recv().await {
7863                assert_eq!(
7864                    envelope.exchange.input.header("CamelHttpMethod"),
7865                    Some(&serde_json::Value::String("POST".into()))
7866                );
7867                assert_eq!(
7868                    envelope.exchange.input.header("CamelHttpPath"),
7869                    Some(&serde_json::Value::String("/echo".into()))
7870                );
7871                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7872                if let Some(reply_tx) = envelope.reply_tx {
7873                    let _ = reply_tx.send(Ok(envelope.exchange));
7874                }
7875            }
7876        });
7877
7878        let resp = http_result.unwrap();
7879        assert_eq!(resp.status().as_u16(), 200);
7880        let body = resp.text().await.unwrap();
7881        assert_eq!(body, "pong");
7882
7883        token.cancel();
7884    }
7885
7886    #[tokio::test]
7887    #[allow(clippy::await_holding_lock)]
7888    async fn test_integration_two_consumers_shared_port() {
7889        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7890
7891        let _guard = lock_registry_test_mutex();
7892
7893        // Get an OS-assigned free port (ephemeral)
7894        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7895        let port = listener.local_addr().unwrap().port();
7896        drop(listener);
7897
7898        let component = HttpComponent::new();
7899        let endpoint_ctx = NoOpComponentContext;
7900
7901        // Consumer A: /hello
7902        let endpoint_a = component
7903            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
7904            .unwrap();
7905        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
7906
7907        // Consumer B: /world
7908        let endpoint_b = component
7909            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
7910            .unwrap();
7911        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
7912
7913        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7914        let token_a = tokio_util::sync::CancellationToken::new();
7915        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
7916
7917        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7918        let token_b = tokio_util::sync::CancellationToken::new();
7919        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
7920
7921        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
7922        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
7923        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7924
7925        let client = reqwest::Client::new();
7926
7927        // Request to /hello
7928        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
7929        let (resp_hello, _) = tokio::join!(fut_hello, async {
7930            if let Some(mut envelope) = rx_a.recv().await {
7931                envelope.exchange.input.body =
7932                    camel_component_api::Body::Text("hello-response".to_string());
7933                if let Some(reply_tx) = envelope.reply_tx {
7934                    let _ = reply_tx.send(Ok(envelope.exchange));
7935                }
7936            }
7937        });
7938
7939        // Request to /world
7940        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
7941        let (resp_world, _) = tokio::join!(fut_world, async {
7942            if let Some(mut envelope) = rx_b.recv().await {
7943                envelope.exchange.input.body =
7944                    camel_component_api::Body::Text("world-response".to_string());
7945                if let Some(reply_tx) = envelope.reply_tx {
7946                    let _ = reply_tx.send(Ok(envelope.exchange));
7947                }
7948            }
7949        });
7950
7951        let body_a = resp_hello.unwrap().text().await.unwrap();
7952        let body_b = resp_world.unwrap().text().await.unwrap();
7953
7954        assert_eq!(body_a, "hello-response");
7955        assert_eq!(body_b, "world-response");
7956
7957        token_a.cancel();
7958        token_b.cancel();
7959    }
7960
7961    #[tokio::test]
7962    #[allow(clippy::await_holding_lock)]
7963    async fn test_integration_unregistered_path_returns_404() {
7964        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7965
7966        let _guard = lock_registry_test_mutex();
7967
7968        // Get an OS-assigned free port (ephemeral)
7969        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7970        let port = listener.local_addr().unwrap().port();
7971        drop(listener);
7972
7973        let component = HttpComponent::new();
7974        let endpoint_ctx = NoOpComponentContext;
7975        let endpoint = component
7976            .create_endpoint(
7977                &format!("http://127.0.0.1:{port}/registered"),
7978                &endpoint_ctx,
7979            )
7980            .unwrap();
7981        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7982
7983        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7984        let token = tokio_util::sync::CancellationToken::new();
7985        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7986
7987        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7988
7989        // Wait until the server is actually accepting connections (CI runners can be slow).
7990        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7991        loop {
7992            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
7993                .await
7994                .is_ok()
7995            {
7996                break;
7997            }
7998            if std::time::Instant::now() >= deadline {
7999                panic!("HTTP server did not start within 5s on port {port}");
8000            }
8001            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8002        }
8003
8004        let client = reqwest::Client::new();
8005        let resp = client
8006            .get(format!("http://127.0.0.1:{port}/not-there"))
8007            .send()
8008            .await
8009            .unwrap();
8010        assert_eq!(resp.status().as_u16(), 404);
8011
8012        token.cancel();
8013    }
8014
8015    #[test]
8016    fn test_http_consumer_declares_concurrent() {
8017        use camel_component_api::ConcurrencyModel;
8018
8019        let config = HttpServerConfig {
8020            scheme: "http".to_string(),
8021            host: "127.0.0.1".to_string(),
8022            port: 19999,
8023            path: "/test".to_string(),
8024            max_request_body: 2 * 1024 * 1024,
8025            max_response_body: 10 * 1024 * 1024,
8026            max_inflight_requests: 1024,
8027            method: None,
8028            tls_config: None,
8029        };
8030        let consumer = HttpConsumer::new(config, test_rt());
8031        assert_eq!(
8032            consumer.concurrency_model(),
8033            ConcurrencyModel::Concurrent { max: None }
8034        );
8035    }
8036
8037    #[test]
8038    fn server_config_parses_tls_cert_and_key() {
8039        let cfg = HttpServerConfig::from_uri(
8040            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
8041        )
8042        .unwrap();
8043        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
8044        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
8045    }
8046
8047    #[test]
8048    fn server_config_no_tls_when_params_absent() {
8049        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
8050        assert!(cfg.tls_config.is_none());
8051    }
8052
8053    // -----------------------------------------------------------------------
8054    // HttpReplyBody streaming tests
8055    // -----------------------------------------------------------------------
8056
8057    #[tokio::test]
8058    async fn test_http_reply_body_stream_variant_exists() {
8059        use bytes::Bytes;
8060        use camel_component_api::CamelError;
8061        use futures::stream;
8062
8063        let chunks: Vec<Result<Bytes, CamelError>> =
8064            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
8065        let stream = Box::pin(stream::iter(chunks));
8066        let reply_body = HttpReplyBody::Stream(stream);
8067        // Si compila y el match funciona, el test pasa
8068        match reply_body {
8069            HttpReplyBody::Stream(_) => {}
8070            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
8071        }
8072    }
8073
8074    // -----------------------------------------------------------------------
8075    // OpenTelemetry propagation tests (only compiled with "otel" feature)
8076    // -----------------------------------------------------------------------
8077
8078    #[cfg(feature = "otel")]
8079    mod otel_tests {
8080        use super::*;
8081        use camel_component_api::Message;
8082        use tower::ServiceExt;
8083
8084        #[tokio::test]
8085        async fn test_producer_injects_traceparent_header() {
8086            let (url, _handle) = start_test_server_with_header_capture().await;
8087            let ctx = test_producer_ctx();
8088
8089            let component = HttpComponent::new();
8090            let endpoint_ctx = NoOpComponentContext;
8091            let endpoint = component
8092                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8093                .unwrap();
8094            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8095
8096            // Create exchange with an OTel context by extracting from a traceparent header
8097            let mut exchange = Exchange::new(Message::default());
8098            let mut headers = std::collections::HashMap::new();
8099            headers.insert(
8100                "traceparent".to_string(),
8101                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
8102            );
8103            camel_otel::extract_into_exchange(&mut exchange, &headers);
8104
8105            let result = producer.oneshot(exchange).await.unwrap();
8106
8107            // Verify request succeeded
8108            let status = result
8109                .input
8110                .header("CamelHttpResponseCode")
8111                .and_then(|v| v.as_u64())
8112                .unwrap();
8113            assert_eq!(status, 200);
8114
8115            // The test server echoes back the received traceparent header
8116            let traceparent = result.input.header("X-Received-Traceparent");
8117            assert!(
8118                traceparent.is_some(),
8119                "traceparent header should have been sent"
8120            );
8121
8122            let traceparent_str = traceparent.unwrap().as_str().unwrap();
8123            // Verify format: version-traceid-spanid-flags
8124            let parts: Vec<&str> = traceparent_str.split('-').collect();
8125            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8126            assert_eq!(parts[0], "00", "version should be 00");
8127            assert_eq!(
8128                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8129                "trace-id should match"
8130            );
8131            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
8132            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
8133        }
8134
8135        #[tokio::test]
8136        async fn test_consumer_extracts_traceparent_header() {
8137            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8138
8139            // Get an OS-assigned free port
8140            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8141            let port = listener.local_addr().unwrap().port();
8142            drop(listener);
8143
8144            let component = HttpComponent::new();
8145            let endpoint_ctx = NoOpComponentContext;
8146            let endpoint = component
8147                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8148                .unwrap();
8149            let mut consumer = endpoint.create_consumer(rt()).unwrap();
8150
8151            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8152            let token = tokio_util::sync::CancellationToken::new();
8153            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8154
8155            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8156            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8157
8158            // Send request with traceparent header
8159            let client = reqwest::Client::new();
8160            let send_fut = client
8161                .post(format!("http://127.0.0.1:{port}/trace"))
8162                .header(
8163                    "traceparent",
8164                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8165                )
8166                .body("test")
8167                .send();
8168
8169            let (http_result, _) = tokio::join!(send_fut, async {
8170                if let Some(envelope) = rx.recv().await {
8171                    // Verify the exchange has a valid OTel context by re-injecting it
8172                    // and checking the traceparent matches
8173                    let mut injected_headers = std::collections::HashMap::new();
8174                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8175
8176                    assert!(
8177                        injected_headers.contains_key("traceparent"),
8178                        "Exchange should have traceparent after extraction"
8179                    );
8180
8181                    let traceparent = injected_headers.get("traceparent").unwrap();
8182                    let parts: Vec<&str> = traceparent.split('-').collect();
8183                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8184                    assert_eq!(
8185                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8186                        "Trace ID should match the original traceparent header"
8187                    );
8188
8189                    if let Some(reply_tx) = envelope.reply_tx {
8190                        let _ = reply_tx.send(Ok(envelope.exchange));
8191                    }
8192                }
8193            });
8194
8195            let resp = http_result.unwrap();
8196            assert_eq!(resp.status().as_u16(), 200);
8197
8198            token.cancel();
8199        }
8200
8201        #[tokio::test]
8202        async fn test_consumer_extracts_mixed_case_traceparent_header() {
8203            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8204
8205            // Get an OS-assigned free port
8206            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8207            let port = listener.local_addr().unwrap().port();
8208            drop(listener);
8209
8210            let component = HttpComponent::new();
8211            let endpoint_ctx = NoOpComponentContext;
8212            let endpoint = component
8213                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8214                .unwrap();
8215            let mut consumer = endpoint.create_consumer(rt()).unwrap();
8216
8217            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8218            let token = tokio_util::sync::CancellationToken::new();
8219            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8220
8221            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8222            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8223
8224            // Send request with MIXED-CASE TraceParent header (not lowercase)
8225            let client = reqwest::Client::new();
8226            let send_fut = client
8227                .post(format!("http://127.0.0.1:{port}/trace"))
8228                .header(
8229                    "TraceParent",
8230                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8231                )
8232                .body("test")
8233                .send();
8234
8235            let (http_result, _) = tokio::join!(send_fut, async {
8236                if let Some(envelope) = rx.recv().await {
8237                    // Verify the exchange has a valid OTel context by re-injecting it
8238                    // and checking the traceparent matches
8239                    let mut injected_headers = HashMap::new();
8240                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8241
8242                    assert!(
8243                        injected_headers.contains_key("traceparent"),
8244                        "Exchange should have traceparent after extraction from mixed-case header"
8245                    );
8246
8247                    let traceparent = injected_headers.get("traceparent").unwrap();
8248                    let parts: Vec<&str> = traceparent.split('-').collect();
8249                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8250                    assert_eq!(
8251                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8252                        "Trace ID should match the original mixed-case TraceParent header"
8253                    );
8254
8255                    if let Some(reply_tx) = envelope.reply_tx {
8256                        let _ = reply_tx.send(Ok(envelope.exchange));
8257                    }
8258                }
8259            });
8260
8261            let resp = http_result.unwrap();
8262            assert_eq!(resp.status().as_u16(), 200);
8263
8264            token.cancel();
8265        }
8266
8267        #[tokio::test]
8268        async fn test_producer_no_trace_context_no_crash() {
8269            let (url, _handle) = start_test_server().await;
8270            let ctx = test_producer_ctx();
8271
8272            let component = HttpComponent::new();
8273            let endpoint_ctx = NoOpComponentContext;
8274            let endpoint = component
8275                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8276                .unwrap();
8277            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8278
8279            // Create exchange with default (empty) otel_context - no trace context
8280            let exchange = Exchange::new(Message::default());
8281
8282            // Should succeed without panic
8283            let result = producer.oneshot(exchange).await.unwrap();
8284
8285            // Verify request succeeded
8286            let status = result
8287                .input
8288                .header("CamelHttpResponseCode")
8289                .and_then(|v| v.as_u64())
8290                .unwrap();
8291            assert_eq!(status, 200);
8292        }
8293
8294        /// Test server that captures and echoes back the traceparent header
8295        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
8296            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8297            let addr = listener.local_addr().unwrap();
8298            let url = format!("http://127.0.0.1:{}", addr.port());
8299
8300            let handle = tokio::spawn(async move {
8301                loop {
8302                    if let Ok((mut stream, _)) = listener.accept().await {
8303                        tokio::spawn(async move {
8304                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
8305                            let mut buf = vec![0u8; 8192];
8306                            let n = stream.read(&mut buf).await.unwrap_or(0);
8307                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
8308
8309                            // Extract traceparent header from request
8310                            let traceparent = request
8311                                .lines()
8312                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
8313                                .map(|line| {
8314                                    line.split(':')
8315                                        .nth(1)
8316                                        .map(|s| s.trim().to_string())
8317                                        .unwrap_or_default()
8318                                })
8319                                .unwrap_or_default();
8320
8321                            let body =
8322                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
8323                            let response = format!(
8324                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
8325                                body.len(),
8326                                traceparent,
8327                                body
8328                            );
8329                            let _ = stream.write_all(response.as_bytes()).await;
8330                        });
8331                    }
8332                }
8333            });
8334
8335            (url, handle)
8336        }
8337    }
8338
8339    // -----------------------------------------------------------------------
8340    // Response streaming tests (Eje A - Task 2)
8341    // -----------------------------------------------------------------------
8342
8343    // -----------------------------------------------------------------------
8344    // Request streaming tests (Eje B - Task 3)
8345    // -----------------------------------------------------------------------
8346
8347    #[tokio::test]
8348    async fn test_request_body_arrives_as_stream() {
8349        use camel_component_api::Body;
8350        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8351
8352        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8353        let port = listener.local_addr().unwrap().port();
8354        drop(listener);
8355
8356        let component = HttpComponent::new();
8357        let endpoint_ctx = NoOpComponentContext;
8358        let endpoint = component
8359            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
8360            .unwrap();
8361        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8362
8363        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8364        let token = tokio_util::sync::CancellationToken::new();
8365        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8366
8367        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8368        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8369
8370        let client = reqwest::Client::new();
8371        let send_fut = client
8372            .post(format!("http://127.0.0.1:{port}/upload"))
8373            .body("hello streaming world")
8374            .send();
8375
8376        let (http_result, _) = tokio::join!(send_fut, async {
8377            if let Some(mut envelope) = rx.recv().await {
8378                // Body must be Body::Stream, not Body::Text or Body::Bytes
8379                assert!(
8380                    matches!(envelope.exchange.input.body, Body::Stream(_)),
8381                    "expected Body::Stream, got discriminant {:?}",
8382                    std::mem::discriminant(&envelope.exchange.input.body)
8383                );
8384                // Materialize to verify content
8385                let bytes = envelope
8386                    .exchange
8387                    .input
8388                    .body
8389                    .into_bytes(1024 * 1024)
8390                    .await
8391                    .unwrap();
8392                assert_eq!(&bytes[..], b"hello streaming world");
8393
8394                envelope.exchange.input.body = camel_component_api::Body::Empty;
8395                if let Some(reply_tx) = envelope.reply_tx {
8396                    let _ = reply_tx.send(Ok(envelope.exchange));
8397                }
8398            }
8399        });
8400
8401        let resp = http_result.unwrap();
8402        assert_eq!(resp.status().as_u16(), 200);
8403
8404        token.cancel();
8405    }
8406
8407    // -----------------------------------------------------------------------
8408    // Response streaming tests (Eje A - Task 2)
8409    // -----------------------------------------------------------------------
8410
8411    #[tokio::test]
8412    async fn test_streaming_response_chunked() {
8413        use bytes::Bytes;
8414        use camel_component_api::Body;
8415        use camel_component_api::CamelError;
8416        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8417        use camel_component_api::{StreamBody, StreamMetadata};
8418        use futures::stream;
8419        use std::sync::Arc;
8420        use tokio::sync::Mutex;
8421
8422        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8423        let port = listener.local_addr().unwrap().port();
8424        drop(listener);
8425
8426        let component = HttpComponent::new();
8427        let endpoint_ctx = NoOpComponentContext;
8428        let endpoint = component
8429            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
8430            .unwrap();
8431        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8432
8433        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8434        let token = tokio_util::sync::CancellationToken::new();
8435        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8436
8437        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8438        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8439
8440        let client = reqwest::Client::new();
8441        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
8442
8443        let (http_result, _) = tokio::join!(send_fut, async {
8444            if let Some(mut envelope) = rx.recv().await {
8445                // Respond with Body::Stream
8446                let chunks: Vec<Result<Bytes, CamelError>> =
8447                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
8448                let stream = Box::pin(stream::iter(chunks));
8449                envelope.exchange.input.body = Body::Stream(StreamBody {
8450                    stream: Arc::new(Mutex::new(Some(stream))),
8451                    metadata: StreamMetadata::default(),
8452                });
8453                if let Some(reply_tx) = envelope.reply_tx {
8454                    let _ = reply_tx.send(Ok(envelope.exchange));
8455                }
8456            }
8457        });
8458
8459        let resp = http_result.unwrap();
8460        assert_eq!(resp.status().as_u16(), 200);
8461        let body = resp.text().await.unwrap();
8462        assert_eq!(body, "chunk1chunk2");
8463
8464        token.cancel();
8465    }
8466
8467    // -----------------------------------------------------------------------
8468    // 413 Content-Length limit test (Task 4)
8469    // -----------------------------------------------------------------------
8470
8471    #[tokio::test]
8472    async fn test_413_when_content_length_exceeds_limit() {
8473        use camel_component_api::ConsumerContext;
8474
8475        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8476        let port = listener.local_addr().unwrap().port();
8477        drop(listener);
8478
8479        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
8480        let component = HttpComponent::new();
8481        let endpoint_ctx = NoOpComponentContext;
8482        let endpoint = component
8483            .create_endpoint(
8484                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
8485                &endpoint_ctx,
8486            )
8487            .unwrap();
8488        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8489
8490        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8491        let token = tokio_util::sync::CancellationToken::new();
8492        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8493
8494        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8495        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8496
8497        let client = reqwest::Client::new();
8498        let resp = client
8499            .post(format!("http://127.0.0.1:{port}/upload"))
8500            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
8501            .body("x".repeat(1000))
8502            .send()
8503            .await
8504            .unwrap();
8505
8506        assert_eq!(resp.status().as_u16(), 413);
8507
8508        token.cancel();
8509    }
8510
8511    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
8512    /// The spec says: "If there is no Content-Length, the limit does not apply at the
8513    /// consumer level — the route is responsible."
8514    #[tokio::test]
8515    async fn test_chunked_upload_without_content_length_bypasses_limit() {
8516        use bytes::Bytes;
8517        use camel_component_api::Body;
8518        use camel_component_api::ConsumerContext;
8519        use futures::stream;
8520
8521        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8522        let port = listener.local_addr().unwrap().port();
8523        drop(listener);
8524
8525        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
8526        let component = HttpComponent::new();
8527        let endpoint_ctx = NoOpComponentContext;
8528        let endpoint = component
8529            .create_endpoint(
8530                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
8531                &endpoint_ctx,
8532            )
8533            .unwrap();
8534        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8535
8536        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8537        let token = tokio_util::sync::CancellationToken::new();
8538        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8539
8540        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8541        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8542
8543        let client = reqwest::Client::new();
8544
8545        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
8546        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
8547        // but since there's no Content-Length the 413 check must NOT fire.
8548        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
8549            Ok(Bytes::from("y".repeat(50))),
8550            Ok(Bytes::from("y".repeat(50))),
8551        ];
8552        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
8553        let send_fut = client
8554            .post(format!("http://127.0.0.1:{port}/upload"))
8555            .body(stream_body)
8556            .send();
8557
8558        let consumer_fut = async {
8559            // Use timeout to avoid deadlock if the handler rejects before enqueueing
8560            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
8561                Ok(Some(mut envelope)) => {
8562                    assert!(
8563                        matches!(envelope.exchange.input.body, Body::Stream(_)),
8564                        "expected Body::Stream"
8565                    );
8566                    envelope.exchange.input.body = camel_component_api::Body::Empty;
8567                    if let Some(reply_tx) = envelope.reply_tx {
8568                        let _ = reply_tx.send(Ok(envelope.exchange));
8569                    }
8570                }
8571                Ok(None) => panic!("consumer channel closed unexpectedly"),
8572                Err(_) => {
8573                    // Timeout: the request was rejected before reaching the consumer.
8574                    // The HTTP response will carry the real status code (we check below).
8575                }
8576            }
8577        };
8578
8579        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
8580
8581        let resp = http_result.unwrap();
8582        // Audit 2026-08-31 (F2-1): the request is no longer rejected at the door
8583        // (no Content-Length to pre-check), but the byte cap now travels with the
8584        // stream: ANY materialization past maxRequestBody fails closed. This test
8585        // does not consume the body, so the request still completes with 200 —
8586        // enforcement happens at consumption time (see
8587        // test_http_consumer_chunked_body_is_capped).
8588        assert_ne!(
8589            resp.status().as_u16(),
8590            413,
8591            "chunked upload has no Content-Length to pre-check"
8592        );
8593        assert_eq!(resp.status().as_u16(), 200);
8594
8595        token.cancel();
8596    }
8597
8598    #[test]
8599    fn test_is_private_ip_ranges() {
8600        use camel_api::is_ssrf_blocked_ip;
8601        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
8602        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
8603        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
8604        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
8605        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
8606        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
8607
8608        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
8609        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
8610        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
8611        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
8612        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
8613        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
8614        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
8615        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
8616
8617        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
8618        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
8619        assert!(!is_ssrf_blocked_ip(
8620            &"2001:4860:4860::8888".parse().unwrap()
8621        )); // allow-unwrap
8622    }
8623
8624    #[test]
8625    fn test_title_case_header() {
8626        assert_eq!(title_case_header("content-type"), "Content-Type");
8627        assert_eq!(title_case_header("authorization"), "Authorization");
8628        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
8629        assert_eq!(title_case_header("host"), "Host");
8630        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
8631        assert_eq!(title_case_header("single"), "Single");
8632        assert_eq!(title_case_header(""), "");
8633    }
8634
8635    #[test]
8636    fn test_resolve_url_combines_path_and_query_sources() {
8637        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
8638        let mut exchange = Exchange::new(Message::default());
8639        exchange.input.set_header(
8640            "CamelHttpPath",
8641            serde_json::Value::String("next".to_string()),
8642        );
8643        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8644        assert!(url.starts_with("http://example.com/base/next?"));
8645        assert!(url.contains("foo=bar"));
8646
8647        exchange.input.set_header(
8648            "CamelHttpUri",
8649            serde_json::Value::String("http://other.test/root".to_string()),
8650        );
8651        exchange.input.set_header(
8652            "CamelHttpQuery",
8653            serde_json::Value::String("a=1&b=2".to_string()),
8654        );
8655
8656        let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8657        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
8658    }
8659
8660    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
8661        let mut exchange = Exchange::new(Message::default());
8662        exchange
8663            .input
8664            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
8665        exchange.input.set_header(
8666            "CamelHttpQuery",
8667            serde_json::Value::String(query.to_string()),
8668        );
8669        exchange
8670    }
8671
8672    #[test]
8673    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
8674        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8675        cfg.bridge_endpoint = true;
8676        cfg.query_params
8677            .push(("token".to_string(), "secret".to_string()));
8678        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
8679        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8680        // Verbatim assembly: the old round-trip normalized the empty base
8681        // path to `/` (`http://x/?token=secret`); authored bytes end-to-end
8682        // no longer insert it.
8683        assert_eq!(url, "http://x?token=secret");
8684        assert!(!url.contains("/foo"));
8685        assert!(!url.contains("dropme"));
8686    }
8687
8688    #[test]
8689    fn resolve_url_bridge_endpoint_false_merges_path() {
8690        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8691        cfg.bridge_endpoint = false;
8692        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
8693        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8694        assert!(url.contains("/foo"), "url should contain /foo: {url}");
8695        assert!(
8696            url.contains("dropme=1"),
8697            "url should contain dropme=1: {url}"
8698        );
8699    }
8700
8701    #[test]
8702    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
8703        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8704        cfg.bridge_endpoint = true;
8705        let mut exchange = Exchange::new(Message::default());
8706        exchange.input.set_header(
8707            "CamelHttpPath",
8708            serde_json::Value::String("/foo".to_string()),
8709        );
8710        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8711        assert_eq!(url, "http://x");
8712        assert!(!url.contains("/foo"));
8713    }
8714
8715    #[test]
8716    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
8717        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8718        cfg.bridge_endpoint = true;
8719        // query_params stays empty ([])
8720        let mut exchange = Exchange::new(Message::default());
8721        exchange.input.set_header(
8722            "CamelHttpUri",
8723            serde_json::Value::String("http://dest/explicit".to_string()),
8724        );
8725        exchange.input.set_header(
8726            "CamelHttpPath",
8727            serde_json::Value::String("/foo".to_string()),
8728        );
8729        exchange.input.set_header(
8730            "CamelHttpQuery",
8731            serde_json::Value::String("x=1".to_string()),
8732        );
8733        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8734        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
8735        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
8736        // wins verbatim.
8737        assert_eq!(url, "http://x");
8738    }
8739
8740    #[test]
8741    fn bridge_programmatic_params_use_percent20() {
8742        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8743        cfg.bridge_endpoint = true;
8744        cfg.query_params = vec![("b".to_string(), "x y".to_string())];
8745        let exchange = Exchange::new(Message::default());
8746
8747        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8748
8749        // `%20 never +` is global for programmatic values — the bridge arm
8750        // uses the same encoder as the non-bridge path. Bridging
8751        // semantics (what gets bridged, precedence) are unchanged.
8752        assert_eq!(url, "http://x?b=x%20y");
8753        assert!(!url.contains('+'));
8754    }
8755
8756    #[test]
8757    fn bridge_arm_carries_authored_raw_query() {
8758        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8759        // bridgeEndpoint is consumed as an endpoint option; a=1 is the
8760        // authored leftover riding raw_query.
8761        let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
8762
8763        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8764
8765        // Authored leftovers ride under bridging (Apache Camel semantics):
8766        // query is a=1 in authored bytes; exchange path/query stay ignored.
8767        assert_eq!(url, "http://h/p?a=1");
8768        assert!(!url.contains("dropme"), "exchange query leaked: {url}");
8769        assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
8770    }
8771
8772    // -----------------------------------------------------------------------
8773    // Bridge arm verbatim assembly (Papal Direction A): the bridged base is
8774    // never round-tripped through `url::Url` normalization — authored bytes
8775    // end-to-end, identical assembly to every other resolve_url arm.
8776    // -----------------------------------------------------------------------
8777
8778    #[test]
8779    fn resolve_url_bridge_preserves_dot_segments() {
8780        let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
8781        cfg.bridge_endpoint = true;
8782        cfg.query_params.push(("k".to_string(), "1".to_string()));
8783        let exchange = Exchange::new(Message::default());
8784
8785        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8786
8787        // Dot segments are authored bytes; the old round-trip collapsed
8788        // them (`/a/../b` → `/b`). Verbatim keeps them.
8789        assert_eq!(url, "http://h/a/../b?k=1");
8790    }
8791
8792    #[test]
8793    fn resolve_url_bridge_preserves_default_port() {
8794        let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
8795        cfg.bridge_endpoint = true;
8796        cfg.query_params.push(("k".to_string(), "1".to_string()));
8797        let exchange = Exchange::new(Message::default());
8798
8799        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8800
8801        // The old round-trip stripped the default port `:80`. Verbatim
8802        // keeps it.
8803        assert_eq!(url, "http://h:80/p?k=1");
8804    }
8805
8806    #[test]
8807    fn resolve_url_bridge_preserves_scheme_and_host_case() {
8808        let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
8809        cfg.bridge_endpoint = true;
8810        cfg.query_params.push(("k".to_string(), "1".to_string()));
8811        // `from_uri`'s scheme validation is case-sensitive, so the scheme
8812        // case is applied on the stored base directly — the resolve path
8813        // must carry whatever bytes the operator authored.
8814        cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
8815        let exchange = Exchange::new(Message::default());
8816
8817        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8818
8819        // The old round-trip lowercased scheme and host. Verbatim keeps
8820        // both authored.
8821        assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
8822    }
8823
8824    #[test]
8825    fn resolve_url_bridge_no_query_emits_base_verbatim() {
8826        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8827        cfg.bridge_endpoint = true;
8828        let exchange = Exchange::new(Message::default());
8829
8830        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8831
8832        // No resolved query: exactly the authored base — no synthetic `/`,
8833        // no dangling `?`.
8834        assert_eq!(url, "http://h/p");
8835    }
8836
8837    #[test]
8838    fn resolve_url_bridge_and_non_bridge_byte_identical() {
8839        // (a) Bridged arm: the effective query comes from programmatic
8840        // query_params.
8841        let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8842        bridged.bridge_endpoint = true;
8843        bridged
8844            .query_params
8845            .push(("k".to_string(), "1".to_string()));
8846        let bridge_url =
8847            HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
8848
8849        // (b) Non-bridge CamelHttpQuery composition path: same effective
8850        // query riding the exchange header.
8851        let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8852        let mut exchange = Exchange::new(Message::default());
8853        exchange.input.set_header(
8854            "CamelHttpQuery",
8855            serde_json::Value::String("k=1".to_string()),
8856        );
8857        let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
8858
8859        assert_eq!(bridge_url, plain_url);
8860        assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
8861    }
8862
8863    #[test]
8864    fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
8865        let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
8866        cfg.bridge_endpoint = true;
8867        cfg.query_params.push(("k".to_string(), "1".to_string()));
8868        let exchange = Exchange::new(Message::default());
8869
8870        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8871
8872        assert_eq!(url, "http://[::1]:8080/p?k=1");
8873    }
8874
8875    #[test]
8876    fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
8877        let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
8878        let exchange = Exchange::new(Message::default());
8879
8880        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8881
8882        // Authored query on an empty base path: the old round-trip
8883        // inserted a synthetic `/` (`http://h/?x=1`); verbatim does not.
8884        assert_eq!(url, "http://h?x=1");
8885    }
8886
8887    // -----------------------------------------------------------------------
8888    // Raw-preserving outbound query serialization (http-query-wire-fidelity)
8889    // -----------------------------------------------------------------------
8890
8891    #[test]
8892    fn resolve_url_preserves_authored_query_order_and_bytes() {
8893        let config =
8894            HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
8895        let exchange = Exchange::new(Message::default());
8896
8897        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8898
8899        // Authored order, authored separators, no %2C/%3A re-encoding,
8900        // consumed option (connectTimeout) removed.
8901        assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
8902    }
8903
8904    #[test]
8905    fn resolve_url_consumes_encoded_option_key() {
8906        let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
8907        let exchange = Exchange::new(Message::default());
8908
8909        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8910
8911        // The raw filter matches the decoded key, not the encoded bytes.
8912        assert_eq!(url, "http://h/p?a=1");
8913    }
8914
8915    #[test]
8916    fn resolve_url_all_options_consumed_drops_query() {
8917        let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
8918        let exchange = Exchange::new(Message::default());
8919
8920        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8921
8922        // A non-empty query whose every pair was consumed drops the query
8923        // component entirely — no dangling `?`.
8924        assert_eq!(url, "http://h/p");
8925        assert!(!url.contains('?'));
8926    }
8927
8928    #[test]
8929    fn resolve_url_preserves_empty_query_marker() {
8930        let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
8931        let exchange = Exchange::new(Message::default());
8932
8933        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8934
8935        // A bare `?` marker is preserved distinctly, never conflated with
8936        // an all-consumed query.
8937        assert_eq!(url, "http://h/p?");
8938    }
8939
8940    #[test]
8941    fn resolve_url_raw_wrapper_not_re_encoded() {
8942        let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
8943        let exchange = Exchange::new(Message::default());
8944
8945        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8946
8947        // RAW(...) wrapper bytes survive exactly as authored (rc-g4isv).
8948        assert_eq!(url, "http://h/p?token=RAW(abc)");
8949        assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
8950    }
8951
8952    #[test]
8953    fn resolve_url_camel_http_query_composes_verbatim_span() {
8954        let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
8955        let mut exchange = Exchange::new(Message::default());
8956        exchange.input.set_header(
8957            "CamelHttpQuery",
8958            serde_json::Value::String("userFilter=a%2Cb".to_string()),
8959        );
8960
8961        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8962
8963        // Policy change (ADR-0071): the header no longer replaces the
8964        // endpoint query — it composes, the endpoint winning collisions.
8965        // The header span bytes still ride verbatim: `a%2Cb` is carried
8966        // as-authored, never re-encoded (no %252C).
8967        assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
8968        assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
8969    }
8970
8971    // -----------------------------------------------------------------------
8972    // Outbound query composition (http-contract-surface, ADR-0071)
8973    // -----------------------------------------------------------------------
8974
8975    #[test]
8976    fn header_composes_with_endpoint_query() {
8977        let config =
8978            HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
8979        let mut exchange = Exchange::new(Message::default());
8980        exchange.input.set_header(
8981            "CamelHttpQuery",
8982            serde_json::Value::String("lang=es&page=2".to_string()),
8983        );
8984
8985        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8986
8987        // Higher precedence (endpoint) wins collisions: `lang` stays `en`;
8988        // the header appends only its absent keys.
8989        assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
8990    }
8991
8992    #[test]
8993    fn header_alone_still_rides() {
8994        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8995        let mut exchange = Exchange::new(Message::default());
8996        exchange.input.set_header(
8997            "CamelHttpQuery",
8998            serde_json::Value::String("page=2".to_string()),
8999        );
9000
9001        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9002
9003        // No endpoint query: the header pairs are the whole query.
9004        assert_eq!(url, "http://upstream/api?page=2");
9005    }
9006
9007    #[test]
9008    fn empty_reflected_query_leaves_endpoint_query_intact() {
9009        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9010        let mut exchange = Exchange::new(Message::default());
9011        // The consumer installs an empty CamelHttpQuery on requests that
9012        // arrived without a query string.
9013        exchange
9014            .input
9015            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9016
9017        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9018
9019        // No second `?` marker, no dropped endpoint pair.
9020        assert_eq!(url, "http://upstream/api?apiKey=secret");
9021        assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
9022    }
9023
9024    #[test]
9025    fn forbidden_byte_in_header_query_errors() {
9026        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9027        let mut exchange = Exchange::new(Message::default());
9028        exchange.input.set_header(
9029            "CamelHttpQuery",
9030            serde_json::Value::String("q=ab<cd".to_string()),
9031        );
9032
9033        let err = HttpProducer::resolve_url(&exchange, &config)
9034            .unwrap_err()
9035            .to_string();
9036
9037        // Fail loud naming the forbidden byte (`<` = 0x3C); a resolve
9038        // error means no URL is emitted, never a re-encoded one.
9039        assert!(err.contains("0x3C"), "error must name the byte: {err}");
9040    }
9041
9042    #[test]
9043    fn override_uri_with_query_plus_header_query() {
9044        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9045        let mut exchange = Exchange::new(Message::default());
9046        exchange.input.set_header(
9047            "CamelHttpUri",
9048            serde_json::Value::String("http://host/api?a=1".to_string()),
9049        );
9050        exchange.input.set_header(
9051            "CamelHttpQuery",
9052            serde_json::Value::String("a=2&b=3".to_string()),
9053        );
9054
9055        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9056
9057        // Pair-level merge with a single `?`: the override's `a=1` wins
9058        // the collision, the header appends `b=3` — no `?a=1?a=2` concat.
9059        assert_eq!(url, "http://host/api?a=1&b=3");
9060    }
9061
9062    #[test]
9063    fn path_applies_before_query_composition() {
9064        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9065        let mut exchange = Exchange::new(Message::default());
9066        exchange.input.set_header(
9067            "CamelHttpUri",
9068            serde_json::Value::String("http://host/api?a=1".to_string()),
9069        );
9070        exchange.input.set_header(
9071            "CamelHttpPath",
9072            serde_json::Value::String("/extra".to_string()),
9073        );
9074        exchange.input.set_header(
9075            "CamelHttpQuery",
9076            serde_json::Value::String("b=2".to_string()),
9077        );
9078
9079        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9080
9081        // CamelHttpPath applies to the override base without its query,
9082        // then the query composes.
9083        assert_eq!(url, "http://host/api/extra?a=1&b=2");
9084    }
9085
9086    #[test]
9087    fn plain_proxy_reflection_composes() {
9088        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9089        // Headers as the consumer installs them from the wire.
9090        let exchange = exchange_with_path_and_query("/in/extra", "page=2");
9091
9092        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9093
9094        // Reflection rides by default and composes: the operator pair is
9095        // not replaced (rc-k3pir parity).
9096        assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
9097    }
9098
9099    #[test]
9100    fn bridge_endpoint_ignores_url_headers() {
9101        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9102        let mut exchange = Exchange::new(Message::default());
9103        exchange.input.set_header(
9104            "CamelHttpUri",
9105            serde_json::Value::String("http://evil.test/x".to_string()),
9106        );
9107        exchange.input.set_header(
9108            "CamelHttpPath",
9109            serde_json::Value::String("/foo".to_string()),
9110        );
9111        exchange.input.set_header(
9112            "CamelHttpQuery",
9113            serde_json::Value::String("z=9".to_string()),
9114        );
9115
9116        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9117
9118        // All three URL headers ignored; the endpoint base plus its own
9119        // (consumed-option-filtered) query is sent, exactly as before.
9120        assert_eq!(url, "http://h/p?a=1");
9121        assert!(!url.contains("evil"), "override leaked: {url}");
9122        assert!(!url.contains("z=9"), "header query leaked: {url}");
9123        assert!(!url.contains("/foo"), "header path leaked: {url}");
9124    }
9125
9126    #[test]
9127    fn resolve_url_programmatic_params_use_percent20_deterministic() {
9128        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9129        config.query_params = vec![
9130            ("b".to_string(), "x y".to_string()),
9131            ("a".to_string(), "1".to_string()),
9132        ];
9133        let exchange = Exchange::new(Message::default());
9134
9135        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9136
9137        // Declaration order (not lexical), minimal RFC-3986 encoding,
9138        // `%20` — never `+` — for spaces.
9139        assert_eq!(url, "http://h/p?b=x%20y&a=1");
9140        assert!(!url.contains('+'));
9141    }
9142
9143    #[test]
9144    fn resolve_url_authored_and_programmatic_merge() {
9145        let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
9146        config.query_params = vec![
9147            ("b".to_string(), "2".to_string()),
9148            ("a".to_string(), "9".to_string()),
9149        ];
9150        let exchange = Exchange::new(Message::default());
9151
9152        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9153
9154        // Programmatic `b` appended (absent from raw); programmatic `a=9`
9155        // ignored (authored key wins); no duplication.
9156        assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
9157    }
9158
9159    #[test]
9160    fn from_uri_no_longer_fills_query_params_from_uri() {
9161        let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
9162
9163        // Authored pairs live in raw_query ONLY (provenance pin).
9164        assert!(
9165            config.query_params.is_empty(),
9166            "query_params is programmatic-only: {:?}",
9167            config.query_params
9168        );
9169        assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
9170    }
9171
9172    #[test]
9173    fn resolve_url_forbidden_raw_byte_errors() {
9174        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9175        config.raw_query = Some("a=x y".to_string());
9176        let exchange = Exchange::new(Message::default());
9177
9178        let err = HttpProducer::resolve_url(&exchange, &config)
9179            .expect_err("literal space in raw query must error");
9180
9181        // The error names the forbidden byte; no output string is produced.
9182        assert!(
9183            err.to_string().contains("0x20"),
9184            "error must name the forbidden byte: {err}"
9185        );
9186    }
9187
9188    /// rc-m4xk1: the override URI's own query is span-validated at resolve
9189    /// time — a forbidden byte in the override arm errors naming the byte,
9190    /// instead of riding verbatim to a reqwest send error.
9191    #[test]
9192    fn resolve_url_override_query_forbidden_byte_errors() {
9193        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9194        let mut exchange = Exchange::new(Message::default());
9195        exchange.input.set_header(
9196            "CamelHttpUri",
9197            serde_json::Value::String("http://h2/p?a=x y".to_string()),
9198        );
9199
9200        let err = HttpProducer::resolve_url(&exchange, &config)
9201            .expect_err("literal space in the override URI's query must error");
9202
9203        assert!(
9204            err.to_string().contains("0x20"),
9205            "error must name the forbidden byte from the override query: {err}"
9206        );
9207    }
9208
9209    /// rc-m4xk1 pin: decoded-key collision — a header pair whose key decodes
9210    /// to a key already present in the higher-precedence query (here
9211    /// `%61=2`, decoding to `a`) is dropped by the shared decoded-key
9212    /// matching; the higher-precedence authored span rides verbatim.
9213    #[test]
9214    fn merge_header_query_decoded_key_collision_drops_header_pair() {
9215        let merged = merge_header_query(Some("a=1"), "%61=2")
9216            .expect("decoded-key collision must not be a parse error");
9217        assert_eq!(
9218            merged.as_deref(),
9219            Some("a=1"),
9220            "the higher-precedence span wins and the colliding header pair is dropped"
9221        );
9222    }
9223
9224    /// rc-m4xk1 pin: duplicate keys within ONE header query are not
9225    /// deduplicated — both spans ride verbatim in authored order.
9226    #[test]
9227    fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
9228        let merged = merge_header_query(None, "k=1&k=2")
9229            .expect("duplicate header keys must not be a parse error");
9230        assert_eq!(
9231            merged.as_deref(),
9232            Some("k=1&k=2"),
9233            "intra-header duplicate keys ride verbatim"
9234        );
9235    }
9236
9237    /// rc-dhkeo: the Debug surface masks userinfo-style bytes in
9238    /// `base_url` and leaves a userinfo-free base untouched, byte-for-byte.
9239    #[test]
9240    fn endpoint_config_debug_masks_base_url_userinfo() {
9241        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9242        config.base_url = "http://user:pass@h.example/p".to_string();
9243        let rendered = format!("{config:?}");
9244        assert!(
9245            rendered.contains("***@h.example"),
9246            "userinfo must render masked: {rendered}"
9247        );
9248        assert!(
9249            !rendered.contains("user:pass"),
9250            "no credentials in Debug output: {rendered}"
9251        );
9252
9253        let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9254        let rendered_plain = format!("{plain:?}");
9255        assert!(
9256            rendered_plain.contains("http://h.example/p"),
9257            "a base without userinfo renders unchanged: {rendered_plain}"
9258        );
9259    }
9260
9261    /// rc-nmupb: authored apostrophe (0x27) is RFC 3986 pchar-legal, but
9262    /// reqwest's WHATWG parser re-encodes it as `%27` in every http/https
9263    /// query — the raw byte can never ride the wire verbatim. Resolve
9264    /// rejects it naming the byte; the authored `%27` escape is the
9265    /// wire-faithful form and rides verbatim.
9266    #[test]
9267    fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
9268        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9269
9270        config.raw_query = Some("q=it's".to_string());
9271        let exchange = Exchange::new(Message::default());
9272        let err = HttpProducer::resolve_url(&exchange, &config)
9273            .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
9274        assert!(
9275            err.to_string().contains("0x27"),
9276            "error must name the apostrophe byte: {err}"
9277        );
9278
9279        config.raw_query = Some("q=it%27s".to_string());
9280        let url = HttpProducer::resolve_url(&exchange, &config)
9281            .expect("authored %27 escape is wire-legal");
9282        assert!(
9283            url.contains("q=it%27s"),
9284            "the authored escape must ride byte-for-byte: {url}"
9285        );
9286
9287        // The rest of reqwest's WHATWG special-query set shares the same
9288        // rationale and is rejected alongside (`"` and backtick are not
9289        // RFC 3986 query-legal bytes; `<`/`>` likewise).
9290        for &byte in b"\"`<>" {
9291            config.raw_query = Some(format!("k={}x", byte as char));
9292            let err = HttpProducer::resolve_url(&exchange, &config)
9293                .expect_err("WHATWG special-query byte must be rejected");
9294            assert!(
9295                err.to_string().contains(&format!("0x{byte:02X}")),
9296                "error must name byte 0x{byte:02X}: {err}"
9297            );
9298        }
9299    }
9300
9301    #[test]
9302    fn armed_fence_rejects_unknown_host_redacted() {
9303        let cfg = HttpEndpointConfig::from_uri(
9304            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9305        )
9306        .unwrap();
9307        let mut exchange = Exchange::new(Message::default());
9308        exchange.input.set_header(
9309            "CamelHttpUri",
9310            serde_json::Value::String(
9311                "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
9312            ),
9313        );
9314
9315        let err = HttpProducer::resolve_url(&exchange, &cfg)
9316            .expect_err("override host outside the fence must fail resolution");
9317
9318        let message = err.to_string();
9319        assert!(!message.contains("pass"), "userinfo leaked: {message}");
9320        assert!(!message.contains("s3cret"), "query leaked: {message}");
9321    }
9322
9323    #[test]
9324    fn armed_fence_rejects_unparseable_override_redacted() {
9325        let cfg = HttpEndpointConfig::from_uri(
9326            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9327        )
9328        .unwrap();
9329        let mut exchange = Exchange::new(Message::default());
9330        exchange.input.set_header(
9331            "CamelHttpUri",
9332            serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
9333        );
9334
9335        let err = HttpProducer::resolve_url(&exchange, &cfg)
9336            .expect_err("unparseable override outside the fence must fail resolution");
9337
9338        let message = err.to_string();
9339        assert!(
9340            message.contains("allowedUriHosts fence"),
9341            "fence must be named: {message}"
9342        );
9343        assert!(
9344            message.contains("[redacted]"),
9345            "suppression sentinel missing: {message}"
9346        );
9347        assert!(
9348            !message.contains("evil.example.com"),
9349            "host leaked: fail-closed arm must render only the sentinel: {message}"
9350        );
9351        assert!(
9352            !message.contains("fencesecret"),
9353            "password leaked: {message}"
9354        );
9355        assert!(!message.contains("u:"), "userinfo leaked: {message}");
9356    }
9357
9358    #[test]
9359    fn armed_fence_rejects_password_only_userinfo_redacted() {
9360        let cfg = HttpEndpointConfig::from_uri(
9361            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9362        )
9363        .unwrap();
9364        let mut exchange = Exchange::new(Message::default());
9365        exchange.input.set_header(
9366            "CamelHttpUri",
9367            serde_json::Value::String(
9368                "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
9369            ),
9370        );
9371
9372        let err = HttpProducer::resolve_url(&exchange, &cfg)
9373            .expect_err("password-only override outside the fence must fail resolution");
9374
9375        let message = err.to_string();
9376        assert!(
9377            !message.contains("passwordonly"),
9378            "password-only userinfo leaked: {message}"
9379        );
9380        assert!(!message.contains("querysecret"), "query leaked: {message}");
9381        assert!(
9382            message.contains("http://***@evil.example.com/x?[redacted]"),
9383            "masked shape missing: {message}"
9384        );
9385    }
9386
9387    #[test]
9388    fn armed_fence_allows_listed_host() {
9389        let cfg = HttpEndpointConfig::from_uri(
9390            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9391        )
9392        .unwrap();
9393        let mut exchange = Exchange::new(Message::default());
9394        exchange.input.set_header(
9395            "CamelHttpUri",
9396            serde_json::Value::String("http://cdn.example.com/x".to_string()),
9397        );
9398
9399        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9400        assert_eq!(url, "http://cdn.example.com/x");
9401    }
9402
9403    #[test]
9404    fn host_only_entry_permits_any_port() {
9405        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
9406        let mut exchange = Exchange::new(Message::default());
9407        exchange.input.set_header(
9408            "CamelHttpUri",
9409            serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
9410        );
9411
9412        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9413        assert_eq!(url, "http://cdn.example.com:9443/x");
9414    }
9415
9416    #[test]
9417    fn unarmed_endpoint_unchanged() {
9418        let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9419        let mut exchange = Exchange::new(Message::default());
9420        exchange.input.set_header(
9421            "CamelHttpUri",
9422            serde_json::Value::String("http://any.example.com/path".to_string()),
9423        );
9424
9425        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9426        assert_eq!(url, "http://any.example.com/path");
9427    }
9428
9429    #[test]
9430    fn empty_allowlist_fails_endpoint_creation() {
9431        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
9432    }
9433
9434    #[test]
9435    fn malformed_entry_fails_endpoint_creation() {
9436        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
9437    }
9438
9439    #[test]
9440    fn fence_entry_with_path_fails_creation() {
9441        // A trailing path is a typo'd entry: silently narrowing it to the
9442        // hostname would widen or skew the fence. Reject loudly.
9443        assert!(
9444            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
9445        );
9446    }
9447
9448    #[test]
9449    fn fence_entry_with_userinfo_fails_creation() {
9450        assert!(
9451            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
9452        );
9453    }
9454
9455    #[test]
9456    fn ipv6_fence_entry_allows_bracketed_host() {
9457        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
9458        // The textual host forms differ; both parse to the same bracketed
9459        // canonical host (`[::1]`) that the entry stores, so both ride.
9460        for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
9461            let mut exchange = Exchange::new(Message::default());
9462            exchange
9463                .input
9464                .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
9465            let url = HttpProducer::resolve_url(&exchange, &cfg)
9466                .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
9467            assert_eq!(url, uri, "bracketed IPv6 override not honored");
9468        }
9469    }
9470
9471    #[test]
9472    fn dns_case_insensitive_fence_match() {
9473        // The entry is stored ASCII-lowercased, so the mixed-case option
9474        // matches the lowercase override host.
9475        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
9476        let mut exchange = Exchange::new(Message::default());
9477        exchange.input.set_header(
9478            "CamelHttpUri",
9479            serde_json::Value::String("http://cdn.example.com/x".to_string()),
9480        );
9481        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9482        assert_eq!(url, "http://cdn.example.com/x");
9483    }
9484
9485    #[test]
9486    fn fence_allowed_override_query_merges_with_header() {
9487        // Fence pass plus full composition: the override URI query is the
9488        // higher-precedence source, the header pair appends.
9489        let cfg =
9490            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
9491        let mut exchange = Exchange::new(Message::default());
9492        exchange.input.set_header(
9493            "CamelHttpUri",
9494            serde_json::Value::String("http://host.example/api?a=1".to_string()),
9495        );
9496        exchange.input.set_header(
9497            "CamelHttpQuery",
9498            serde_json::Value::String("b=2".to_string()),
9499        );
9500
9501        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9502        assert_eq!(url, "http://host.example/api?a=1&b=2");
9503    }
9504
9505    #[test]
9506    fn empty_header_with_armed_fence_leaves_no_query() {
9507        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
9508        let mut exchange = Exchange::new(Message::default());
9509        exchange.input.set_header(
9510            "CamelHttpUri",
9511            serde_json::Value::String("http://host.example/api".to_string()),
9512        );
9513        exchange
9514            .input
9515            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9516
9517        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9518        assert_eq!(url, "http://host.example/api");
9519        assert!(!url.contains('?'), "query marker leaked: {url}");
9520    }
9521
9522    #[test]
9523    fn fence_option_is_consumed() {
9524        // A raw query on the base URI plus the fence option; no override
9525        // header. The option is consumed at parse time and must never
9526        // appear in the outbound query.
9527        let cfg =
9528            HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
9529        let exchange = Exchange::new(Message::default());
9530
9531        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9532        assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
9533        assert!(url.contains("x=1"), "authored query lost: {url}");
9534    }
9535
9536    #[tokio::test]
9537    async fn resolve_url_malformed_base_url_errors_no_panic() {
9538        use tower::ServiceExt;
9539
9540        let (url, _handle) = start_test_server().await;
9541        let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
9542        config.allow_internal = true; // test server binds 127.0.0.1
9543        let producer = HttpProducer {
9544            config: Arc::new(config),
9545            client: build_client(&HttpConfig::default(), None),
9546            pinned_cache: Arc::new(PinnedClientCache::new(
9547                PINNED_CLIENT_TTL,
9548                PINNED_CLIENT_MAX_ENTRIES,
9549            )),
9550            http_config: Arc::new(HttpConfig::default()),
9551            runtime: rt(),
9552        };
9553
9554        // First call: malformed base URL propagates as an error through the
9555        // real producer path — no panic, no poisoned state (rc-ph7z2).
9556        let first = producer
9557            .clone()
9558            .oneshot(Exchange::new(Message::default()))
9559            .await;
9560        let err = first.expect_err("malformed base URL must error, not panic");
9561        assert!(
9562            err.to_string().to_lowercase().contains("url"),
9563            "error must name the malformed URL: {err}"
9564        );
9565
9566        // Second call through the SAME producer succeeds — the failure
9567        // left no poisoned state.
9568        let mut exchange = Exchange::new(Message::default());
9569        exchange.input.set_header(
9570            "CamelHttpUri",
9571            serde_json::Value::String(format!("{url}/api")),
9572        );
9573        let response = producer
9574            .oneshot(exchange)
9575            .await
9576            .expect("valid request through same producer must succeed");
9577        let status = response
9578            .input
9579            .header("CamelHttpResponseCode")
9580            .and_then(|v| v.as_u64())
9581            .unwrap();
9582        assert_eq!(status, 200);
9583    }
9584
9585    #[test]
9586    fn resolve_url_bridge_malformed_base_errors_no_panic() {
9587        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9588        cfg.bridge_endpoint = true;
9589        cfg.query_params.push(("k".to_string(), "1".to_string()));
9590        // `from_uri` rejects the malformed authority, so the base is set on
9591        // the stored config directly (same build shape as the scheme-case
9592        // test). The bridge arm's validation-only parse (rc-ph7z2) must
9593        // surface it as an error — no panic.
9594        cfg.base_url = "http://[::1:bad".to_string();
9595        let exchange = Exchange::new(Message::default());
9596
9597        let err = HttpProducer::resolve_url(&exchange, &cfg)
9598            .expect_err("malformed bridge base URL must error");
9599        assert!(
9600            err.to_string().contains("invalid base URL"),
9601            "error must name the invalid base URL: {err}"
9602        );
9603    }
9604
9605    #[test]
9606    fn test_http_producer_helpers_status_and_size_boundaries() {
9607        assert!(HttpProducer::is_ok_status(200, (200, 299)));
9608        assert!(HttpProducer::is_ok_status(299, (200, 299)));
9609        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
9610        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
9611
9612        assert!(!exceeds_max_response_body(10, 10));
9613        assert!(exceeds_max_response_body(11, 10));
9614    }
9615
9616    // -----------------------------------------------------------------------
9617    // Content-Type inference tests
9618    // -----------------------------------------------------------------------
9619
9620    #[allow(clippy::await_holding_lock)]
9621    async fn setup_consumer_on_free_port(
9622        path: &str,
9623    ) -> (
9624        u16,
9625        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
9626        tokio_util::sync::CancellationToken,
9627    ) {
9628        use camel_component_api::ConsumerContext;
9629
9630        // ADR-0070 staged-listener law: bind, KEEP the socket, and stage it
9631        // in the ServerRegistry; the consumer's `get_or_spawn` consumes the
9632        // staged listener, so the port never returns to the ephemeral pool
9633        // between probe and serve (no bind-read-drop race).
9634        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9635        let port = listener.local_addr().unwrap().port();
9636
9637        // Hold the registry test mutex across the whole stage→spawn→ready
9638        // window so a concurrent `ServerRegistry::reset()` cannot evict the
9639        // staged listener between staging and readiness. The guard covers
9640        // stage_listener, the consumer spawn, the readiness poll and the
9641        // tail-yield loop; it releases when this helper returns.
9642        // Poison-recovering acquire: a failed sibling test must not
9643        // cascade — the mutex guards test serialization only, no
9644        // structural invariant, so recovery via into_inner is safe.
9645        let _registry_guard = lock_registry_test_mutex();
9646
9647        ServerRegistry::global()
9648            .stage_listener(listener)
9649            .await
9650            .expect("stage consumer test listener");
9651
9652        let consumer_cfg = HttpServerConfig {
9653            scheme: "http".to_string(),
9654            host: "127.0.0.1".to_string(),
9655            port,
9656            path: path.to_string(),
9657            max_request_body: 2 * 1024 * 1024,
9658            max_response_body: 10 * 1024 * 1024,
9659            max_inflight_requests: 1024,
9660            method: None,
9661            tls_config: None,
9662        };
9663        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9664
9665        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9666        let token = tokio_util::sync::CancellationToken::new();
9667        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9668
9669        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9670
9671        // Readiness without a fixed wall-clock sleep: poll the registry
9672        // entry live (1ms doubling backoff, 10s deadline), then yield so
9673        // the spawned `start()` completes route registration (that tail
9674        // path has no pending timers — only the registry lock — so
9675        // scheduler yields order it deterministically behind this loop).
9676        wait_for_registry_ready("127.0.0.1", port).await;
9677        for _ in 0..8 {
9678            tokio::task::yield_now().await;
9679        }
9680
9681        (port, rx, token)
9682    }
9683
9684    /// Poll `ServerRegistry::bound_addr(host, port)` until the entry
9685    /// appears: 1ms backoff doubling per iteration, capped at 64ms, with
9686    /// a 10s deadline. Panics with a hint naming the likely causes when
9687    /// the deadline fires.
9688    async fn wait_for_registry_ready(host: &str, port: u16) {
9689        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
9690        let mut backoff = std::time::Duration::from_millis(1);
9691        while ServerRegistry::global().bound_addr(host, port).is_none() {
9692            assert!(
9693                tokio::time::Instant::now() < deadline,
9694                "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
9695            );
9696            tokio::time::sleep(backoff).await;
9697            backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
9698        }
9699    }
9700
9701    #[tokio::test]
9702    #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
9703    async fn readiness_deadline_fires_loud_with_hint() {
9704        // Poll a key no writer can produce. Registry keys come from
9705        // either the listener's resolved IP string (staged path) or the
9706        // caller-provided host verbatim (legacy get_or_spawn path), so a
9707        // synthetic host literal that no test passes is unreachable on
9708        // BOTH paths. Binding and HOLDING the listener (never dropped,
9709        // never staged) additionally keeps its port out of the ephemeral
9710        // pool, so no concurrent test can register that port either.
9711        // (Earlier drafts polled 127.0.0.2 — rejected: macOS exposes only
9712        // 127.0.0.1 and the bind fails there, rc-dwmd; and "localhost" —
9713        // rejected: the legacy host-verbatim path could produce it.)
9714        let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9715        let port = held.local_addr().unwrap().port();
9716        wait_for_registry_ready("httpflake-unreachable-host", port).await;
9717    }
9718
9719    // -----------------------------------------------------------------------
9720    // Readiness vs concurrent registry reset (httpflake, regression RED)
9721    // -----------------------------------------------------------------------
9722
9723    #[tokio::test]
9724    async fn readiness_survives_concurrent_registry_reset() {
9725        let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
9726        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
9727
9728        // Hammer thread: loop legal resets, counting a contention whenever
9729        // its try-lock on the registry test mutex blocks (someone else held
9730        // it). The guard is dropped at each iteration end.
9731        let contended_hammer = std::sync::Arc::clone(&contended);
9732        let stop_hammer = std::sync::Arc::clone(&stop);
9733        let handle = std::thread::spawn(move || {
9734            while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
9735                let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
9736                    Err(_) => {
9737                        contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
9738                        lock_registry_test_mutex()
9739                    }
9740                    Ok(guard) => guard,
9741                };
9742                ServerRegistry::reset();
9743            }
9744        });
9745
9746        // Drop guard: even if a setup panics, stop the hammer and join it so
9747        // the thread never outlives the test.
9748        struct StopHammerOnDrop {
9749            handle: Option<std::thread::JoinHandle<()>>,
9750            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
9751        }
9752        impl Drop for StopHammerOnDrop {
9753            fn drop(&mut self) {
9754                self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
9755                if let Some(handle) = self.handle.take() {
9756                    let _ = handle.join();
9757                }
9758            }
9759        }
9760        let _hammer_guard = StopHammerOnDrop {
9761            handle: Some(handle),
9762            stop,
9763        };
9764
9765        // Always at least 25 setups on fresh ephemeral ports; continue past
9766        // 25 only until one contended reset is observed; hard cap 50.
9767        let mut setups = 0;
9768        loop {
9769            setups += 1;
9770            let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
9771            drop(rx);
9772            token.cancel();
9773            if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
9774                || setups >= 50
9775            {
9776                break;
9777            }
9778        }
9779
9780        let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
9781        assert!(
9782            contended_hits >= 1,
9783            "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
9784        );
9785    }
9786
9787    #[tokio::test]
9788    async fn test_content_type_inferred_for_json_body() {
9789        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
9790
9791        let client = reqwest::Client::new();
9792        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
9793
9794        let (http_result, _) = tokio::join!(send_fut, async {
9795            if let Some(mut envelope) = rx.recv().await {
9796                envelope.exchange.input.body =
9797                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
9798                if let Some(reply_tx) = envelope.reply_tx {
9799                    let _ = reply_tx.send(Ok(envelope.exchange));
9800                }
9801            }
9802        });
9803
9804        let resp = http_result.unwrap();
9805        assert_eq!(resp.status().as_u16(), 200);
9806        let ct = resp
9807            .headers()
9808            .get("content-type")
9809            .expect("Content-Type header should be present");
9810        assert_eq!(ct, "application/json");
9811        let body = resp.text().await.unwrap();
9812        assert_eq!(body, r#"{"message":"hello"}"#);
9813
9814        token.cancel();
9815    }
9816
9817    #[tokio::test]
9818    async fn test_content_type_inferred_for_text_body() {
9819        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
9820
9821        let client = reqwest::Client::new();
9822        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
9823
9824        let (http_result, _) = tokio::join!(send_fut, async {
9825            if let Some(mut envelope) = rx.recv().await {
9826                envelope.exchange.input.body =
9827                    camel_component_api::Body::Text("plain text response".to_string());
9828                if let Some(reply_tx) = envelope.reply_tx {
9829                    let _ = reply_tx.send(Ok(envelope.exchange));
9830                }
9831            }
9832        });
9833
9834        let resp = http_result.unwrap();
9835        assert_eq!(resp.status().as_u16(), 200);
9836        let ct = resp
9837            .headers()
9838            .get("content-type")
9839            .expect("Content-Type header should be present");
9840        assert_eq!(ct, "text/plain; charset=utf-8");
9841        let body = resp.text().await.unwrap();
9842        assert_eq!(body, "plain text response");
9843
9844        token.cancel();
9845    }
9846
9847    #[tokio::test]
9848    async fn test_content_type_inferred_for_xml_body() {
9849        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
9850
9851        let client = reqwest::Client::new();
9852        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
9853
9854        let (http_result, _) = tokio::join!(send_fut, async {
9855            if let Some(mut envelope) = rx.recv().await {
9856                envelope.exchange.input.body =
9857                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
9858                if let Some(reply_tx) = envelope.reply_tx {
9859                    let _ = reply_tx.send(Ok(envelope.exchange));
9860                }
9861            }
9862        });
9863
9864        let resp = http_result.unwrap();
9865        assert_eq!(resp.status().as_u16(), 200);
9866        let ct = resp
9867            .headers()
9868            .get("content-type")
9869            .expect("Content-Type header should be present");
9870        assert_eq!(ct, "application/xml");
9871        let body = resp.text().await.unwrap();
9872        assert_eq!(body, "<root><item>value</item></root>");
9873
9874        token.cancel();
9875    }
9876
9877    #[tokio::test]
9878    async fn test_no_content_type_for_empty_body() {
9879        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
9880
9881        let client = reqwest::Client::new();
9882        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
9883
9884        let (http_result, _) = tokio::join!(send_fut, async {
9885            if let Some(mut envelope) = rx.recv().await {
9886                envelope.exchange.input.body = camel_component_api::Body::Empty;
9887                if let Some(reply_tx) = envelope.reply_tx {
9888                    let _ = reply_tx.send(Ok(envelope.exchange));
9889                }
9890            }
9891        });
9892
9893        let resp = http_result.unwrap();
9894        assert_eq!(resp.status().as_u16(), 200);
9895        assert!(
9896            resp.headers().get("content-type").is_none(),
9897            "Empty body should not set Content-Type"
9898        );
9899
9900        token.cancel();
9901    }
9902
9903    #[tokio::test]
9904    async fn test_no_content_type_for_raw_bytes_body() {
9905        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
9906
9907        let client = reqwest::Client::new();
9908        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
9909
9910        let (http_result, _) = tokio::join!(send_fut, async {
9911            if let Some(mut envelope) = rx.recv().await {
9912                envelope.exchange.input.body =
9913                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
9914                if let Some(reply_tx) = envelope.reply_tx {
9915                    let _ = reply_tx.send(Ok(envelope.exchange));
9916                }
9917            }
9918        });
9919
9920        let resp = http_result.unwrap();
9921        assert_eq!(resp.status().as_u16(), 200);
9922        assert!(
9923            resp.headers().get("content-type").is_none(),
9924            "Raw Bytes body should not set Content-Type"
9925        );
9926
9927        token.cancel();
9928    }
9929
9930    #[tokio::test]
9931    async fn test_content_type_from_stream_metadata() {
9932        use camel_component_api::{StreamBody, StreamMetadata};
9933        use futures::stream;
9934
9935        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
9936
9937        let client = reqwest::Client::new();
9938        let send_fut = client
9939            .get(format!("http://127.0.0.1:{port}/stream-ct"))
9940            .send();
9941
9942        let (http_result, _) = tokio::join!(send_fut, async {
9943            if let Some(mut envelope) = rx.recv().await {
9944                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
9945                    vec![Ok(bytes::Bytes::from("audio data"))];
9946                let stream = Box::pin(stream::iter(chunks));
9947                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
9948                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
9949                    metadata: StreamMetadata {
9950                        size_hint: None,
9951                        content_type: Some("audio/mpeg".to_string()),
9952                        origin: None,
9953                    },
9954                });
9955                if let Some(reply_tx) = envelope.reply_tx {
9956                    let _ = reply_tx.send(Ok(envelope.exchange));
9957                }
9958            }
9959        });
9960
9961        let resp = http_result.unwrap();
9962        assert_eq!(resp.status().as_u16(), 200);
9963        let ct = resp
9964            .headers()
9965            .get("content-type")
9966            .expect("Content-Type header should be present");
9967        assert_eq!(ct, "audio/mpeg");
9968        let body = resp.text().await.unwrap();
9969        assert_eq!(body, "audio data");
9970
9971        token.cancel();
9972    }
9973
9974    #[tokio::test]
9975    async fn test_user_content_type_overrides_inferred() {
9976        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
9977
9978        let client = reqwest::Client::new();
9979        let send_fut = client
9980            .get(format!("http://127.0.0.1:{port}/override-ct"))
9981            .send();
9982
9983        let (http_result, _) = tokio::join!(send_fut, async {
9984            if let Some(mut envelope) = rx.recv().await {
9985                envelope.exchange.input.body =
9986                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
9987                envelope.exchange.input.set_header(
9988                    "Content-Type",
9989                    serde_json::Value::String("text/html".to_string()),
9990                );
9991                if let Some(reply_tx) = envelope.reply_tx {
9992                    let _ = reply_tx.send(Ok(envelope.exchange));
9993                }
9994            }
9995        });
9996
9997        let resp = http_result.unwrap();
9998        assert_eq!(resp.status().as_u16(), 200);
9999        let ct = resp
10000            .headers()
10001            .get("content-type")
10002            .expect("Content-Type header should be present");
10003        assert_eq!(
10004            ct, "text/html",
10005            "User-set Content-Type should take precedence over inferred type"
10006        );
10007
10008        token.cancel();
10009    }
10010
10011    #[tokio::test]
10012    async fn test_user_content_type_with_bytes_body() {
10013        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
10014
10015        let client = reqwest::Client::new();
10016        let send_fut = client
10017            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
10018            .send();
10019
10020        let (http_result, _) = tokio::join!(send_fut, async {
10021            if let Some(mut envelope) = rx.recv().await {
10022                envelope.exchange.input.body =
10023                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
10024                envelope.exchange.input.set_header(
10025                    "Content-Type",
10026                    serde_json::Value::String("application/json".to_string()),
10027                );
10028                if let Some(reply_tx) = envelope.reply_tx {
10029                    let _ = reply_tx.send(Ok(envelope.exchange));
10030                }
10031            }
10032        });
10033
10034        let resp = http_result.unwrap();
10035        assert_eq!(resp.status().as_u16(), 200);
10036        let ct = resp
10037            .headers()
10038            .get("content-type")
10039            .expect("Content-Type header should be present for Bytes body with user header");
10040        assert_eq!(
10041            ct, "application/json",
10042            "User Content-Type should be sent for Bytes body"
10043        );
10044
10045        token.cancel();
10046    }
10047
10048    // -----------------------------------------------------------------------
10049    // Server monitor tests (GRL-005)
10050    // -----------------------------------------------------------------------
10051
10052    #[tokio::test]
10053    async fn monitor_task_silent_on_clean_exit() {
10054        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
10055        // Clean exit should complete without panicking or logging errors
10056        monitor_axum_task(
10057            handle,
10058            "127.0.0.1:0".to_string(),
10059            noop_rt(),
10060            "test-monitor".into(),
10061        )
10062        .await;
10063    }
10064
10065    #[tokio::test]
10066    async fn monitor_task_handles_panicked_task() {
10067        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
10068            panic!("simulated server crash");
10069        });
10070        // Should complete without panicking even though the inner task panicked
10071        monitor_axum_task(
10072            handle,
10073            "127.0.0.1:9999".to_string(),
10074            noop_rt(),
10075            "test-monitor".into(),
10076        )
10077        .await;
10078    }
10079
10080    // -----------------------------------------------------------------------
10081    // Credential redaction tests
10082    // -----------------------------------------------------------------------
10083
10084    #[test]
10085    fn http_auth_basic_debug_redacts_password() {
10086        let auth = HttpAuth::Basic {
10087            username: "admin".to_string(),
10088            password: "hunter2".to_string(),
10089        };
10090        let debug = format!("{:?}", auth);
10091        assert!(
10092            !debug.contains("hunter2"),
10093            "password must be redacted: {debug}"
10094        );
10095        assert!(debug.contains("admin"), "username should appear: {debug}");
10096    }
10097
10098    #[test]
10099    fn http_auth_bearer_debug_redacts_token() {
10100        let auth = HttpAuth::Bearer {
10101            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
10102        };
10103        let debug = format!("{:?}", auth);
10104        assert!(
10105            !debug.contains("eyJhbGci"),
10106            "token must be redacted: {debug}"
10107        );
10108    }
10109
10110    #[test]
10111    fn http_auth_none_debug_shows_variant() {
10112        let debug = format!("{:?}", HttpAuth::None);
10113        assert!(
10114            debug.contains("None"),
10115            "None variant should appear: {debug}"
10116        );
10117    }
10118
10119    #[test]
10120    fn http_endpoint_config_debug_redacts_auth_credentials() {
10121        let config = HttpEndpointConfig::from_uri(
10122            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
10123        )
10124        .unwrap();
10125        let debug = format!("{:?}", config);
10126        assert!(
10127            !debug.contains("secret123"),
10128            "password must be redacted in HttpEndpointConfig debug: {debug}"
10129        );
10130    }
10131
10132    #[test]
10133    fn debug_lists_all_public_fields() {
10134        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10135        let debug = format!("{:?}", config);
10136        for field in [
10137            "base_url",
10138            "http_method",
10139            "throw_exception_on_failure",
10140            "ok_status_code_range",
10141            "response_timeout",
10142            "query_params",
10143            "raw_query",
10144            "allow_internal",
10145            "blocked_hosts",
10146            "max_body_size",
10147            "read_timeout_ms",
10148            "max_response_bytes",
10149            "auth",
10150            "token_provider",
10151            "user_agent",
10152            "bridge_endpoint",
10153            "connection_close",
10154            "skip_request_headers",
10155            "skip_response_headers",
10156            "follow_redirects",
10157            "max_redirects",
10158        ] {
10159            assert!(
10160                debug.contains(field),
10161                "Debug output missing field '{field}': {debug}"
10162            );
10163        }
10164    }
10165
10166    // -----------------------------------------------------------------------
10167    // Static file serving tests (Task 5)
10168    // -----------------------------------------------------------------------
10169
10170    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
10171    use tower_http::services::ServeDir;
10172
10173    fn make_test_registry() -> HttpRouteRegistry {
10174        HttpRouteRegistry::new()
10175    }
10176
10177    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
10178        AppState {
10179            registry,
10180            max_request_body: 2 * 1024 * 1024,
10181            max_response_body: 10 * 1024 * 1024,
10182            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
10183        }
10184    }
10185
10186    #[allow(clippy::await_holding_lock)]
10187    #[tokio::test]
10188    async fn test_static_file_serving_serves_file_contents() {
10189        let _guard = lock_registry_test_mutex();
10190        ServerRegistry::reset();
10191
10192        // Create temp dir with test files
10193        let temp_dir =
10194            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
10195        std::fs::create_dir_all(&temp_dir).unwrap();
10196        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
10197        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
10198
10199        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10200
10201        let registry = make_test_registry();
10202        let serve_dir = ServeDir::new(&canonical_dir)
10203            .precompressed_gzip()
10204            .precompressed_br()
10205            .append_index_html_on_directories(true);
10206
10207        let mount = StaticMount {
10208            mount_path: "/".to_string(),
10209            mode: MountMode::Static,
10210            dir: canonical_dir.clone(),
10211            cache_control: "public, max-age=3600".to_string(),
10212            error_pages: std::collections::HashMap::new(),
10213            serve_dir,
10214        };
10215        registry.register_static_mount(mount).await.unwrap();
10216
10217        let state = make_test_state(registry);
10218
10219        // Test serving hello.txt
10220        let req = Request::builder()
10221            .uri("/hello.txt")
10222            .body(AxumBody::empty())
10223            .unwrap();
10224        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
10225        assert_eq!(resp.status(), StatusCode::OK);
10226        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10227            .await
10228            .unwrap();
10229        assert_eq!(&body[..], b"Hello, static world!");
10230
10231        // Test serving style.css
10232        let req = Request::builder()
10233            .uri("/style.css")
10234            .body(AxumBody::empty())
10235            .unwrap();
10236        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10237        assert_eq!(resp.status(), StatusCode::OK);
10238        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10239            .await
10240            .unwrap();
10241        assert_eq!(&body[..], b"body { color: red; }");
10242
10243        // Test 404 for non-existent file
10244        let req = Request::builder()
10245            .uri("/missing.txt")
10246            .body(AxumBody::empty())
10247            .unwrap();
10248        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
10249        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10250
10251        // Cleanup
10252        std::fs::remove_dir_all(&temp_dir).ok();
10253    }
10254
10255    #[allow(clippy::await_holding_lock)]
10256    #[tokio::test]
10257    async fn test_spa_fallback_serves_index_for_unknown_paths() {
10258        let _guard = lock_registry_test_mutex();
10259        ServerRegistry::reset();
10260
10261        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
10262        std::fs::create_dir_all(&temp_dir).unwrap();
10263        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
10264        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
10265
10266        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10267
10268        let registry = make_test_registry();
10269        let serve_dir = ServeDir::new(&canonical_dir)
10270            .precompressed_gzip()
10271            .precompressed_br()
10272            .append_index_html_on_directories(true);
10273
10274        let mount = StaticMount {
10275            mount_path: "/".to_string(),
10276            mode: MountMode::Spa,
10277            dir: canonical_dir.clone(),
10278            cache_control: "public, max-age=0".to_string(),
10279            error_pages: std::collections::HashMap::new(),
10280            serve_dir,
10281        };
10282        // Register as SPA mount
10283        registry.register_static_mount(mount).await.unwrap();
10284
10285        let state = make_test_state(registry);
10286
10287        // SPA fallback: GET /dashboard with Accept: text/html → index.html
10288        let req = Request::builder()
10289            .method("GET")
10290            .uri("/dashboard")
10291            .header("Accept", "text/html")
10292            .body(AxumBody::empty())
10293            .unwrap();
10294        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
10295        assert_eq!(resp.status(), StatusCode::OK);
10296        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10297            .await
10298            .unwrap();
10299        assert_eq!(&body[..], b"<h1>SPA App</h1>");
10300
10301        // Static file still works: GET /app.js
10302        let req = Request::builder()
10303            .method("GET")
10304            .uri("/app.js")
10305            .body(AxumBody::empty())
10306            .unwrap();
10307        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
10308        assert_eq!(resp.status(), StatusCode::OK);
10309        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10310            .await
10311            .unwrap();
10312        assert_eq!(&body[..], b"console.log('app')");
10313
10314        // No SPA fallback for JSON accept → 404
10315        let req = Request::builder()
10316            .method("GET")
10317            .uri("/api/data")
10318            .header("Accept", "application/json")
10319            .body(AxumBody::empty())
10320            .unwrap();
10321        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
10322        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10323
10324        // No SPA fallback for file extensions → 404
10325        let req = Request::builder()
10326            .method("GET")
10327            .uri("/style.css")
10328            .header("Accept", "text/html")
10329            .body(AxumBody::empty())
10330            .unwrap();
10331        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10332        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10333
10334        // Cleanup
10335        std::fs::remove_dir_all(&temp_dir).ok();
10336    }
10337
10338    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
10339    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
10340    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
10341    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
10342    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
10343    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
10344    #[allow(clippy::await_holding_lock)]
10345    async fn run_conditional_get_returns_304(mode: MountMode) {
10346        let _guard = lock_registry_test_mutex();
10347        ServerRegistry::reset();
10348
10349        let temp_dir = std::env::temp_dir().join(format!(
10350            "http_cond_get_{}_{}",
10351            if mode == MountMode::Spa {
10352                "spa"
10353            } else {
10354                "static"
10355            },
10356            std::process::id()
10357        ));
10358        std::fs::create_dir_all(&temp_dir).unwrap();
10359        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
10360
10361        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10362
10363        let registry = make_test_registry();
10364        let serve_dir = ServeDir::new(&canonical_dir)
10365            .precompressed_gzip()
10366            .precompressed_br()
10367            .append_index_html_on_directories(true);
10368
10369        let mount = StaticMount {
10370            mount_path: "/".to_string(),
10371            mode,
10372            dir: canonical_dir.clone(),
10373            cache_control: "public, max-age=3600".to_string(),
10374            error_pages: std::collections::HashMap::new(),
10375            serve_dir,
10376        };
10377        registry.register_static_mount(mount).await.unwrap();
10378
10379        let state = make_test_state(registry);
10380
10381        // 1st request: normal GET → 200, capture validators.
10382        let req = Request::builder()
10383            .method("GET")
10384            .uri("/index.html")
10385            .body(AxumBody::empty())
10386            .unwrap();
10387        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10388        assert_eq!(
10389            resp.status(),
10390            StatusCode::OK,
10391            "first GET should return 200, got {}",
10392            resp.status()
10393        );
10394        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
10395        assert!(
10396            resp.headers().contains_key(http::header::CACHE_CONTROL),
10397            "200 response missing Cache-Control"
10398        );
10399        let etag = resp
10400            .headers()
10401            .get(http::header::ETAG)
10402            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
10403            .clone();
10404        let last_modified = resp
10405            .headers()
10406            .get(http::header::LAST_MODIFIED)
10407            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
10408            .clone();
10409        // Consume the body so the response is fully drained.
10410        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
10411            .await
10412            .unwrap();
10413
10414        // 2nd request: If-None-Match with the captured ETag → 304.
10415        // Unconditional: ETag presence is required (asserted above) so this
10416        // sub-test cannot silently skip on a ServeDir etag_method change.
10417        let req = Request::builder()
10418            .method("GET")
10419            .uri("/index.html")
10420            .header(http::header::IF_NONE_MATCH, etag.clone())
10421            .body(AxumBody::empty())
10422            .unwrap();
10423        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10424        assert_eq!(
10425            resp.status(),
10426            StatusCode::NOT_MODIFIED,
10427            "If-None-Match with matching ETag should return 304, got {}",
10428            resp.status()
10429        );
10430        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
10431        assert!(
10432            resp.headers().contains_key(http::header::CACHE_CONTROL),
10433            "304 (If-None-Match) missing Cache-Control"
10434        );
10435        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
10436        // response parts rebuild in serve_via_serve_dir preserves them.
10437        assert_eq!(
10438            resp.headers().get(http::header::ETAG),
10439            Some(&etag),
10440            "304 (If-None-Match) must echo the ETag validator"
10441        );
10442        assert_eq!(
10443            resp.headers().get(http::header::LAST_MODIFIED),
10444            Some(&last_modified),
10445            "304 (If-None-Match) must carry Last-Modified"
10446        );
10447
10448        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
10449        let req = Request::builder()
10450            .method("GET")
10451            .uri("/index.html")
10452            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
10453            .body(AxumBody::empty())
10454            .unwrap();
10455        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10456        assert_eq!(
10457            resp.status(),
10458            StatusCode::NOT_MODIFIED,
10459            "If-Modified-Since with matching timestamp should return 304, got {}",
10460            resp.status()
10461        );
10462        assert!(
10463            resp.headers().contains_key(http::header::CACHE_CONTROL),
10464            "304 (If-Modified-Since) missing Cache-Control"
10465        );
10466        assert_eq!(
10467            resp.headers().get(http::header::ETAG),
10468            Some(&etag),
10469            "304 (If-Modified-Since) must carry the ETag validator"
10470        );
10471        assert_eq!(
10472            resp.headers().get(http::header::LAST_MODIFIED),
10473            Some(&last_modified),
10474            "304 (If-Modified-Since) must echo Last-Modified"
10475        );
10476
10477        // Negative control: a PAST If-Modified-Since (before the file's mtime)
10478        // MUST return 200 — proving the 304 path is validator-aware, not a
10479        // blanket "always 304" regression. A future date would correctly yield
10480        // 304 since the file's mtime precedes it; that is RFC-correct 304
10481        // behaviour, not a negative control.
10482        let req = Request::builder()
10483            .method("GET")
10484            .uri("/index.html")
10485            .header(
10486                http::header::IF_MODIFIED_SINCE,
10487                "Wed, 21 Oct 2000 07:28:00 GMT",
10488            )
10489            .body(AxumBody::empty())
10490            .unwrap();
10491        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10492        assert_eq!(
10493            resp.status(),
10494            StatusCode::OK,
10495            "past If-Modified-Since should return 200 (file modified after it), got {}",
10496            resp.status()
10497        );
10498
10499        // Cleanup
10500        std::fs::remove_dir_all(&temp_dir).ok();
10501    }
10502
10503    #[tokio::test]
10504    async fn test_conditional_get_returns_304_static_mode() {
10505        run_conditional_get_returns_304(MountMode::Static).await;
10506    }
10507
10508    #[tokio::test]
10509    async fn test_conditional_get_returns_304_spa_mode() {
10510        run_conditional_get_returns_304(MountMode::Spa).await;
10511    }
10512
10513    #[allow(clippy::await_holding_lock)]
10514    #[tokio::test]
10515    async fn test_error_page_mapping_serves_custom_404() {
10516        let _guard = lock_registry_test_mutex();
10517        ServerRegistry::reset();
10518
10519        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
10520        let errors_dir = temp_dir.join("errors");
10521        std::fs::create_dir_all(&errors_dir).unwrap();
10522        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
10523        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
10524
10525        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10526        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
10527
10528        let registry = make_test_registry();
10529        let serve_dir = ServeDir::new(&canonical_dir)
10530            .precompressed_gzip()
10531            .precompressed_br()
10532            .append_index_html_on_directories(true);
10533
10534        let mut error_pages = std::collections::HashMap::new();
10535        error_pages.insert(404, canonical_404);
10536
10537        let mount = StaticMount {
10538            mount_path: "/".to_string(),
10539            mode: MountMode::Static,
10540            dir: canonical_dir.clone(),
10541            cache_control: "public, max-age=0".to_string(),
10542            error_pages,
10543            serve_dir,
10544        };
10545        registry.register_static_mount(mount).await.unwrap();
10546
10547        let state = make_test_state(registry);
10548
10549        // Request non-existent file → custom 404 page
10550        let req = Request::builder()
10551            .method("GET")
10552            .uri("/missing.html")
10553            .body(AxumBody::empty())
10554            .unwrap();
10555        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
10556        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10557        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10558            .await
10559            .unwrap();
10560        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
10561
10562        // Existing file still works
10563        let req = Request::builder()
10564            .method("GET")
10565            .uri("/index.html")
10566            .body(AxumBody::empty())
10567            .unwrap();
10568        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10569        assert_eq!(resp.status(), StatusCode::OK);
10570        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10571            .await
10572            .unwrap();
10573        assert_eq!(&body[..], b"<h1>Home</h1>");
10574
10575        // Cleanup
10576        std::fs::remove_dir_all(&temp_dir).ok();
10577    }
10578
10579    #[tokio::test]
10580    async fn http_consumer_returns_body_and_code_on_stop() {
10581        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
10582        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
10583        use tower::ServiceExt;
10584
10585        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
10586        let set_body_step = CompiledStep::Process {
10587            kind_hint: camel_api::SpanKindHint::Internal,
10588            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
10589                ex.input.body = Body::Text("nope".into());
10590                Box::pin(async move { Ok(ex) })
10591            }),
10592            body_contract: None,
10593            lifecycle: None,
10594            label: None,
10595            to_uri: None,
10596        };
10597        let set_status_step = CompiledStep::Process {
10598            kind_hint: camel_api::SpanKindHint::Internal,
10599            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
10600                ex.input.set_header(
10601                    "CamelHttpResponseCode",
10602                    serde_json::Value::Number(409.into()),
10603                );
10604                Box::pin(async move { Ok(ex) })
10605            }),
10606            body_contract: None,
10607            lifecycle: None,
10608            label: None,
10609            to_uri: None,
10610        };
10611        let pipeline = compose_pipeline_with_handler(
10612            vec![set_body_step, set_status_step, CompiledStep::Stop],
10613            None,
10614            PipelineRuntimeCtx::compile_time(),
10615        );
10616
10617        let ex = Exchange::new(Message::default());
10618        let result = pipeline.oneshot(ex).await;
10619        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
10620        let returned = result.unwrap();
10621        assert_eq!(returned.input.body.as_text(), Some("nope"));
10622        assert_eq!(
10623            returned
10624                .input
10625                .header("CamelHttpResponseCode")
10626                .and_then(|v| v.as_u64()),
10627            Some(409)
10628        );
10629    }
10630
10631    #[tokio::test]
10632    async fn http_consumer_returns_200_when_body_empty_on_stop() {
10633        // After ADR-0024: Stop with no body + no status header produces 200 (same as
10634        // a normal completion with no body). The 204 default is gone — users who
10635        // want 204 set CamelHttpResponseCode=204 explicitly.
10636        //
10637        // This test stays at the pipeline level (consistent with the test above).
10638        // E2E coverage of the full HTTP dispatch path is in
10639        // crates/camel-test/tests/integration_test.rs.
10640        use camel_api::{Exchange, Message};
10641        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
10642        use tower::ServiceExt;
10643
10644        let pipeline = compose_pipeline_with_handler(
10645            vec![CompiledStep::Stop],
10646            None,
10647            PipelineRuntimeCtx::compile_time(),
10648        );
10649        let ex = Exchange::new(Message::default());
10650        let result = pipeline.oneshot(ex).await;
10651        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
10652        // Body is default (empty); no CamelHttpResponseCode header was set.
10653        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
10654    }
10655
10656    // -----------------------------------------------------------------------
10657    // Task 5: Method-aware REST dispatch tests
10658    // -----------------------------------------------------------------------
10659
10660    /// Spins up an axum server on a free port with a fresh registry.
10661    /// Returns the port plus the registry so the caller can register
10662    /// REST endpoints directly.
10663    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
10664        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10665        let port = listener.local_addr().unwrap().port();
10666        let registry = HttpRouteRegistry::new();
10667        tokio::spawn(run_axum_server(
10668            listener,
10669            registry.clone(),
10670            2 * 1024 * 1024,
10671            10 * 1024 * 1024,
10672            Arc::new(tokio::sync::Semaphore::new(1024)),
10673            test_rt(),
10674            "test-route".into(),
10675        ));
10676        // Give the server a moment to start accepting.
10677        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10678        (port, registry)
10679    }
10680
10681    /// Helper for REST integration tests: spawns a responder task that
10682    /// reads from `rx`, writes a fixed `(status, body)` back via the
10683    /// envelope's reply channel, and returns once the test request is
10684    /// satisfied.
10685    fn spawn_responder(
10686        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
10687        status: u16,
10688        body: String,
10689    ) -> tokio::task::JoinHandle<()> {
10690        tokio::spawn(async move {
10691            if let Some(envelope) = rx.recv().await {
10692                let _ = envelope.reply_tx.send(HttpReply {
10693                    status,
10694                    headers: vec![],
10695                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
10696                });
10697            }
10698        })
10699    }
10700
10701    #[tokio::test]
10702    async fn method_aware_dispatch_same_path_different_verbs() {
10703        let (port, registry) = spawn_test_server().await;
10704
10705        // Register two REST endpoints on the same path with different
10706        // methods. This is the core scenario REST DSL needs to support:
10707        // GET /users (list) and POST /users (create) must not overwrite
10708        // each other.
10709        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10710        registry
10711            .register_rest_endpoint(
10712                "GET".into(),
10713                vec![PathSegment::Literal("users".into())],
10714                get_tx,
10715            )
10716            .await;
10717
10718        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10719        registry
10720            .register_rest_endpoint(
10721                "POST".into(),
10722                vec![PathSegment::Literal("users".into())],
10723                post_tx,
10724            )
10725            .await;
10726
10727        let get_handle = spawn_responder(get_rx, 200, "list".into());
10728        let post_handle = spawn_responder(post_rx, 201, "create".into());
10729
10730        let client = reqwest::Client::new();
10731
10732        // GET /users → list route
10733        let resp = client
10734            .get(format!("http://127.0.0.1:{port}/users"))
10735            .send()
10736            .await
10737            .unwrap();
10738        assert_eq!(resp.status().as_u16(), 200);
10739        let body = resp.text().await.unwrap();
10740        assert_eq!(body, "list");
10741
10742        // POST /users → create route
10743        let resp = client
10744            .post(format!("http://127.0.0.1:{port}/users"))
10745            .send()
10746            .await
10747            .unwrap();
10748        assert_eq!(resp.status().as_u16(), 201);
10749        let body = resp.text().await.unwrap();
10750        assert_eq!(body, "create");
10751
10752        let _ = tokio::join!(get_handle, post_handle);
10753    }
10754
10755    #[tokio::test]
10756    async fn method_aware_dispatch_templated_path_extracts_params() {
10757        let (port, registry) = spawn_test_server().await;
10758
10759        // Register GET /users/{id} as a templated endpoint. The
10760        // dispatcher should match `/users/42` against the template and
10761        // attach `id=42` to the envelope's path_params.
10762        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10763        registry
10764            .register_rest_endpoint(
10765                "GET".into(),
10766                vec![
10767                    PathSegment::Literal("users".into()),
10768                    PathSegment::Param("id".into()),
10769                ],
10770                tx,
10771            )
10772            .await;
10773
10774        // Spawn a responder that echoes the captured id back in the body
10775        // so the test can verify the param was set.
10776        let handle = tokio::spawn(async move {
10777            if let Some(envelope) = rx.recv().await {
10778                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
10779                let _ = envelope.reply_tx.send(HttpReply {
10780                    status: 200,
10781                    headers: vec![],
10782                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
10783                });
10784            }
10785        });
10786
10787        let client = reqwest::Client::new();
10788        let resp = client
10789            .get(format!("http://127.0.0.1:{port}/users/42"))
10790            .send()
10791            .await
10792            .unwrap();
10793        assert_eq!(resp.status().as_u16(), 200);
10794        let body = resp.text().await.unwrap();
10795        assert_eq!(body, "id=42");
10796
10797        let _ = handle.await;
10798    }
10799
10800    #[tokio::test]
10801    async fn method_aware_dispatch_unmatched_method_falls_through() {
10802        // If no REST endpoint matches the method, dispatch must fall
10803        // through to the legacy api_routes lookup or static mounts. With
10804        // nothing else registered, the request gets 404 from static
10805        // dispatch.
10806        let (port, _registry) = spawn_test_server().await;
10807
10808        // Register only GET /users; a DELETE /users request has no match.
10809        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10810        _registry
10811            .register_rest_endpoint(
10812                "GET".into(),
10813                vec![PathSegment::Literal("users".into())],
10814                get_tx,
10815            )
10816            .await;
10817
10818        // Drain the GET channel in the background so the consumer side
10819        // doesn't block (we don't expect any envelopes here).
10820        let drain = tokio::spawn(async move {
10821            let mut get_rx = get_rx;
10822            while get_rx.recv().await.is_some() {}
10823        });
10824
10825        let client = reqwest::Client::new();
10826        let resp = client
10827            .delete(format!("http://127.0.0.1:{port}/users"))
10828            .send()
10829            .await
10830            .unwrap();
10831        assert_eq!(resp.status().as_u16(), 404);
10832
10833        drop(drain);
10834    }
10835
10836    #[tokio::test]
10837    async fn regression_legacy_exact_api_route_still_works() {
10838        // A `http:` route registered without an `httpMethod=` URI param
10839        // lands in the legacy api_routes registry. The dispatcher must
10840        // still find it via exact path lookup. This guards against
10841        // regressions introduced by the new REST-aware dispatch.
10842        let (port, registry) = spawn_test_server().await;
10843
10844        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10845        registry.register_api_route("/legacy/path".into(), tx).await;
10846
10847        let handle = tokio::spawn(async move {
10848            if let Some(envelope) = rx.recv().await {
10849                let _ = envelope.reply_tx.send(HttpReply {
10850                    status: 200,
10851                    headers: vec![],
10852                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
10853                });
10854            }
10855        });
10856
10857        let client = reqwest::Client::new();
10858        let resp = client
10859            .get(format!("http://127.0.0.1:{port}/legacy/path"))
10860            .send()
10861            .await
10862            .unwrap();
10863        assert_eq!(resp.status().as_u16(), 200);
10864        let body = resp.text().await.unwrap();
10865        assert_eq!(body, "legacy ok");
10866
10867        let _ = handle.await;
10868    }
10869
10870    #[allow(clippy::await_holding_lock)]
10871    #[tokio::test]
10872    async fn regression_static_mount_still_works() {
10873        // Verify that static file serving still works after the
10874        // dispatch refactor. We register a temp-dir mount and request
10875        // a file from it; the static dispatcher should serve it.
10876        let _guard = lock_registry_test_mutex();
10877        ServerRegistry::reset();
10878
10879        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
10880        std::fs::create_dir_all(&temp_dir).unwrap();
10881        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
10882        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10883
10884        let registry = make_test_registry();
10885        let serve_dir = ServeDir::new(&canonical_dir)
10886            .precompressed_gzip()
10887            .precompressed_br()
10888            .append_index_html_on_directories(true);
10889        let mount = StaticMount {
10890            mount_path: "/".to_string(),
10891            mode: MountMode::Static,
10892            dir: canonical_dir.clone(),
10893            cache_control: "public, max-age=3600".to_string(),
10894            error_pages: std::collections::HashMap::new(),
10895            serve_dir,
10896        };
10897        registry.register_static_mount(mount).await.unwrap();
10898
10899        let state = make_test_state(registry);
10900        let req = Request::builder()
10901            .uri("/regress.txt")
10902            .body(AxumBody::empty())
10903            .unwrap();
10904        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
10905        assert_eq!(resp.status(), StatusCode::OK);
10906        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10907            .await
10908            .unwrap();
10909        assert_eq!(&body[..], b"static works");
10910
10911        std::fs::remove_dir_all(&temp_dir).ok();
10912    }
10913
10914    // -----------------------------------------------------------------------
10915    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
10916    // templated from-URI round-trip. These exercise the real axum dispatch
10917    // path (register → HTTP request → reply) so a regression in any of the
10918    // three critical fixes surfaces as a test failure rather than a silent
10919    // production 404/500.
10920    // -----------------------------------------------------------------------
10921
10922    #[tokio::test]
10923    async fn deregister_one_method_keeps_sibling_verbs() {
10924        // Review C1: stopping the GET /users consumer must NOT tear down the
10925        // live POST /users endpoint. Register both, deregister GET only,
10926        // then verify POST still dispatches.
10927        let (port, registry) = spawn_test_server().await;
10928
10929        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10930        registry
10931            .register_rest_endpoint(
10932                "GET".into(),
10933                vec![PathSegment::Literal("users".into())],
10934                get_tx,
10935            )
10936            .await;
10937
10938        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10939        registry
10940            .register_rest_endpoint(
10941                "POST".into(),
10942                vec![PathSegment::Literal("users".into())],
10943                post_tx,
10944            )
10945            .await;
10946
10947        // Drain GET in the background (no requests expected after deregister).
10948        let drain = tokio::spawn(async move {
10949            let mut get_rx = get_rx;
10950            while get_rx.recv().await.is_some() {}
10951        });
10952
10953        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
10954        registry.unregister_rest_endpoint("GET", "/users").await;
10955        drop(drain);
10956
10957        let post_handle = spawn_responder(post_rx, 201, "create".into());
10958
10959        let client = reqwest::Client::new();
10960        // POST /users must still reach its consumer after GET was removed.
10961        let resp = client
10962            .post(format!("http://127.0.0.1:{port}/users"))
10963            .send()
10964            .await
10965            .unwrap();
10966        assert_eq!(resp.status().as_u16(), 201);
10967        assert_eq!(resp.text().await.unwrap(), "create");
10968
10969        let _ = post_handle.await;
10970    }
10971
10972    #[tokio::test]
10973    async fn dispatch_exact_legacy_beats_rest_template() {
10974        // Review C2: an exact legacy API route (`GET /api/users`, no
10975        // httpMethod) must win over a templated REST route
10976        // (`GET /api/{resource}`) for the request `/api/users`, per spec
10977        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
10978        let (port, registry) = spawn_test_server().await;
10979
10980        // Exact legacy route.
10981        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10982        registry
10983            .register_api_route("/api/users".into(), exact_tx)
10984            .await;
10985        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
10986
10987        // Templated REST route that would ALSO match /api/users.
10988        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10989        registry
10990            .register_rest_endpoint(
10991                "GET".into(),
10992                vec![
10993                    PathSegment::Literal("api".into()),
10994                    PathSegment::Param("resource".into()),
10995                ],
10996                tpl_tx,
10997            )
10998            .await;
10999        // The templated handler must NOT receive the /api/users request. If
11000        // it does, it replies "template-leak" so a future assertion could
11001        // catch it. We do NOT await this task: the exact-match branch wins
11002        // and the templated channel never receives, so awaiting would block
11003        // until the test runtime tears down.
11004        let _tpl_drain = tokio::spawn(async move {
11005            let mut tpl_rx = tpl_rx;
11006            if let Some(env) = tpl_rx.recv().await {
11007                let _ = env.reply_tx.send(HttpReply {
11008                    status: 200,
11009                    headers: vec![],
11010                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
11011                });
11012            }
11013        });
11014
11015        let client = reqwest::Client::new();
11016        let resp = client
11017            .get(format!("http://127.0.0.1:{port}/api/users"))
11018            .send()
11019            .await
11020            .unwrap();
11021        assert_eq!(resp.status().as_u16(), 200);
11022        // Exact-match handler answered — not the templated one.
11023        assert_eq!(resp.text().await.unwrap(), "exact");
11024
11025        let _ = exact_handle.await;
11026    }
11027
11028    #[tokio::test]
11029    async fn ambiguous_rest_templates_return_500_not_silent_404() {
11030        // Review C3: two equal-specificity templates that both match one
11031        // request are an ambiguous registration. At runtime this must
11032        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
11033        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
11034        let (port, registry) = spawn_test_server().await;
11035
11036        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11037        registry
11038            .register_rest_endpoint(
11039                "GET".into(),
11040                vec![
11041                    PathSegment::Literal("users".into()),
11042                    PathSegment::Param("id".into()),
11043                ],
11044                a_tx,
11045            )
11046            .await;
11047
11048        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11049        registry
11050            .register_rest_endpoint(
11051                "GET".into(),
11052                vec![
11053                    PathSegment::Literal("users".into()),
11054                    PathSegment::Param("name".into()),
11055                ],
11056                b_tx,
11057            )
11058            .await;
11059
11060        let client = reqwest::Client::new();
11061        let resp = client
11062            .get(format!("http://127.0.0.1:{port}/users/42"))
11063            .send()
11064            .await
11065            .unwrap();
11066        // Ambiguous → 500 (previously a silent 404).
11067        assert_eq!(resp.status().as_u16(), 500);
11068    }
11069
11070    #[test]
11071    fn from_uri_round_trips_templated_path_with_http_method() {
11072        // Review I4: a REST-lowered from-URI like
11073        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
11074        // through HttpServerConfig::from_uri, preserving the templated path
11075        // and the (uppercased) method. This is the binding the DSL lowering
11076        // emits and the consumer reads; it was previously unasserted.
11077        use crate::UriConfig;
11078        let cfg =
11079            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
11080        assert_eq!(cfg.host, "0.0.0.0");
11081        assert_eq!(cfg.port, 8080);
11082        assert_eq!(cfg.path, "/users/{id}");
11083        assert_eq!(cfg.method.as_deref(), Some("GET"));
11084
11085        // Lower-case httpMethod is uppercased (review I5).
11086        let cfg_lc =
11087            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
11088        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
11089        assert_eq!(cfg_lc.path, "/orders");
11090    }
11091
11092    // -----------------------------------------------------------------------
11093    // rc-1dk4: TypeConversionFailed → 400 Bad Request
11094    // -----------------------------------------------------------------------
11095
11096    #[test]
11097    fn type_conversion_failed_maps_to_400() {
11098        let reply = pipeline_error_to_reply(
11099            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
11100            "/api/users",
11101        );
11102        assert_eq!(reply.status, 400);
11103        // Exactly one Content-Type header, application/json
11104        let json_ct = reply
11105            .headers
11106            .iter()
11107            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11108            .count();
11109        assert_eq!(json_ct, 1);
11110        // Body must be structured error JSON with the expected fields
11111        let body = match &reply.body {
11112            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11113            _ => panic!("expected bytes body"),
11114        };
11115        let parsed: serde_json::Value =
11116            serde_json::from_str(&body).expect("body must be valid JSON");
11117        assert_eq!(parsed["error"], "bad_request");
11118        assert_eq!(parsed["message"], "invalid JSON at line 1");
11119    }
11120
11121    #[test]
11122    fn other_error_still_maps_to_500() {
11123        let reply =
11124            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
11125        assert_eq!(reply.status, 500);
11126    }
11127
11128    #[test]
11129    fn unauthenticated_maps_to_401() {
11130        let reply = pipeline_error_to_reply(
11131            CamelError::Unauthenticated("no token".to_string()),
11132            "/api/users",
11133        );
11134        assert_eq!(reply.status, 401);
11135    }
11136
11137    #[test]
11138    fn unauthorized_maps_to_403() {
11139        let reply = pipeline_error_to_reply(
11140            CamelError::Unauthorized("forbidden".to_string()),
11141            "/api/users",
11142        );
11143        assert_eq!(reply.status, 403);
11144    }
11145
11146    #[test]
11147    fn validation_error_maps_to_400() {
11148        let reply = pipeline_error_to_reply(
11149            CamelError::ValidationError("body does not match schema".to_string()),
11150            "/api/users",
11151        );
11152        assert_eq!(reply.status, 400);
11153        let json_ct = reply
11154            .headers
11155            .iter()
11156            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11157            .count();
11158        assert_eq!(json_ct, 1);
11159        let body = match &reply.body {
11160            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11161            _ => panic!("expected bytes body"),
11162        };
11163        let parsed: serde_json::Value =
11164            serde_json::from_str(&body).expect("body must be valid JSON");
11165        assert_eq!(parsed["error"], "validation_error");
11166        assert_eq!(parsed["message"], "body does not match schema");
11167    }
11168
11169    // -----------------------------------------------------------------------
11170    // rc-hlb1q: media negotiation errors → 415 / 406
11171    // -----------------------------------------------------------------------
11172
11173    #[test]
11174    fn finalizer_maps_unsupported_media_type() {
11175        let reply = pipeline_error_to_reply(
11176            CamelError::UnsupportedMediaType {
11177                consumed: "text/plain".to_string(),
11178                declared: "application/json".to_string(),
11179            },
11180            "/x",
11181        );
11182        assert_eq!(reply.status, 415);
11183        let json_ct = reply
11184            .headers
11185            .iter()
11186            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11187            .count();
11188        assert_eq!(json_ct, 1);
11189        let body = match &reply.body {
11190            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11191            _ => panic!("expected bytes body"),
11192        };
11193        let parsed: serde_json::Value =
11194            serde_json::from_str(&body).expect("body must be valid JSON");
11195        assert_eq!(parsed["error"], "unsupported_media_type");
11196        assert_eq!(
11197            parsed["message"],
11198            "consumed text/plain, declared application/json"
11199        );
11200    }
11201
11202    #[test]
11203    fn finalizer_maps_not_acceptable() {
11204        let reply = pipeline_error_to_reply(
11205            CamelError::NotAcceptable {
11206                accept: "application/xml".to_string(),
11207                produced: "application/json".to_string(),
11208            },
11209            "/x",
11210        );
11211        assert_eq!(reply.status, 406);
11212        let json_ct = reply
11213            .headers
11214            .iter()
11215            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11216            .count();
11217        assert_eq!(json_ct, 1);
11218        let body = match &reply.body {
11219            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11220            _ => panic!("expected bytes body"),
11221        };
11222        let parsed: serde_json::Value =
11223            serde_json::from_str(&body).expect("body must be valid JSON");
11224        assert_eq!(parsed["error"], "not_acceptable");
11225        assert_eq!(
11226            parsed["message"],
11227            "accept application/xml, produced application/json"
11228        );
11229    }
11230
11231    #[test]
11232    fn json_error_reply_preserves_empty_message() {
11233        let reply = json_error_reply(400, "bad_request", "".to_string());
11234        assert_eq!(reply.status, 400);
11235        let json_ct = reply
11236            .headers
11237            .iter()
11238            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11239            .count();
11240        assert_eq!(json_ct, 1);
11241        let body = match &reply.body {
11242            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11243            _ => panic!("expected bytes body"),
11244        };
11245        let parsed: serde_json::Value =
11246            serde_json::from_str(&body).expect("body must be valid JSON");
11247        assert_eq!(parsed["error"], "bad_request");
11248        assert_eq!(parsed["message"], "");
11249    }
11250
11251    #[test]
11252    fn https_consumer_without_tls_cert_errors() {
11253        let endpoint = HttpEndpoint {
11254            uri: "https://0.0.0.0:8443/api".to_string(),
11255            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11256            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11257            client: reqwest::Client::new(),
11258            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11259                PINNED_CLIENT_TTL,
11260                PINNED_CLIENT_MAX_ENTRIES,
11261            )),
11262            http_config: HttpConfig::default(),
11263        };
11264        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11265        let result = endpoint.create_consumer(rt);
11266        assert!(result.is_err(), "expected error for https without tls cert");
11267        if let Err(e) = result {
11268            let msg = e.to_string();
11269            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
11270        }
11271    }
11272
11273    #[test]
11274    fn http_consumer_with_tls_config_errors() {
11275        let endpoint = HttpEndpoint {
11276            uri: "http://0.0.0.0:8080/api".to_string(),
11277            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
11278            server_config: HttpServerConfig::from_uri(
11279                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
11280            )
11281            .unwrap(),
11282            client: reqwest::Client::new(),
11283            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11284                PINNED_CLIENT_TTL,
11285                PINNED_CLIENT_MAX_ENTRIES,
11286            )),
11287            http_config: HttpConfig::default(),
11288        };
11289        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11290        let result = endpoint.create_consumer(rt);
11291        assert!(result.is_err(), "expected error for http with tls config");
11292        if let Err(e) = result {
11293            let msg = e.to_string();
11294            assert!(msg.contains("https"), "error must mention https: {msg}");
11295        }
11296    }
11297
11298    #[test]
11299    fn https_consumer_with_partial_tls_cert_only_errors() {
11300        // tlsCert without tlsKey → tls_config is None at parse time
11301        // → create_consumer sees https:// + no TLS → must error
11302        let server_config =
11303            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
11304        assert!(
11305            server_config.tls_config.is_none(),
11306            "partial tlsCert must not create ServerTlsConfig"
11307        );
11308        let endpoint = HttpEndpoint {
11309            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
11310            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
11311                .unwrap(),
11312            server_config,
11313            client: reqwest::Client::new(),
11314            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11315                PINNED_CLIENT_TTL,
11316                PINNED_CLIENT_MAX_ENTRIES,
11317            )),
11318            http_config: HttpConfig::default(),
11319        };
11320        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11321        let result = endpoint.create_consumer(rt);
11322        assert!(
11323            result.is_err(),
11324            "must error: https:// requires both tlsCert and tlsKey"
11325        );
11326    }
11327
11328    #[test]
11329    fn load_tls_config_parses_valid_pem() {
11330        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
11331        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11332        use camel_component_api::test_support::tls;
11333        let (_, cert_pem, key_pem) = tls::gen_server_cert();
11334        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
11335        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
11336
11337        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
11338        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
11339    }
11340
11341    #[tokio::test(flavor = "multi_thread")]
11342    #[allow(clippy::await_holding_lock)]
11343    async fn consumer_tls_handshake_roundtrip() {
11344        use camel_component_api::test_support::tls;
11345        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11346
11347        // Install rustls crypto provider (aws-lc-rs)
11348        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11349
11350        // Serialize against global ServerRegistry singleton
11351        let _guard = lock_registry_test_mutex();
11352
11353        // Generate CA + server cert
11354        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
11355        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
11356        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
11357        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
11358
11359        // Get ephemeral port
11360        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11361        let port = probe.local_addr().unwrap().port();
11362        drop(probe);
11363
11364        ServerRegistry::reset();
11365
11366        // Create real HttpComponent + endpoint with TLS URI
11367        let component = HttpComponent::new();
11368        let endpoint_ctx = NoOpComponentContext;
11369        let uri = format!(
11370            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
11371            cert_path.to_string_lossy(),
11372            key_path.to_string_lossy(),
11373        );
11374        let endpoint = component
11375            .create_endpoint(&uri, &endpoint_ctx)
11376            .expect("create TLS endpoint");
11377        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
11378
11379        // Start consumer — this calls get_or_spawn with tls_config
11380        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11381        let token = tokio_util::sync::CancellationToken::new();
11382        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
11383        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11384
11385        // Give server time to start
11386        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
11387
11388        // Client with CA cert — REAL verification (no danger_accept_invalid)
11389        let ca_bytes = std::fs::read(&ca_path).unwrap();
11390        let client = reqwest::Client::builder()
11391            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
11392            .build()
11393            .unwrap();
11394
11395        let send_fut = client
11396            .post(format!("https://localhost:{port}/test"))
11397            .body("ping")
11398            .send();
11399
11400        // Handler: receive envelope, reply 200 with "pong" body
11401        let (http_result, _) = tokio::join!(send_fut, async {
11402            if let Some(mut envelope) = rx.recv().await {
11403                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
11404                if let Some(reply_tx) = envelope.reply_tx {
11405                    let _ = reply_tx.send(Ok(envelope.exchange));
11406                }
11407            }
11408        });
11409
11410        let resp = http_result.expect("TLS handshake + request must succeed");
11411
11412        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
11413        let body = resp.text().await.unwrap();
11414        assert_eq!(body, "pong");
11415
11416        token.cancel();
11417    }
11418
11419    #[tokio::test(flavor = "multi_thread")]
11420    #[allow(clippy::await_holding_lock)]
11421    async fn consumer_tls_rejects_client_without_ca() {
11422        use camel_component_api::test_support::tls;
11423        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11424
11425        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11426
11427        // Serialize against global ServerRegistry singleton
11428        let _guard = lock_registry_test_mutex();
11429
11430        let (_, cert_pem, key_pem) = tls::gen_server_cert();
11431        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
11432        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
11433
11434        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11435        let port = probe.local_addr().unwrap().port();
11436        drop(probe);
11437
11438        ServerRegistry::reset();
11439
11440        // Spawn TLS server via real HttpComponent path
11441        let component = HttpComponent::new();
11442        let endpoint_ctx = NoOpComponentContext;
11443        let uri = format!(
11444            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
11445            cert_path.to_string_lossy(),
11446            key_path.to_string_lossy(),
11447        );
11448        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
11449        let mut consumer = endpoint.create_consumer(rt()).unwrap();
11450        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11451        let token = tokio_util::sync::CancellationToken::new();
11452        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
11453        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11454
11455        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
11456
11457        // Client WITHOUT CA cert — must fail TLS verification
11458        let client = reqwest::Client::builder().build().unwrap();
11459
11460        let result = client
11461            .get(format!("https://localhost:{port}/test"))
11462            .send()
11463            .await;
11464
11465        assert!(
11466            result.is_err(),
11467            "must reject without CA — proves real verification"
11468        );
11469
11470        token.cancel();
11471    }
11472
11473    #[test]
11474    fn server_config_partial_tls_cert_without_key() {
11475        // Parse URI with only tlsCert (no tlsKey)
11476        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
11477        // Partial params → tls_config must be None
11478        assert!(cfg.tls_config.is_none());
11479    }
11480
11481    #[test]
11482    fn endpoint_uri_options_count_parity() {
11483        // Mirror struct must stay in sync with bespoke from_components parser.
11484        assert_eq!(
11485            HttpEndpointConfig::uri_options().len(),
11486            22,
11487            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
11488        );
11489    }
11490
11491    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
11492        pairs
11493            .iter()
11494            .map(|(k, v)| {
11495                (
11496                    (*k).to_string(),
11497                    serde_json::Value::String((*v).to_string()),
11498                )
11499            })
11500            .collect()
11501    }
11502
11503    #[test]
11504    fn response_emits_cache_control_via_pragma_warning() {
11505        let headers = make_headers(&[
11506            ("Cache-Control", "public, max-age=3600"),
11507            ("Via", "1.1 myproxy"),
11508            ("Pragma", "no-cache"),
11509            ("Warning", "199 misc"),
11510        ]);
11511        let selected = select_response_headers(&headers, None, None);
11512        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11513        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
11514            assert!(
11515                names.contains(&expected),
11516                "{expected} should pass through to the response"
11517            );
11518        }
11519    }
11520
11521    #[test]
11522    fn response_excludes_request_only_and_server_owned() {
11523        let headers = make_headers(&[
11524            ("User-Agent", "x"),
11525            ("Accept", "*/*"),
11526            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
11527        ]);
11528        let selected = select_response_headers(&headers, None, None);
11529        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11530        for excluded in ["User-Agent", "Accept", "Date"] {
11531            assert!(
11532                !names.contains(&excluded),
11533                "{excluded} should NOT appear in the response"
11534            );
11535        }
11536    }
11537
11538    #[test]
11539    fn response_re_derives_content_type() {
11540        let headers = make_headers(&[("Content-Type", "text/plain")]);
11541        let selected = select_response_headers(&headers, Some("application/json".into()), None);
11542        let ct_entries: Vec<&str> = selected
11543            .iter()
11544            .filter(|(k, _)| k == "Content-Type")
11545            .map(|(_, v)| v.as_str())
11546            .collect();
11547        assert_eq!(
11548            ct_entries,
11549            ["application/json"],
11550            "exactly one Content-Type entry, re-derived from user_content_type"
11551        );
11552    }
11553
11554    #[test]
11555    fn response_excludes_camel_headers() {
11556        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
11557        let selected = select_response_headers(&headers, None, None);
11558        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11559        assert!(
11560            !names.contains(&"CamelHttpPath"),
11561            "Camel-namespace headers must be excluded"
11562        );
11563        assert!(
11564            names.contains(&"Cache-Control"),
11565            "Cache-Control must pass through"
11566        );
11567    }
11568
11569    #[test]
11570    fn response_stringifies_scalar_header_values() {
11571        let mut headers = make_headers(&[("X-Label", "keep")]);
11572        headers.insert("X-Retries".to_string(), serde_json::json!(3));
11573        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
11574        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
11575        let selected = select_response_headers(&headers, None, None);
11576        let get = |name: &str| -> Option<&str> {
11577            selected
11578                .iter()
11579                .find(|(k, _)| k == name)
11580                .map(|(_, v)| v.as_str())
11581        };
11582        assert_eq!(
11583            get("X-Retries"),
11584            Some("3"),
11585            "integer header must be stringified"
11586        );
11587        assert_eq!(
11588            get("X-Ratio"),
11589            Some("3.5"),
11590            "float header must be stringified"
11591        );
11592        assert_eq!(
11593            get("X-Enabled"),
11594            Some("true"),
11595            "bool header must be stringified"
11596        );
11597        assert_eq!(
11598            get("X-Label"),
11599            Some("keep"),
11600            "string header must pass through"
11601        );
11602    }
11603
11604    #[test]
11605    fn response_drops_null_and_structured_header_values() {
11606        let mut headers = make_headers(&[("X-Keep", "yes")]);
11607        headers.insert("X-Null".to_string(), serde_json::Value::Null);
11608        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
11609        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
11610        let selected = select_response_headers(&headers, None, None);
11611        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11612        for dropped in ["X-Null", "X-Obj", "X-Arr"] {
11613            assert!(
11614                !names.contains(&dropped),
11615                "{dropped} must not be emitted: no single-value form"
11616            );
11617        }
11618        assert!(names.contains(&"X-Keep"), "scalar headers must survive");
11619    }
11620
11621    #[test]
11622    fn response_stringifies_scalars_despite_excluded_names() {
11623        // Excluded names stay excluded regardless of value type: the policy
11624        // filter runs before stringification, so numeric values cannot smuggle
11625        // content-length or server-owned headers into the reply.
11626        let mut headers = HashMap::new();
11627        headers.insert("Content-Length".to_string(), serde_json::json!(999));
11628        headers.insert("Date".to_string(), serde_json::json!(12345));
11629        let selected = select_response_headers(&headers, None, None);
11630        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11631        assert!(
11632            !names.contains(&"Content-Length"),
11633            "content-length is re-derived by the server"
11634        );
11635        assert!(!names.contains(&"Date"), "date is server-owned");
11636    }
11637
11638    #[test]
11639    fn outbound_stringifies_scalar_header_values() {
11640        let mut headers = make_headers(&[("X-Label", "keep")]);
11641        headers.insert("X-Retries".to_string(), serde_json::json!(3));
11642        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
11643        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
11644        let outbound = select_outbound_headers(&headers, &[], &[]);
11645        // HeaderName construction lowercases; lookups compare case-blind.
11646        let get = |name: &str| -> Option<String> {
11647            outbound
11648                .accepted
11649                .iter()
11650                .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11651                .map(|(_, v)| v.to_str().unwrap().to_string())
11652        };
11653        assert_eq!(
11654            get("X-Retries").as_deref(),
11655            Some("3"),
11656            "integer header must be stringified"
11657        );
11658        assert_eq!(
11659            get("X-Ratio").as_deref(),
11660            Some("3.5"),
11661            "float header must be stringified"
11662        );
11663        assert_eq!(
11664            get("X-Enabled").as_deref(),
11665            Some("true"),
11666            "bool header must be stringified"
11667        );
11668        assert_eq!(
11669            get("X-Label").as_deref(),
11670            Some("keep"),
11671            "string header must pass through"
11672        );
11673        assert!(outbound.drops.is_empty(), "scalar headers must not drop");
11674    }
11675
11676    #[test]
11677    fn outbound_drops_null_and_structured_header_values() {
11678        let mut headers = make_headers(&[("X-Keep", "yes")]);
11679        headers.insert("X-Null".to_string(), serde_json::Value::Null);
11680        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
11681        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
11682        let outbound = select_outbound_headers(&headers, &[], &[]);
11683        let has = |name: &str| {
11684            outbound
11685                .accepted
11686                .iter()
11687                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11688        };
11689        assert!(has("X-Keep"), "scalar headers must survive");
11690        for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
11691            let dropped = outbound
11692                .drops
11693                .iter()
11694                .find(|d| d.name == name)
11695                .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
11696            assert_eq!(
11697                dropped.reason, "no scalar string form",
11698                "{name} drop reason must name the value kind absence"
11699            );
11700            assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
11701        }
11702    }
11703
11704    #[test]
11705    fn outbound_stringifies_scalars_despite_excluded_names() {
11706        // Excluded names stay excluded regardless of value type: the policy
11707        // filter runs before stringification, so numeric values cannot smuggle
11708        // hop-by-hop or client-derived headers onto the wire.
11709        let mut headers = HashMap::new();
11710        headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
11711        headers.insert("Host".to_string(), serde_json::json!(12345));
11712        headers.insert("X-Ok".to_string(), serde_json::json!(7));
11713        let outbound = select_outbound_headers(&headers, &[], &[]);
11714        let has = |name: &str| {
11715            outbound
11716                .accepted
11717                .iter()
11718                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11719        };
11720        assert!(
11721            !has("Transfer-Encoding"),
11722            "hop-by-hop header must stay excluded"
11723        );
11724        assert!(!has("Host"), "host is destination-derived");
11725        assert!(has("X-Ok"), "non-excluded scalar must be stringified");
11726        assert!(
11727            outbound
11728                .drops
11729                .iter()
11730                .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
11731            "policy drop must be recorded before coercion"
11732        );
11733    }
11734
11735    #[test]
11736    fn outbound_drops_invalid_names_values_and_skip_config() {
11737        let mut headers = make_headers(&[("X-Good", "fine")]);
11738        headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
11739        headers.insert(
11740            "X-Control-Value".to_string(),
11741            serde_json::json!("line1\nline2"),
11742        );
11743        headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
11744        headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
11745        let skip = vec!["x-secret".to_string()];
11746        let outbound = select_outbound_headers(&headers, &skip, &[]);
11747        let has = |name: &str| {
11748            outbound
11749                .accepted
11750                .iter()
11751                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11752        };
11753        assert!(has("X-Good"), "valid header must survive");
11754        assert!(!has("X Bad Name"), "invalid header name must drop");
11755        assert!(!has("X-Control-Value"), "control-char value must drop");
11756        assert!(!has("X-Secret"), "skipped header must drop");
11757        assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
11758        let reason = |n: &str| {
11759            outbound
11760                .drops
11761                .iter()
11762                .find(|d| d.name == n)
11763                .map(|d| d.reason)
11764        };
11765        assert_eq!(reason("X Bad Name"), Some("invalid header name"));
11766        assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
11767        assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
11768        assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
11769    }
11770
11771    #[test]
11772    fn constructed_header_invalid_value_returns_drop_record() {
11773        let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
11774        let Err(record) = result else {
11775            panic!("invalid value must produce a drop record");
11776        };
11777        assert_eq!(record.reason, "invalid header value");
11778        assert_eq!(record.name, "user-agent");
11779        assert!(record.value_kind.is_none());
11780        let debug = format!("{record:?}");
11781        assert!(
11782            !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
11783            "drop record debug must not leak the value"
11784        );
11785    }
11786
11787    #[test]
11788    fn constructed_header_invalid_name_returns_drop_record() {
11789        let result = constructed_header("bad name", "ok");
11790        let Err(record) = result else {
11791            panic!("invalid name must produce a drop record");
11792        };
11793        assert_eq!(record.reason, "invalid header name");
11794        assert_eq!(record.name, "bad name");
11795        let debug = format!("{record:?}");
11796        assert!(
11797            !debug.contains("ok"),
11798            "drop record debug must not leak the value"
11799        );
11800    }
11801
11802    #[test]
11803    fn constructed_header_valid_pair_roundtrip() {
11804        let result = constructed_header("authorization", "Bearer abc123");
11805        let Ok((name, val)) = result else {
11806            panic!("valid pair must construct");
11807        };
11808        assert_eq!(name.as_str(), "authorization");
11809        let Ok(roundtrip) = val.to_str() else {
11810            panic!("valid value must roundtrip to str");
11811        };
11812        assert_eq!(roundtrip, "Bearer abc123");
11813    }
11814
11815    // -----------------------------------------------------------------------
11816    // Bridge proxy end-to-end integration tests (Task 4.1)
11817    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
11818    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
11819    // -----------------------------------------------------------------------
11820
11821    /// Destination server that captures the outbound request line and the
11822    /// `Host:` header the producer actually sent on the wire. Returns
11823    /// `(host_value, request_line)` so a bridge-proxy test can assert that
11824    /// the producer derived `Host` from the destination (not the exchange)
11825    /// and honoured bridging semantics for the path.
11826    async fn start_host_capturing_destination() -> (
11827        String,
11828        Arc<std::sync::Mutex<Option<(String, String)>>>,
11829        tokio::task::JoinHandle<()>,
11830    ) {
11831        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11832        let port = listener.local_addr().unwrap().port();
11833        let url = format!("http://127.0.0.1:{port}");
11834        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
11835            Arc::new(std::sync::Mutex::new(None));
11836        let captured_clone = Arc::clone(&captured);
11837        let handle = tokio::spawn(async move {
11838            use tokio::io::{AsyncReadExt, AsyncWriteExt};
11839            if let Ok((mut stream, _)) = listener.accept().await {
11840                let mut buf = vec![0u8; 16384];
11841                let n = stream.read(&mut buf).await.unwrap_or(0);
11842                let request = String::from_utf8_lossy(&buf[..n]).to_string();
11843                if request.contains("\r\n\r\n") {
11844                    let request_line = request.lines().next().unwrap_or("").to_string();
11845                    let host_value = request
11846                        .lines()
11847                        .find(|l| l.to_lowercase().starts_with("host:"))
11848                        .and_then(|l| l.split_once(':'))
11849                        .map(|(_, v)| v.trim().to_string())
11850                        .unwrap_or_default();
11851                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
11852                }
11853                let body = r#"{"echo":"ok"}"#;
11854                let resp = format!(
11855                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
11856                    body.len(),
11857                    body
11858                );
11859                let _ = stream.write_all(resp.as_bytes()).await;
11860            }
11861        });
11862        (url, captured, handle)
11863    }
11864
11865    /// A bridging producer must derive `Host` from the destination URL and
11866    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
11867    /// semantics. The wire-level proof is the raw `Host:` header and request
11868    /// line captured at the destination TCP socket.
11869    #[tokio::test]
11870    async fn bridge_proxy_outbound_host_matches_destination() {
11871        use tower::ServiceExt;
11872
11873        let (url, captured, _handle) = start_host_capturing_destination().await;
11874        // The Host header reqwest derives for http://127.0.0.1:{port} is the
11875        // authority, scheme-stripped: "127.0.0.1:{port}".
11876        let expected_host = url.strip_prefix("http://").unwrap();
11877
11878        let ctx = test_producer_ctx();
11879        let component = HttpComponent::new();
11880        let endpoint_ctx = NoOpComponentContext;
11881        let endpoint = component
11882            .create_endpoint(
11883                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
11884                &endpoint_ctx,
11885            )
11886            .unwrap();
11887        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
11888
11889        // Exchange carries a stale Host and a CamelHttpPath that bridging
11890        // must drop.
11891        let mut exchange = Exchange::new(Message::default());
11892        exchange.input.set_header("Host", "localhost");
11893        exchange.input.set_header("CamelHttpPath", "/foo");
11894
11895        let result = producer.oneshot(exchange).await;
11896        assert!(result.is_ok(), "producer call failed: {:?}", result);
11897
11898        tokio::time::sleep(Duration::from_millis(100)).await;
11899        let (host_value, request_line) = captured
11900            .lock()
11901            .unwrap()
11902            .take()
11903            .expect("destination capture mutex empty — producer did not reach the destination");
11904
11905        assert_ne!(
11906            host_value, "localhost",
11907            "bridge producer must not forward the exchange Host: localhost"
11908        );
11909        assert_eq!(
11910            host_value, expected_host,
11911            "Host must be derived from the destination authority (no scheme)"
11912        );
11913        assert!(
11914            !request_line.contains("/foo"),
11915            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
11916        );
11917    }
11918
11919    /// A response header set by the route (`Cache-Control`) must survive to
11920    /// the wire. The assertion is on the reqwest HTTP response — not an
11921    /// in-process HttpReply struct — so it proves the consumer's reply
11922    /// finaliser emitted the header over the socket.
11923    #[tokio::test]
11924    async fn bridge_proxy_route_set_response_header_survives() {
11925        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11926
11927        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11928        let port = listener.local_addr().unwrap().port();
11929        drop(listener);
11930
11931        let component = HttpComponent::new();
11932        let endpoint_ctx = NoOpComponentContext;
11933        let endpoint = component
11934            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
11935            .unwrap();
11936        let mut consumer = endpoint.create_consumer(rt()).unwrap();
11937
11938        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11939        let token = tokio_util::sync::CancellationToken::new();
11940        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
11941
11942        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11943        tokio::time::sleep(Duration::from_millis(50)).await;
11944
11945        let client = reqwest::Client::new();
11946        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
11947
11948        // Route sets Cache-Control on the outbound reply (exchange.input is
11949        // the message the reply finaliser reads — see select_response_headers
11950        // at the dispatch site).
11951        let (http_result, _) = tokio::join!(send_fut, async {
11952            if let Some(mut envelope) = rx.recv().await {
11953                envelope
11954                    .exchange
11955                    .input
11956                    .set_header("Cache-Control", "public, max-age=3600");
11957                if let Some(reply_tx) = envelope.reply_tx {
11958                    let _ = reply_tx.send(Ok(envelope.exchange));
11959                }
11960            }
11961        });
11962
11963        let resp = http_result.unwrap();
11964        assert_eq!(resp.status().as_u16(), 200);
11965
11966        let cache_control = resp.headers().get("cache-control");
11967        assert!(
11968            cache_control.is_some(),
11969            "Cache-Control header must survive to the wire response"
11970        );
11971        assert_eq!(
11972            cache_control.unwrap().to_str().unwrap(),
11973            "public, max-age=3600"
11974        );
11975
11976        token.cancel();
11977    }
11978
11979    // -----------------------------------------------------------------------
11980    // credential-sources task 2.3: credential values stay out of diagnostics
11981    // -----------------------------------------------------------------------
11982    //
11983    // camel-http has no request access log (design.md "Redaction sinks",
11984    // ADR-0051). The only diagnostic sink on the failed-auth path is
11985    // `pipeline_error_to_reply`, which renders the (generic) error message and
11986    // the *configured* route path — never the request URI, query string, or
11987    // extracted credential. These tests pin that redact-by-construction
11988    // contract: a sentinel credential presented in a declared source must not
11989    // appear in the reply body nor in any tracing record emitted while the
11990    // request is handled.
11991    //
11992    // Capture scope: `#[traced_test]` installs a per-crate env filter
11993    // (`camel_component_http=trace`), so records from OTHER targets
11994    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
11995    // redaction contract for those crates is guarded by their own tests.
11996    // Revisit this capture scope if camel-auth ever logs on the auth path.
11997    use camel_api::security_policy::CredentialSource;
11998    use camel_auth::credential_source::extract_token_from_exchange;
11999    use camel_auth::native_auth::NativeCredentialStore;
12000    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
12001
12002    // Sentinel credential values — test fixtures only, not real secrets.
12003    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
12004    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
12005    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
12006
12007    /// Build the exchange the consumer would build for a request envelope:
12008    /// standard Camel HTTP headers plus title-cased forwarded request headers.
12009    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
12010        let mut msg = Message::default();
12011        msg.set_header(
12012            "CamelHttpMethod",
12013            serde_json::Value::String(envelope.method.clone()),
12014        );
12015        msg.set_header(
12016            "CamelHttpPath",
12017            serde_json::Value::String(envelope.path.clone()),
12018        );
12019        msg.set_header(
12020            "CamelHttpQuery",
12021            serde_json::Value::String(envelope.query.clone()),
12022        );
12023        for (k, v) in &envelope.headers {
12024            if let Ok(val_str) = v.to_str() {
12025                msg.set_header(
12026                    title_case_header(k.as_str()),
12027                    serde_json::Value::String(val_str.to_string()),
12028                );
12029            }
12030        }
12031        Exchange::new(msg)
12032    }
12033
12034    /// Register a route whose responder authenticates each request against an
12035    /// empty native store, so every presented credential fails lookup with
12036    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
12037    /// authentication step (extract per `sources` → authenticate → deny) so the
12038    /// credential-extraction redaction contract is exercised on a real
12039    /// authentication failure.
12040    async fn spawn_failing_auth_route(
12041        registry: &HttpRouteRegistry,
12042        path: &str,
12043        sources: Vec<CredentialSource>,
12044    ) {
12045        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
12046            NativeCredentialStore::try_new(vec![]).unwrap(),
12047        ));
12048        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12049        registry.register_api_route(path.to_string(), tx).await;
12050        let path_owned = path.to_string();
12051        tokio::spawn(async move {
12052            while let Some(envelope) = rx.recv().await {
12053                let exchange = envelope_to_exchange(&envelope);
12054                let reply_tx = envelope.reply_tx;
12055                let result: Result<(), CamelError> = async {
12056                    let token = extract_token_from_exchange(&exchange, &sources)
12057                        .map(|extracted| extracted.token)
12058                        .ok_or_else(|| {
12059                            CamelError::Unauthenticated("no credential in any source".into())
12060                        })?;
12061                    authenticator.authenticate_bearer(&token).await?;
12062                    Ok(())
12063                }
12064                .await;
12065                let reply = match result {
12066                    Ok(()) => HttpReply {
12067                        status: 200,
12068                        headers: vec![],
12069                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
12070                    },
12071                    Err(e) => pipeline_error_to_reply(e, &path_owned),
12072                };
12073                let _ = reply_tx.send(reply);
12074            }
12075        });
12076    }
12077
12078    /// Whether any tracing record captured so far (process-wide) contains
12079    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
12080    /// shared buffer, so logs from spawned request-handling tasks are included.
12081    fn captured_logs_contain(needle: &str) -> bool {
12082        let buf = tracing_test::internal::global_buf().lock().unwrap();
12083        String::from_utf8_lossy(&buf).contains(needle)
12084    }
12085
12086    #[tracing_test::traced_test]
12087    #[tokio::test]
12088    async fn error_context_redacts_query_sentinel() {
12089        let (port, registry) = spawn_test_server().await;
12090        spawn_failing_auth_route(
12091            &registry,
12092            "/secure-query",
12093            vec![CredentialSource::QueryParam {
12094                param: "token".to_string(),
12095            }],
12096        )
12097        .await;
12098
12099        let client = reqwest::Client::new();
12100        let resp = client
12101            // allow-secret: `token` is the declared query-source param name, not a credential
12102            .get(format!(
12103                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
12104            ))
12105            .send()
12106            .await
12107            .unwrap();
12108
12109        assert_eq!(resp.status().as_u16(), 401);
12110        let body = resp.text().await.unwrap();
12111        assert_eq!(body, "Unauthorized");
12112        assert!(
12113            !body.contains(SENTINEL_QRY_42),
12114            "reply body must not contain the query credential"
12115        );
12116        assert!(
12117            !captured_logs_contain(SENTINEL_QRY_42),
12118            "no tracing record during request handling may render the query credential"
12119        );
12120        // Permanent positive control: the failed-auth warn! must be captured.
12121        // If the per-crate env filter ever stops matching, this fails loudly
12122        // instead of letting the sentinel assertions pass vacuously.
12123        assert!(
12124            captured_logs_contain("Authentication failed"),
12125            "positive control: the failed-auth warn! must be captured by the test subscriber"
12126        );
12127    }
12128
12129    #[tracing_test::traced_test]
12130    #[tokio::test]
12131    async fn error_context_redacts_cookie_sentinel() {
12132        let (port, registry) = spawn_test_server().await;
12133        spawn_failing_auth_route(
12134            &registry,
12135            "/secure-cookie",
12136            vec![CredentialSource::Cookie {
12137                name: "session".to_string(),
12138            }],
12139        )
12140        .await;
12141
12142        let client = reqwest::Client::new();
12143        let resp = client
12144            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
12145            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
12146            .send()
12147            .await
12148            .unwrap();
12149
12150        assert_eq!(resp.status().as_u16(), 401);
12151        let body = resp.text().await.unwrap();
12152        assert_eq!(body, "Unauthorized");
12153        assert!(
12154            !body.contains(SENTINEL_CKY_7),
12155            "reply body must not contain the cookie credential"
12156        );
12157        assert!(
12158            !captured_logs_contain(SENTINEL_CKY_7),
12159            "no tracing record during request handling may render the cookie credential"
12160        );
12161    }
12162
12163    #[tracing_test::traced_test]
12164    #[tokio::test]
12165    async fn error_reply_no_credential_value() {
12166        let (port, registry) = spawn_test_server().await;
12167        spawn_failing_auth_route(
12168            &registry,
12169            "/secure-bad",
12170            vec![CredentialSource::Cookie {
12171                name: "session".to_string(),
12172            }],
12173        )
12174        .await;
12175
12176        let client = reqwest::Client::new();
12177        let resp = client
12178            .get(format!("http://127.0.0.1:{port}/secure-bad"))
12179            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
12180            .send()
12181            .await
12182            .unwrap();
12183
12184        assert_eq!(resp.status().as_u16(), 401);
12185        let body = resp.text().await.unwrap();
12186        assert_eq!(body, "Unauthorized");
12187        assert!(
12188            !body.contains(SENTINEL_BAD_1),
12189            "reply body must not contain the credential value"
12190        );
12191        assert!(
12192            !captured_logs_contain(SENTINEL_BAD_1),
12193            "error logs must not render the credential value"
12194        );
12195    }
12196
12197    // -----------------------------------------------------------------------
12198    // Pinned-client-cache producer-path behavioral tests
12199    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
12200    // the endpoint cache, hostname requests build one client while the entry
12201    // stays retrievable, IP-literal requests bypass the cache)
12202    // -----------------------------------------------------------------------
12203
12204    /// Local responder that accepts any number of HTTP/1.1 connections on an
12205    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
12206    /// Unlike [`start_host_capturing_destination`], which serves exactly one
12207    /// connection, this loop keeps accepting so cache-reuse tests can drive
12208    /// several requests through one destination. Returns
12209    /// `(base_url, JoinHandle)`.
12210    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12211        use tokio::io::AsyncWriteExt;
12212
12213        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12214            .await
12215            .expect("bind ephemeral 127.0.0.1 listener");
12216        let port = listener.local_addr().expect("local addr").port();
12217        let base_url = format!("http://localhost:{port}");
12218        let handle = tokio::spawn(async move {
12219            while let Ok((mut conn, _)) = listener.accept().await {
12220                let _ = conn
12221                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12222                    .await;
12223                let _ = conn.shutdown().await;
12224            }
12225        });
12226        (base_url, handle)
12227    }
12228
12229    /// rc-0li3: local HTTPS responder — the TLS twin of
12230    /// [`spawn_multi_accept_200`]. Accepts any number of TLS connections on
12231    /// an ephemeral 127.0.0.1 port and answers each with a fixed 200. The
12232    /// certificate comes from `camel_component_api::test_support`
12233    /// (SANs: localhost, 127.0.0.1, ::1); clients run with
12234    /// `tls.insecure = true`.
12235    async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12236        use tokio::io::AsyncWriteExt;
12237
12238        let (_ca_pem, cert_pem, key_pem) =
12239            camel_component_api::test_support::tls::gen_server_cert();
12240        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
12241            .collect::<Result<_, _>>()
12242            .expect("parse server cert pem");
12243        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
12244            .expect("parse server key pem")
12245            .expect("server key present");
12246        // Explicit provider: the process default is ambiguous when multiple
12247        // crates pull rustls feature sets; the graph enables aws-lc-rs.
12248        let provider =
12249            std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
12250        let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
12251            .with_safe_default_protocol_versions()
12252            .expect("safe default protocol versions")
12253            .with_no_client_auth()
12254            .with_single_cert(certs, key)
12255            .expect("build rustls server config");
12256        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
12257
12258        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12259            .await
12260            .expect("bind ephemeral 127.0.0.1 listener");
12261        let port = listener.local_addr().expect("local addr").port();
12262        let base_url = format!("https://localhost:{port}");
12263        let handle = tokio::spawn(async move {
12264            while let Ok((conn, _)) = listener.accept().await {
12265                let acceptor = acceptor.clone();
12266                tokio::spawn(async move {
12267                    if let Ok(mut tls) = acceptor.accept(conn).await {
12268                        let _ = tls
12269                            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12270                            .await;
12271                        let _ = tls.shutdown().await;
12272                    }
12273                });
12274            }
12275        });
12276        (base_url, handle)
12277    }
12278
12279    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
12280    /// target a different authority (the 127.0.0.1 literal) on the same
12281    /// listener.
12282    fn responder_port(base_url: &str) -> u16 {
12283        url::Url::parse(base_url)
12284            .expect("responder base URL parses")
12285            .port()
12286            .expect("responder base URL carries an explicit port")
12287    }
12288
12289    /// Build an endpoint literal whose outbound config points at
12290    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
12291    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
12292    /// build counts stay observable across producers.
12293    fn endpoint_with_shared_cache(
12294        base_url: &str,
12295        pinned_cache: &Arc<PinnedClientCache>,
12296    ) -> HttpEndpoint {
12297        let uri = format!("{base_url}?allowInternal=true");
12298        HttpEndpoint {
12299            uri: uri.clone(),
12300            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
12301            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
12302            client: reqwest::Client::new(),
12303            pinned_cache: Arc::clone(pinned_cache),
12304            http_config: HttpConfig::default(),
12305        }
12306    }
12307
12308    #[tokio::test]
12309    async fn producers_share_endpoint_cache() {
12310        use tower::ServiceExt;
12311
12312        let (base_url, _handle) = spawn_multi_accept_200().await;
12313        let pinned_cache = Arc::new(PinnedClientCache::new(
12314            PINNED_CLIENT_TTL,
12315            PINNED_CLIENT_MAX_ENTRIES,
12316        ));
12317
12318        let ctx = test_producer_ctx();
12319        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
12320        let producer_a = endpoint.create_producer(rt(), &ctx);
12321        let producer_b = endpoint.create_producer(rt(), &ctx);
12322
12323        // Each producer sends one exchange whose resolved URL is the
12324        // endpoint's localhost base URL (a domain name → pinned-client path).
12325        for producer in [producer_a, producer_b] {
12326            let producer = producer.expect("create producer");
12327            let exchange = Exchange::new(Message::default());
12328            let reply = producer.oneshot(exchange).await;
12329            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12330        }
12331
12332        assert_eq!(
12333            pinned_cache.build_count(),
12334            1,
12335            "both producers must hit the same shared cache entry; a second \
12336             build means sharing is broken"
12337        );
12338    }
12339
12340    #[tokio::test]
12341    async fn producer_repeated_hostname_requests_build_one_client() {
12342        use tower::ServiceExt;
12343
12344        let (base_url, _handle) = spawn_multi_accept_200().await;
12345        let pinned_cache = Arc::new(PinnedClientCache::new(
12346            PINNED_CLIENT_TTL,
12347            PINNED_CLIENT_MAX_ENTRIES,
12348        ));
12349        let ctx = test_producer_ctx();
12350        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
12351        let producer = endpoint
12352            .create_producer(rt(), &ctx)
12353            .expect("create producer");
12354
12355        // Two sequential hostname requests — the cached pinned client stays
12356        // retrievable between them, so no second build may happen.
12357        for i in 0..2 {
12358            let exchange = Exchange::new(Message::default());
12359            let reply = producer.clone().oneshot(exchange).await;
12360            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
12361        }
12362
12363        assert_eq!(
12364            pinned_cache.build_count(),
12365            1,
12366            "repeated hostname requests must reuse the one pinned client; \
12367             0 builds means the producer bypassed the cache, more than 1 \
12368             means the entry was dropped"
12369        );
12370    }
12371
12372    #[tokio::test]
12373    async fn ip_literal_request_never_enters_cache() {
12374        use tower::ServiceExt;
12375
12376        let (base_url, _handle) = spawn_multi_accept_200().await;
12377        let pinned_cache = Arc::new(PinnedClientCache::new(
12378            PINNED_CLIENT_TTL,
12379            PINNED_CLIENT_MAX_ENTRIES,
12380        ));
12381
12382        let ctx = test_producer_ctx();
12383        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
12384        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
12385        let producer = endpoint
12386            .create_producer(rt(), &ctx)
12387            .expect("create producer");
12388
12389        let exchange = Exchange::new(Message::default());
12390        let reply = producer.oneshot(exchange).await;
12391        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12392
12393        assert_eq!(
12394            pinned_cache.build_count(),
12395            0,
12396            "an IP-literal URL must use the shared unpinned client and \
12397             never enter the pinned cache"
12398        );
12399    }
12400
12401    #[tokio::test]
12402    async fn test_component_endpoints_share_pinned_cache() {
12403        use tower::ServiceExt;
12404
12405        let component = HttpComponent::new();
12406        let (base_url, _handle) = spawn_multi_accept_200().await;
12407        let baseline = component.pinned_cache.build_count();
12408
12409        let ctx = test_producer_ctx();
12410        let endpoint_ctx = NoOpComponentContext;
12411        for uri in [
12412            format!("{base_url}/a?allowInternal=true&k=a"),
12413            format!("{base_url}/b?allowInternal=true&k=b"),
12414        ] {
12415            let endpoint = component
12416                .create_endpoint(&uri, &endpoint_ctx)
12417                .expect("create endpoint");
12418            let producer = endpoint
12419                .create_producer(rt(), &ctx)
12420                .expect("create producer");
12421            let exchange = Exchange::new(Message::default());
12422            let reply = producer.oneshot(exchange).await;
12423            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12424        }
12425
12426        assert_eq!(
12427            component.pinned_cache.build_count() - baseline,
12428            1,
12429            "endpoints created by one component must share its pinned cache; \
12430             0 builds means the endpoints bypassed it, more than 1 means \
12431             per-endpoint caches came back"
12432        );
12433    }
12434
12435    #[tokio::test]
12436    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
12437        use tower::ServiceExt;
12438
12439        let component = HttpComponent::new();
12440        let (base_url, _handle) = spawn_multi_accept_200().await;
12441        let baseline = component.pinned_cache.build_count();
12442
12443        let ctx = test_producer_ctx();
12444        let endpoint_ctx = NoOpComponentContext;
12445        for i in 0..3 {
12446            let endpoint = component
12447                .create_endpoint(
12448                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
12449                    &endpoint_ctx,
12450                )
12451                .expect("create endpoint");
12452            let producer = endpoint
12453                .create_producer(rt(), &ctx)
12454                .expect("create producer");
12455            let exchange = Exchange::new(Message::default());
12456            let reply = producer.oneshot(exchange).await;
12457            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
12458        }
12459
12460        assert_eq!(
12461            component.pinned_cache.build_count() - baseline,
12462            1,
12463            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
12464             must reuse the component's one pinned cache entry; 0 builds \
12465             means the endpoints bypassed it, more than 1 means \
12466             per-endpoint caches came back"
12467        );
12468    }
12469
12470    /// rc-0li3: BEHAVIORAL https sharing pin — two https endpoints created
12471    /// through one `HttpsComponent` drive real TLS requests through the
12472    /// component's single pinned cache. A regression that reintroduces
12473    /// per-endpoint `PinnedClientCache::new` inside
12474    /// `HttpsComponent::create_endpoint` leaves the component cache at
12475    /// delta 0 and fails this test (the structural ptr_eq test cannot see
12476    /// that).
12477    #[tokio::test]
12478    async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
12479        use tower::ServiceExt;
12480
12481        let http_config = HttpConfig {
12482            tls: Some(crate::config::TlsConfig {
12483                enabled: true,
12484                insecure: true,
12485                ..Default::default()
12486            }),
12487            ..Default::default()
12488        };
12489        let component = HttpsComponent::with_config(http_config);
12490        let (base_url, _handle) = spawn_tls_multi_accept_200().await;
12491        let baseline = component.pinned_cache.build_count();
12492
12493        let ctx = test_producer_ctx();
12494        let endpoint_ctx = NoOpComponentContext;
12495        for uri in [
12496            format!("{base_url}/a?allowInternal=true&k=a"),
12497            format!("{base_url}/b?allowInternal=true&k=b"),
12498        ] {
12499            let endpoint = component
12500                .create_endpoint(&uri, &endpoint_ctx)
12501                .expect("create https endpoint");
12502            let producer = endpoint
12503                .create_producer(rt(), &ctx)
12504                .expect("create producer");
12505            let exchange = Exchange::new(Message::default());
12506            let reply = producer.oneshot(exchange).await;
12507            assert!(reply.is_ok(), "https request failed: {reply:?}");
12508        }
12509
12510        assert_eq!(
12511            component.pinned_cache.build_count() - baseline,
12512            1,
12513            "endpoints of one HttpsComponent must share its pinned cache over \
12514             real https requests; 0 builds means the endpoints bypassed it \
12515             (per-endpoint cache regression), more than 1 means \
12516             per-endpoint caches came back"
12517        );
12518    }
12519
12520    #[test]
12521    fn test_https_component_owns_distinct_cache() {
12522        let http = HttpComponent::new();
12523        let https = HttpsComponent::new();
12524
12525        assert!(
12526            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
12527            "http and https components must each own their own pinned cache"
12528        );
12529
12530        let endpoint_ctx = NoOpComponentContext;
12531        let _ = http
12532            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
12533            .expect("http endpoint");
12534        let _ = https
12535            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
12536            .expect("https endpoint");
12537
12538        assert_eq!(
12539            http.pinned_cache.build_count(),
12540            0,
12541            "endpoint creation must not build a pinned client"
12542        );
12543        assert_eq!(
12544            https.pinned_cache.build_count(),
12545            0,
12546            "endpoint creation must not build a pinned client"
12547        );
12548    }
12549
12550    #[test]
12551    fn test_component_constructor_builds_one_unpinned_client() {
12552        let baseline = build_client_call_count();
12553
12554        let _http = HttpComponent::new();
12555        assert_eq!(
12556            build_client_call_count() - baseline,
12557            1,
12558            "HttpComponent::new() must build exactly one shared unpinned client"
12559        );
12560
12561        let _https = HttpsComponent::new();
12562        assert_eq!(
12563            build_client_call_count() - baseline,
12564            2,
12565            "HttpsComponent::new() must build exactly one more shared unpinned client"
12566        );
12567    }
12568
12569    #[test]
12570    fn test_component_endpoints_share_unpinned_client() {
12571        let component = HttpComponent::new();
12572        let baseline = build_client_call_count();
12573
12574        let endpoint_ctx = NoOpComponentContext;
12575        for uri in [
12576            "http://localhost:1/a?allowInternal=true",
12577            "http://localhost:1/b?allowInternal=true",
12578        ] {
12579            let _endpoint = component
12580                .create_endpoint(uri, &endpoint_ctx)
12581                .expect("create endpoint");
12582        }
12583
12584        assert_eq!(
12585            build_client_call_count() - baseline,
12586            0,
12587            "create_endpoint must clone the component's shared unpinned client, \
12588             never build a fresh one"
12589        );
12590    }
12591
12592    #[test]
12593    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
12594        let component = HttpComponent::new();
12595        let baseline = build_client_call_count();
12596
12597        let ctx = test_producer_ctx();
12598        let endpoint_ctx = NoOpComponentContext;
12599        for i in 0..3 {
12600            let endpoint = component
12601                .create_endpoint(
12602                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
12603                    &endpoint_ctx,
12604                )
12605                .expect("create endpoint");
12606            let _producer = endpoint
12607                .create_producer(rt(), &ctx)
12608                .expect("create producer");
12609        }
12610
12611        assert_eq!(
12612            build_client_call_count() - baseline,
12613            0,
12614            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
12615             must reuse the component's shared unpinned client and build \
12616             no additional clients"
12617        );
12618    }
12619}