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    /// Public-cleartext transport consent (ADR-0081): when `false` (the
128    /// default), cleartext `http://` requests to PUBLIC targets are
129    /// rejected. Independent of `allow_internal` — internal-network
130    /// consent and cleartext-transport consent are separate decisions, and
131    /// there is deliberately no global cleartext lever.
132    pub allow_cleartext: bool,
133    pub blocked_hosts: Vec<String>,
134    pub max_body_size: usize,
135    pub read_timeout_ms: u64,
136    pub max_response_bytes: usize,
137    pub auth: HttpAuth,
138    pub token_provider: Option<Arc<dyn TokenProvider>>,
139    pub user_agent: Option<String>,
140    pub bridge_endpoint: bool,
141    pub connection_close: bool,
142    pub skip_request_headers: Vec<String>,
143    pub skip_response_headers: Vec<String>,
144    pub follow_redirects: bool,
145    pub max_redirects: usize,
146    /// CamelHttpUri host fence (`allowedUriHosts`): `None` when the option
147    /// is absent (override behavior unchanged); `Some` arms the fail-closed
148    /// fence. Parsed entries only — never re-serialized into the outbound
149    /// query.
150    pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
151}
152
153/// ADR-0051 redact-by-construction, ADR-0076 strictest-wins: query bytes
154/// (authored `raw_query` and programmatic `query_params`) may carry
155/// credentials. The display-surface Debug renders the raw view
156/// blanket-masked (mirroring `redact_url_for_diagnostics`) and programmatic
157/// values masked, mirroring `UriComponents`' sensitive-value masking.
158/// `base_url` routes through the canonical
159/// [`camel_api::redact::redact_url`] (string surgery, no `url::Url`
160/// roundtrip, so authored bytes are never WHATWG-normalized): userinfo is
161/// masked in every authority window, query and fragment bytes are dropped
162/// behind their sentinels, and the result is capped at 256 bytes (rc-yvjp3
163/// converged the former byte-preserving local variant). Wire fidelity is
164/// unaffected.
165impl std::fmt::Debug for HttpEndpointConfig {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("HttpEndpointConfig")
168            .field("base_url", &camel_api::redact::redact_url(&self.base_url))
169            .field("http_method", &self.http_method)
170            .field(
171                "throw_exception_on_failure",
172                &self.throw_exception_on_failure,
173            )
174            .field("ok_status_code_range", &self.ok_status_code_range)
175            .field("response_timeout", &self.response_timeout)
176            .field(
177                "query_params",
178                &self
179                    .query_params
180                    .iter()
181                    .map(|(key, _)| (key, "***"))
182                    .collect::<Vec<_>>(),
183            )
184            .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
185            .field("allow_internal", &self.allow_internal)
186            .field("allow_cleartext", &self.allow_cleartext)
187            .field("blocked_hosts", &self.blocked_hosts)
188            .field("max_body_size", &self.max_body_size)
189            .field("read_timeout_ms", &self.read_timeout_ms)
190            .field("max_response_bytes", &self.max_response_bytes)
191            .field("auth", &self.auth)
192            .field("token_provider", &self.token_provider)
193            .field("user_agent", &self.user_agent)
194            .field("bridge_endpoint", &self.bridge_endpoint)
195            .field("connection_close", &self.connection_close)
196            .field("skip_request_headers", &self.skip_request_headers)
197            .field("skip_response_headers", &self.skip_response_headers)
198            .field("follow_redirects", &self.follow_redirects)
199            .field("max_redirects", &self.max_redirects)
200            .field("allowed_uri_hosts", &self.allowed_uri_hosts)
201            .finish()
202    }
203}
204
205#[derive(Clone, PartialEq)]
206pub enum HttpAuth {
207    None,
208    Basic { username: String, password: String },
209    Bearer { token: String },
210}
211
212impl std::fmt::Debug for HttpAuth {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        match self {
215            HttpAuth::None => f.write_str("None"),
216            HttpAuth::Basic { username, .. } => f
217                .debug_struct("Basic")
218                .field("username", username)
219                .field("password", &"***")
220                .finish(),
221            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
222        }
223    }
224}
225
226/// Whether `key` names a camel-http endpoint option consumed at parse time.
227///
228/// Single metadata-driven owner of OUTBOUND option filtering (ADR-0041):
229/// derived from the `#[uri_param]` metadata behind
230/// [`HttpEndpointConfig::uri_options`], so the raw query filter consumes
231/// exactly the keys the component documents — no duplicated handwritten
232/// key lists. `from_components`'s manual typed parsing stays direct and
233/// unchanged; this predicate never re-wires it.
234fn is_consumed_option(key: &str) -> bool {
235    HttpEndpointConfig::uri_options()
236        .iter()
237        .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
238}
239
240impl UriConfig for HttpEndpointConfig {
241    /// Returns "http" as the primary scheme (also accepts "https")
242    fn scheme() -> &'static str {
243        "http"
244    }
245
246    fn from_uri(uri: &str) -> Result<Self, CamelError> {
247        let parts = parse_uri(uri)?;
248        Self::from_components(parts)
249    }
250
251    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
252        // Validate scheme - accept both http and https
253        if parts.scheme != "http" && parts.scheme != "https" {
254            return Err(CamelError::InvalidUri(format!(
255                "expected scheme 'http' or 'https', got '{}'",
256                parts.scheme
257            )));
258        }
259
260        // Construct base_url from scheme + path
261        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
262        let base_url = format!("{}:{}", parts.scheme, parts.path);
263
264        let http_method = parts.params.get("httpMethod").cloned();
265
266        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
267            Some(v) => parse_bool_param_http(v).map_err(|e| {
268                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
269            })?,
270            None => true,
271        };
272
273        // Parse status code range from "start-end" format (e.g., "200-299")
274        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
275            Some(v) => parse_ok_status_code_range(v)?,
276            None => (200, 299),
277        };
278
279        let response_timeout = match parts.params.get("responseTimeout") {
280            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
281                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
282            })?),
283            None => None,
284        };
285
286        // SSRF protection settings
287        let allow_internal = match parts.params.get("allowInternal") {
288            Some(v) => parse_bool_param_http(v).map_err(|e| {
289                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
290            })?,
291            None => false, // Default: block private IPs
292        };
293
294        // Public-cleartext transport consent (ADR-0081) — per-endpoint
295        // only, deliberately never inherited from the global HttpConfig.
296        let allow_cleartext = match parts.params.get("allowCleartext") {
297            Some(v) => parse_bool_param_http(v).map_err(|e| {
298                CamelError::InvalidUri(format!("invalid value for allowCleartext: {e}"))
299            })?,
300            None => false, // Default: reject cleartext http:// to public targets
301        };
302
303        // Parse comma-separated blocked hosts
304        let blocked_hosts = parts
305            .params
306            .get("blockedHosts")
307            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
308            .unwrap_or_default();
309
310        let max_body_size = match parts.params.get("maxBodySize") {
311            Some(v) => v.parse::<usize>().map_err(|e| {
312                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
313            })?,
314            None => 10 * 1024 * 1024, // Default: 10MB
315        };
316
317        let read_timeout_ms = match parts.params.get("readTimeout") {
318            Some(v) => v.parse::<u64>().map_err(|e| {
319                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
320            })?,
321            None => 30_000, // Default: 30s
322        };
323
324        let max_response_bytes = match parts.params.get("maxResponseBytes") {
325            Some(v) => v.parse::<usize>().map_err(|e| {
326                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
327            })?,
328            None => 10 * 1024 * 1024, // Default: 10MB
329        };
330
331        let auth = parse_auth_from_params(&parts.params)?;
332
333        let user_agent = parts.params.get("userAgent").cloned();
334
335        if parts.params.contains_key("cookieHandling") {
336            return Err(CamelError::InvalidUri(
337                "cookieHandling is not supported".into(),
338            ));
339        }
340
341        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
342            Some(v) => parse_bool_param_http(v).map_err(|e| {
343                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
344            })?,
345            None => false,
346        };
347
348        let connection_close = match parts.params.get("connectionClose") {
349            Some(v) => parse_bool_param_http(v).map_err(|e| {
350                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
351            })?,
352            None => false,
353        };
354
355        let skip_request_headers = parts
356            .params
357            .get("skipRequestHeaders")
358            .map(|v| {
359                v.split(',')
360                    .map(str::trim)
361                    .filter(|s| !s.is_empty())
362                    .map(|s| s.to_ascii_lowercase())
363                    .collect::<Vec<_>>()
364            })
365            .unwrap_or_default();
366
367        let skip_response_headers = parts
368            .params
369            .get("skipResponseHeaders")
370            .map(|v| {
371                v.split(',')
372                    .map(str::trim)
373                    .filter(|s| !s.is_empty())
374                    .map(|s| s.to_ascii_lowercase())
375                    .collect::<Vec<_>>()
376            })
377            .unwrap_or_default();
378
379        let follow_redirects = match parts.params.get("followRedirects") {
380            Some(v) => parse_bool_param_http(v).map_err(|e| {
381                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
382            })?,
383            None => false,
384        };
385
386        let max_redirects = match parts.params.get("maxRedirects") {
387            Some(v) => v.parse::<usize>().map_err(|e| {
388                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
389            })?,
390            None => 10,
391        };
392
393        // CamelHttpUri host fence: parsed eagerly so a malformed or empty
394        // allowlist fails endpoint creation (fail-closed), not resolution.
395        let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
396            Some(v) => Some(parse_allowed_uri_hosts(v)?),
397            None => None,
398        };
399
400        // Authored pairs ride raw_query verbatim (the sole carrier);
401        // query_params is programmatic-only — never auto-populated from
402        // URI leftovers. Consumed option keys are filtered at
403        // serialization time by `is_consumed_option`.
404        let raw_query = parts.raw_query.clone();
405
406        Ok(Self {
407            base_url,
408            http_method,
409            throw_exception_on_failure,
410            ok_status_code_range,
411            response_timeout,
412            query_params: Vec::new(),
413            raw_query,
414            allow_internal,
415            allow_cleartext,
416            blocked_hosts,
417            max_body_size,
418            read_timeout_ms,
419            max_response_bytes,
420            auth,
421            token_provider: None,
422            user_agent,
423            bridge_endpoint,
424            connection_close,
425            skip_request_headers,
426            skip_response_headers,
427            follow_redirects,
428            max_redirects,
429            allowed_uri_hosts,
430        })
431    }
432}
433
434/// Private container for macro-derived `uri_options()` and `metadata()`.
435///
436/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
437/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
438/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
439/// derivation targets this inner type whose fields are all URI-param-compatible.
440#[derive(Debug, Clone, UriConfig)]
441#[allow(dead_code)]
442#[uri_scheme = "http"]
443#[uri_config(
444    skip_impl,
445    metadata(
446        scheme = "http",
447        description = "HTTP client and server component",
448        producer,
449        consumer,
450        streaming
451    ),
452    crate = "camel_component_api"
453)]
454struct HttpEndpointUriConfig {
455    #[allow(dead_code)]
456    _base_url: String,
457
458    #[uri_param(
459        name = "httpMethod",
460        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
461    )]
462    http_method: Option<String>,
463
464    #[uri_param(
465        name = "throwExceptionOnFailure",
466        default = "true",
467        desc = "Throw on non-2xx status"
468    )]
469    throw_exception_on_failure: bool,
470
471    #[uri_param(
472        name = "okStatusCodeRange",
473        default = "200-299",
474        desc = "Success status code range"
475    )]
476    ok_status_code_range: String,
477
478    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
479    response_timeout: Option<u64>,
480
481    #[uri_param(
482        name = "connectTimeout",
483        desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
484    )]
485    connect_timeout: Option<u64>,
486
487    #[uri_param(
488        name = "allowInternal",
489        default = "false",
490        desc = "Allow private/internal network destinations (SSRF)"
491    )]
492    allow_internal: bool,
493
494    #[uri_param(
495        name = "allowCleartext",
496        default = "false",
497        desc = "Allow cleartext http:// to public targets (transport consent; ADR-0081)"
498    )]
499    allow_cleartext: bool,
500
501    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
502    blocked_hosts: Option<String>,
503
504    #[uri_param(
505        name = "maxBodySize",
506        default = "10485760",
507        desc = "Max request/response body bytes"
508    )]
509    max_body_size: u64,
510
511    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
512    read_timeout: Option<u64>,
513
514    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
515    max_response_bytes: Option<u64>,
516
517    #[uri_param(
518        name = "authMethod",
519        kind = "enum:Basic,Bearer",
520        desc = "Authentication method"
521    )]
522    auth_method: Option<String>,
523
524    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
525    auth_username: Option<String>,
526
527    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
528    auth_password: Option<String>,
529
530    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
531    auth_bearer_token: Option<String>,
532
533    #[uri_param(name = "userAgent", desc = "User-Agent header")]
534    user_agent: Option<String>,
535
536    #[uri_param(
537        name = "bridgeEndpoint",
538        default = "false",
539        desc = "Bridge endpoint mode"
540    )]
541    bridge_endpoint: bool,
542
543    #[uri_param(
544        name = "connectionClose",
545        default = "false",
546        desc = "Send Connection: close"
547    )]
548    connection_close: bool,
549
550    #[uri_param(
551        name = "skipRequestHeaders",
552        desc = "Comma-separated request headers to skip"
553    )]
554    skip_request_headers: Option<String>,
555
556    #[uri_param(
557        name = "skipResponseHeaders",
558        desc = "Comma-separated response headers to skip"
559    )]
560    skip_response_headers: Option<String>,
561
562    #[uri_param(
563        name = "followRedirects",
564        default = "false",
565        desc = "Follow HTTP redirects"
566    )]
567    follow_redirects: bool,
568
569    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
570    max_redirects: u64,
571
572    #[uri_param(
573        name = "allowedUriHosts",
574        desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
575    )]
576    allowed_uri_hosts: Option<String>,
577}
578
579impl HttpEndpointConfig {
580    /// Component metadata for the http/https scheme, derived from the
581    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
582    pub fn metadata() -> ComponentMetadata {
583        HttpEndpointUriConfig::metadata()
584    }
585
586    /// URI option definitions, derived from `#[uri_param]` fields.
587    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
588        HttpEndpointUriConfig::uri_options()
589    }
590}
591
592fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
593    let Some(method) = params.get("authMethod") else {
594        return Ok(HttpAuth::None);
595    };
596
597    if method.eq_ignore_ascii_case("none") {
598        return Ok(HttpAuth::None);
599    }
600
601    if method.eq_ignore_ascii_case("basic") {
602        let username = params.get("authUsername").cloned().ok_or_else(|| {
603            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
604        })?;
605        let password = params.get("authPassword").cloned().ok_or_else(|| {
606            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
607        })?;
608        return Ok(HttpAuth::Basic { username, password });
609    }
610
611    if method.eq_ignore_ascii_case("bearer") {
612        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
613            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
614        })?;
615        return Ok(HttpAuth::Bearer { token });
616    }
617
618    Err(CamelError::InvalidUri(format!(
619        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
620    )))
621}
622
623fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
624    match value.to_ascii_lowercase().as_str() {
625        "true" | "1" | "yes" => Ok(true),
626        "false" | "0" | "no" => Ok(false),
627        _ => Err(CamelError::InvalidUri(format!(
628            "invalid boolean value: '{value}'"
629        ))),
630    }
631}
632
633impl HttpEndpointConfig {
634    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
635        let parts = parse_uri(uri)?;
636        let mut endpoint = Self::from_components(parts.clone())?;
637        if endpoint.response_timeout.is_none() {
638            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
639        }
640        if !parts.params.contains_key("allowInternal") {
641            endpoint.allow_internal = config.allow_internal;
642        }
643        if !parts.params.contains_key("blockedHosts") {
644            endpoint.blocked_hosts = config.blocked_hosts.clone();
645        }
646        if !parts.params.contains_key("maxBodySize") {
647            endpoint.max_body_size = config.max_body_size;
648        }
649        if !parts.params.contains_key("readTimeout") {
650            endpoint.read_timeout_ms = config.read_timeout_ms;
651        }
652        if !parts.params.contains_key("maxResponseBytes") {
653            endpoint.max_response_bytes = config.max_response_bytes;
654        }
655        if !parts.params.contains_key("okStatusCodeRange")
656            && let Some(range) = &config.ok_status_code_range
657        {
658            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
659        }
660        if !parts.params.contains_key("followRedirects") {
661            endpoint.follow_redirects = config.follow_redirects;
662        }
663        if !parts.params.contains_key("maxRedirects") {
664            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
665        }
666
667        Ok(endpoint)
668    }
669}
670
671// ---------------------------------------------------------------------------
672// HttpServerConfig
673// ---------------------------------------------------------------------------
674
675/// Configuration for an HTTP server (consumer) endpoint.
676#[derive(Debug, Clone)]
677pub struct HttpServerConfig {
678    /// URI scheme ("http" or "https") parsed from the endpoint URI.
679    pub scheme: String,
680    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
681    pub host: String,
682    /// TCP port to listen on.
683    pub port: u16,
684    /// URL path this consumer handles, e.g. "/orders".
685    pub path: String,
686    /// Maximum request body size in bytes.
687    pub max_request_body: usize,
688    /// Maximum response body size for materializing streams in bytes.
689    pub max_response_body: usize,
690    /// Maximum number of in-flight requests handled concurrently by this server.
691    pub max_inflight_requests: usize,
692    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
693    /// the consumer registers as a method-aware REST endpoint and the
694    /// path is treated as a template (e.g. `/users/{id}` is matched
695    /// against any `/users/<value>`). When `None`, the consumer
696    /// registers in the legacy path-only `api_routes` registry.
697    /// Extracted from the `httpMethod=` URI param at config build time.
698    pub method: Option<String>,
699    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
700    /// `None` for plain HTTP servers.
701    pub tls_config: Option<crate::config::ServerTlsConfig>,
702}
703
704impl UriConfig for HttpServerConfig {
705    /// Returns "http" as the primary scheme (also accepts "https")
706    fn scheme() -> &'static str {
707        "http"
708    }
709
710    fn from_uri(uri: &str) -> Result<Self, CamelError> {
711        let parts = parse_uri(uri)?;
712        Self::from_components(parts)
713    }
714
715    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
716        // Validate scheme - accept both http and https
717        if parts.scheme != "http" && parts.scheme != "https" {
718            return Err(CamelError::InvalidUri(format!(
719                "expected scheme 'http' or 'https', got '{}'",
720                parts.scheme
721            )));
722        }
723
724        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
725        // Strip leading "//"
726        let authority_and_path = parts.path.trim_start_matches('/');
727
728        // Split on the first "/" to separate "host:port" from "/path"
729        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
730            (&authority_and_path[..idx], &authority_and_path[idx..])
731        } else {
732            (authority_and_path, "/")
733        };
734
735        let path = if path_suffix.is_empty() {
736            "/"
737        } else {
738            path_suffix
739        }
740        .to_string();
741
742        // Parse host:port from authority
743        let (host, port) = if let Some(colon) = authority.rfind(':') {
744            let port_str = &authority[colon + 1..];
745            match port_str.parse::<u16>() {
746                Ok(p) => (authority[..colon].to_string(), p),
747                Err(_) => {
748                    return Err(CamelError::InvalidUri(format!(
749                        "invalid port '{}' in authority",
750                        port_str
751                    )));
752                }
753            }
754        } else {
755            // Default port based on scheme: 443 for https, 80 for http
756            let default_port = if parts.scheme == "https" { 443 } else { 80 };
757            (authority.to_string(), default_port)
758        };
759
760        let max_request_body = parts
761            .params
762            .get("maxRequestBody")
763            .and_then(|v| v.parse::<usize>().ok())
764            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
765
766        let max_response_body = parts
767            .params
768            .get("maxResponseBody")
769            .and_then(|v| v.parse::<usize>().ok())
770            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
771
772        let max_inflight_requests = parts
773            .params
774            .get("maxInflightRequests")
775            .and_then(|v| v.parse::<usize>().ok())
776            .unwrap_or(1024);
777
778        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
779        // uppercase method the dispatcher compares against (axum's
780        // `req.method().to_string()` yields "GET"). Without this, a
781        // lower-case `httpMethod` would never match and silently 404.
782        // Review I5.
783        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
784
785        Ok(Self {
786            scheme: parts.scheme,
787            host,
788            port,
789            path,
790            max_request_body,
791            max_response_body,
792            max_inflight_requests,
793            method,
794            tls_config: {
795                let cert = parts.params.get("tlsCert").cloned();
796                let key = parts.params.get("tlsKey").cloned();
797                match (cert, key) {
798                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
799                        cert_path: c,
800                        key_path: k,
801                    }),
802                    (None, None) => None,
803                    _ => None, // partial — enforced in create_consumer, not here
804                }
805            },
806        })
807    }
808}
809
810impl HttpServerConfig {
811    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
812        let parts = parse_uri(uri)?;
813        let mut server = Self::from_components(parts.clone())?;
814        if !parts.params.contains_key("maxRequestBody") {
815            server.max_request_body = config.max_request_body;
816        }
817        if !parts.params.contains_key("maxResponseBody") {
818            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
819            server.max_response_body = config.max_body_size;
820        }
821        Ok(server)
822    }
823}
824
825// ---------------------------------------------------------------------------
826// RequestEnvelope / HttpReply
827// ---------------------------------------------------------------------------
828
829/// Body of the HTTP response: already-materialized bytes or a lazy stream.
830///
831/// **Internal plumbing** — subject to change without notice.
832pub enum HttpReplyBody {
833    Bytes(bytes::Bytes),
834    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
835}
836
837/// An inbound HTTP request sent from the Axum dispatch handler to an
838/// `HttpConsumer` receive loop.
839///
840/// **Internal plumbing** — subject to change without notice.
841pub struct RequestEnvelope {
842    pub method: String,
843    pub path: String,
844    pub query: String,
845    pub headers: http::HeaderMap,
846    pub body: StreamBody,
847    /// Path parameters extracted from a REST template match, e.g.
848    /// `id=42` for a request to `/users/42` matched against
849    /// `/users/{id}`. Empty for non-REST requests or for literal
850    /// template matches. The consumer turns these into
851    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
852    pub path_params: std::collections::HashMap<String, String>,
853    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
854}
855
856/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
857///
858/// **Internal plumbing** — subject to change without notice.
859pub struct HttpReply {
860    pub status: u16,
861    pub headers: Vec<(String, String)>,
862    pub body: HttpReplyBody,
863}
864
865// ---------------------------------------------------------------------------
866// HttpRouteRegistry / ServerRegistry
867// ---------------------------------------------------------------------------
868
869type ServerKey = (String, u16);
870
871/// Handle to a running Axum server on one interface/port.
872struct ServerHandle {
873    registry: HttpRouteRegistry,
874    /// Actual local address of the served listening socket (differs from the
875    /// configured `host:port` when spawning from a staged/pre-bound listener).
876    bound_addr: std::net::SocketAddr,
877    max_request_body: usize,
878    max_response_body: usize,
879    max_inflight_requests: usize,
880    is_tls: bool,
881    tls_cert_path: Option<String>,
882    tls_key_path: Option<String>,
883    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
884    /// dead-server eviction signal in `get_or_spawn`.
885    monitor_task: tokio::task::JoinHandle<()>,
886    /// Abort handle for the Axum server task itself. The JoinHandle is
887    /// consumed by `monitor_axum_task`; this survives on the handle so
888    /// crashed-server tests (and future ops tooling) can deterministically
889    /// kill the shared transport to exercise the death path.
890    /// Test-only today — no production reader yet (rc-szmob).
891    #[allow(dead_code)]
892    server_abort: tokio::task::AbortHandle,
893    // Retained so the reload handler (Task 7) can call reload_from_config()
894    // to hot-swap certs without restarting the server.
895    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
896    tls_source: Option<ServerTlsSource>,
897}
898
899/// Internal registry state: live server entries plus pre-bound listeners
900/// staged for consumption by the next spawn on the same key.
901#[derive(Default)]
902struct RegistryState {
903    entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
904    staged: HashMap<ServerKey, tokio::net::TcpListener>,
905}
906
907/// Process-global registry mapping (host, port) → running Axum server handle.
908pub struct ServerRegistry {
909    inner: Mutex<RegistryState>,
910}
911
912impl ServerRegistry {
913    /// Returns the global singleton.
914    pub fn global() -> &'static Self {
915        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
916        INSTANCE.get_or_init(|| ServerRegistry {
917            inner: Mutex::new(RegistryState::default()),
918        })
919    }
920
921    /// Returns route registry for `port`, spawning new Axum server if
922    /// none is running on that port yet.
923    #[allow(clippy::too_many_arguments)]
924    pub async fn get_or_spawn(
925        &'static self,
926        host: &str,
927        port: u16,
928        max_request_body: usize,
929        max_response_body: usize,
930        max_inflight_requests: usize,
931        runtime: Arc<dyn RuntimeObservability>,
932        route_id: String,
933        tls_config: Option<crate::config::ServerTlsConfig>,
934    ) -> Result<HttpRouteRegistry, CamelError> {
935        self.get_or_spawn_internal(
936            host,
937            port,
938            max_request_body,
939            max_response_body,
940            max_inflight_requests,
941            runtime,
942            route_id,
943            tls_config,
944            None,
945        )
946        .await
947    }
948
949    /// Like [`ServerRegistry::get_or_spawn`], but serves `listener` instead
950    /// of binding `host:port`. The registry key is derived from the listener's
951    /// actual local address, so callers must query that port afterwards. If an
952    /// entry for the key already holds a live server, the same compatibility
953    /// checks as `get_or_spawn` apply and the entry is reused; the passed
954    /// listener is simply dropped.
955    #[allow(clippy::too_many_arguments)]
956    pub async fn get_or_spawn_with_listener(
957        &'static self,
958        listener: tokio::net::TcpListener,
959        max_request_body: usize,
960        max_response_body: usize,
961        max_inflight_requests: usize,
962        runtime: Arc<dyn RuntimeObservability>,
963        route_id: String,
964        tls_config: Option<crate::config::ServerTlsConfig>,
965    ) -> Result<HttpRouteRegistry, CamelError> {
966        let addr = listener
967            .local_addr()
968            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
969        self.get_or_spawn_internal(
970            &addr.ip().to_string(),
971            addr.port(),
972            max_request_body,
973            max_response_body,
974            max_inflight_requests,
975            runtime,
976            route_id,
977            tls_config,
978            Some(listener),
979        )
980        .await
981    }
982
983    /// Stage a pre-bound listener so the next `get_or_spawn` for its
984    /// `(ip, port)` key serves this socket instead of binding a new one.
985    ///
986    /// The staged listener is consumed by exactly one spawn: the exact-key
987    /// `get_or_spawn` takes it under the registry lock, eliminating the bind
988    /// window between a port probe and server startup (itest-bound-ports).
989    pub async fn stage_listener(
990        &'static self,
991        listener: tokio::net::TcpListener,
992    ) -> Result<(), CamelError> {
993        let addr = listener
994            .local_addr()
995            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
996        let host = addr.ip().to_string();
997        use std::collections::hash_map::Entry;
998        let mut guard = self.inner.lock().map_err(|_| {
999            CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1000        })?;
1001        match guard.staged.entry((host.clone(), addr.port())) {
1002            Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
1003                "listener already staged for {host}:{}",
1004                addr.port()
1005            ))),
1006            Entry::Vacant(slot) => {
1007                slot.insert(listener);
1008                Ok(())
1009            }
1010        }
1011    }
1012
1013    /// Returns the bound address of the live server entry for `(host, port)`,
1014    /// if one is initialized.
1015    pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
1016        let guard = self.inner.lock().ok()?;
1017        guard
1018            .entries
1019            .get(&(host.to_string(), port))
1020            .and_then(|cell| cell.get())
1021            .map(|handle| handle.bound_addr)
1022    }
1023
1024    #[allow(clippy::too_many_arguments)]
1025    async fn get_or_spawn_internal(
1026        &'static self,
1027        host: &str,
1028        port: u16,
1029        max_request_body: usize,
1030        max_response_body: usize,
1031        max_inflight_requests: usize,
1032        runtime: Arc<dyn RuntimeObservability>,
1033        route_id: String,
1034        tls_config: Option<crate::config::ServerTlsConfig>,
1035        provided: Option<tokio::net::TcpListener>,
1036    ) -> Result<HttpRouteRegistry, CamelError> {
1037        let host_owned = host.to_string();
1038        let key = (host.to_string(), port);
1039
1040        let cell = {
1041            let mut guard = self.inner.lock().map_err(|_| {
1042                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1043            })?;
1044            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
1045            // The monitor task awaits the server task, so monitor_task.is_finished()
1046            // is a reliable proxy for the server being gone (either crashed or aborted).
1047            if let Some(existing) = guard.entries.get(&key)
1048                && let Some(handle) = existing.get()
1049                && handle.monitor_task.is_finished()
1050            {
1051                // Deregister TLS reload handler so a respawned HTTPS server
1052                // doesn't reload stale cert config from the crashed handler.
1053                if handle.is_tls {
1054                    let scheme = if handle.is_tls { "https" } else { "http" };
1055                    camel_component_api::tls_source::TlsReloadRegistry::global()
1056                        .unregister(scheme, host, port);
1057                }
1058                guard.entries.remove(&key);
1059            }
1060            guard
1061                .entries
1062                .entry(key)
1063                .or_insert_with(|| Arc::new(OnceCell::new()))
1064                .clone()
1065        };
1066
1067        if let Some(existing) = cell.get()
1068            && existing.max_request_body != max_request_body
1069        {
1070            return Err(CamelError::EndpointCreationFailed(format!(
1071                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1072                existing.max_request_body, max_request_body
1073            )));
1074        }
1075
1076        if let Some(existing) = cell.get()
1077            && existing.max_response_body != max_response_body
1078        {
1079            return Err(CamelError::EndpointCreationFailed(format!(
1080                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1081                existing.max_response_body, max_response_body
1082            )));
1083        }
1084
1085        if let Some(existing) = cell.get()
1086            && existing.max_inflight_requests != max_inflight_requests
1087        {
1088            return Err(CamelError::EndpointCreationFailed(format!(
1089                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1090                existing.max_inflight_requests, max_inflight_requests
1091            )));
1092        }
1093
1094        // TLS mode mismatch: plain vs TLS
1095        if let Some(existing) = cell.get()
1096            && existing.is_tls != tls_config.is_some()
1097        {
1098            return Err(CamelError::EndpointCreationFailed(format!(
1099                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1100                existing.is_tls,
1101                tls_config.is_some()
1102            )));
1103        }
1104
1105        // TLS cert/key mismatch: different cert on same TLS port
1106        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1107            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1108                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1109        {
1110            return Err(CamelError::EndpointCreationFailed(format!(
1111                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1112            )));
1113        }
1114
1115        let handle = cell
1116            .get_or_try_init(|| {
1117                let rt = Arc::clone(&runtime);
1118                let rid = route_id.clone();
1119                let key = (host_owned.clone(), port);
1120                async move {
1121                    // Resolve the listener source inside the init body so
1122                    // exactly one caller — the init winner — consumes a
1123                    // staged listener. Resolving it before the cell init let
1124                    // a racing caller strand the staged socket in the
1125                    // loser's hands: the winner then bound the same port and
1126                    // failed with EADDRINUSE. The sync registry lock here is
1127                    // never held across an await. Occupied cells never run
1128                    // this body, so they never touch the staged map.
1129                    let source = match provided {
1130                        Some(listener) => ListenerSource::Staged(listener),
1131                        None => {
1132                            let mut guard = self.inner.lock().map_err(|_| {
1133                                CamelError::EndpointCreationFailed(
1134                                    "ServerRegistry lock poisoned".into(),
1135                                )
1136                            })?;
1137                            match guard.staged.remove(&key) {
1138                                Some(listener) => ListenerSource::Staged(listener),
1139                                // Conflict check before any entry is
1140                                // initialized so the error leaves the staged
1141                                // slot untouched.
1142                                None => {
1143                                    if let Some((staged_host, _)) = guard
1144                                        .staged
1145                                        .keys()
1146                                        .find(|(_, staged_port)| *staged_port == port)
1147                                    {
1148                                        let staged_host = staged_host.clone();
1149                                        return Err(CamelError::EndpointCreationFailed(
1150                                            format!(
1151                                                "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1152                                            ),
1153                                        ));
1154                                    }
1155                                    ListenerSource::Bind
1156                                }
1157                            }
1158                        }
1159                    };
1160                    spawn_entry(
1161                        key,
1162                        source,
1163                        max_request_body,
1164                        max_response_body,
1165                        max_inflight_requests,
1166                        rt,
1167                        rid,
1168                        tls_config,
1169                    )
1170                    .await
1171                    .and_then(|handle| {
1172                        // spawn_entry returns a freshly created Arc (refcount
1173                        // 1), so unwrapping it back into the owned handle for
1174                        // the cell always succeeds here.
1175                        Arc::try_unwrap(handle).map_err(|_| {
1176                            CamelError::EndpointCreationFailed(
1177                                "spawned server handle has dangling clones".into(),
1178                            )
1179                        })
1180                    })
1181                }
1182            })
1183            .await?;
1184
1185        Ok(handle.registry.clone())
1186    }
1187
1188    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
1189    /// the server stays in the registry for potential restart. Path
1190    /// deregistration happens separately in the consumer's cleanup.
1191    pub async fn unregister(&self, host: &str, port: u16) {
1192        debug!(
1193            host = host,
1194            port = port,
1195            "consumer unregistered from HTTP server"
1196        );
1197    }
1198
1199    /// Reset the global registry — **test-only**.
1200    ///
1201    /// Clears all registered server handles so that tests can start from a clean
1202    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1203    /// process-global singleton in production and resetting it would break
1204    /// running servers.
1205    #[cfg(test)]
1206    pub fn reset() {
1207        let instance = Self::global();
1208        let mut guard = instance
1209            .inner
1210            .lock()
1211            .expect("ServerRegistry lock poisoned during test reset");
1212        guard.entries.clear();
1213        guard.staged.clear();
1214    }
1215}
1216
1217/// Where a spawned server's listening socket comes from: a fresh bind on
1218/// `key`, or a listener pre-bound (staged or passed) by the caller.
1219enum ListenerSource {
1220    Bind,
1221    Staged(tokio::net::TcpListener),
1222}
1223
1224/// Create the server handle for a vacant registry entry: serve `key` via a
1225/// freshly bound or caller-provided listener. This is the OnceCell init body
1226/// of `get_or_spawn`, extracted so the legacy and staged entry points share
1227/// one spawn path.
1228#[allow(clippy::too_many_arguments)]
1229async fn spawn_entry(
1230    key: ServerKey,
1231    source: ListenerSource,
1232    max_request_body: usize,
1233    max_response_body: usize,
1234    max_inflight_requests: usize,
1235    runtime: Arc<dyn RuntimeObservability>,
1236    route_id: String,
1237    tls_config: Option<crate::config::ServerTlsConfig>,
1238) -> Result<Arc<ServerHandle>, CamelError> {
1239    let rt = Arc::clone(&runtime);
1240    let rid = route_id.clone();
1241    let (host_owned, port) = key;
1242    let listener = match source {
1243        ListenerSource::Bind => {
1244            let addr = format!("{host_owned}:{port}");
1245            tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1246                CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1247            })?
1248        }
1249        ListenerSource::Staged(listener) => listener,
1250    };
1251    let bound_addr = listener
1252        .local_addr()
1253        .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1254    let server_exited = tokio_util::sync::CancellationToken::new();
1255    let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1256    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1257    // Constructed once in the TLS branch so they can be retained
1258    // on ServerHandle for the reload handler (Task 7).
1259    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1260    let tls_source: Option<ServerTlsSource>;
1261    let server_task = if let Some(ref tls) = tls_config {
1262        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1263        let source = ServerTlsSource {
1264            cert_path: std::path::PathBuf::from(&tls.cert_path),
1265            key_path: std::path::PathBuf::from(&tls.key_path),
1266            client_ca_path: None,
1267        };
1268        // Build the RustlsConfig once — clone() is cheap (Arc
1269        // internally) and shares the ArcSwap the reload handler
1270        // will mutate via reload_from_config().
1271        let rustls_cfg =
1272            axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1273        tls_rustls_cfg = Some(rustls_cfg.clone());
1274        tls_source = Some(source);
1275        // Convert tokio listener to std for axum-server
1276        let std_listener = listener.into_std().map_err(|e| {
1277            CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1278        })?;
1279        tokio::spawn(run_axum_server_tls(
1280            std_listener,
1281            rustls_cfg,
1282            registry.clone(),
1283            max_request_body,
1284            max_response_body,
1285            Arc::clone(&inflight),
1286            Arc::clone(&rt),
1287            rid.clone(),
1288        ))
1289    } else {
1290        tls_rustls_cfg = None;
1291        tls_source = None;
1292        tokio::spawn(run_axum_server(
1293            listener,
1294            registry.clone(),
1295            max_request_body,
1296            max_response_body,
1297            Arc::clone(&inflight),
1298            Arc::clone(&rt),
1299            rid.clone(),
1300        ))
1301    };
1302    let addr_for_monitor = format!("{host_owned}:{port}");
1303    let server_abort = server_task.abort_handle();
1304    let monitor_task = tokio::spawn(monitor_axum_task(
1305        server_task,
1306        addr_for_monitor,
1307        Arc::clone(&rt),
1308        rid,
1309        server_exited,
1310    ));
1311    let handle = ServerHandle {
1312        registry,
1313        bound_addr,
1314        max_request_body,
1315        max_response_body,
1316        max_inflight_requests,
1317        is_tls: tls_config.is_some(),
1318        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1319        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1320        monitor_task,
1321        server_abort,
1322        tls_config: tls_rustls_cfg,
1323        tls_source,
1324    };
1325    // Register reload handler (exactly-once: inside OnceCell init closure).
1326    // Note: HTTP servers are process-lifetime (no release/eviction path),
1327    // so handlers are never unregistered. If eviction is added later,
1328    // add TlsReloadRegistry::global().unregister() there.
1329    if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1330    {
1331        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1332            tls_cfg.clone(),
1333            source.clone(),
1334            host_owned.clone(),
1335            port,
1336        ));
1337        camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1338    }
1339    Ok(Arc::new(handle))
1340}
1341
1342// ---------------------------------------------------------------------------
1343// Axum server
1344// ---------------------------------------------------------------------------
1345
1346use axum::{
1347    Router,
1348    body::Body as AxumBody,
1349    extract::{Request, State},
1350    http::{Response, StatusCode},
1351    response::IntoResponse,
1352};
1353
1354#[derive(Clone)]
1355pub(crate) struct AppState {
1356    registry: HttpRouteRegistry,
1357    max_request_body: usize,
1358    max_response_body: usize,
1359    inflight: Arc<tokio::sync::Semaphore>,
1360}
1361
1362/// Hard wall-clock limit for one inbound request on the consumer side
1363/// (audit 2026-08-31, F2-1). A slow-drip client otherwise holds an
1364/// `inflight` semaphore permit (and its connection) indefinitely, starving
1365/// the consumer into 503s. 30s matches the documented component default
1366/// timeouts. Applies to the whole dispatch; streaming bodies are additionally
1367/// protected by the byte cap in `dispatch_handler`.
1368const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1369
1370async fn run_axum_server(
1371    listener: tokio::net::TcpListener,
1372    registry: HttpRouteRegistry,
1373    max_request_body: usize,
1374    max_response_body: usize,
1375    inflight: Arc<tokio::sync::Semaphore>,
1376    runtime: Arc<dyn RuntimeObservability>,
1377    route_id: String,
1378) {
1379    let state = AppState {
1380        registry,
1381        max_request_body,
1382        max_response_body,
1383        inflight,
1384    };
1385    let app = Router::new()
1386        .fallback(dispatch_handler)
1387        .with_state(state)
1388        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1389            StatusCode::REQUEST_TIMEOUT,
1390            CONSUMER_REQUEST_TIMEOUT,
1391        ));
1392
1393    axum::serve(listener, app).await.unwrap_or_else(|e| {
1394        runtime
1395            .metrics()
1396            .increment_errors(&route_id, "e:http:accept");
1397        // log-policy: outside-contract
1398        tracing::error!(error = %e, "Axum server error");
1399    });
1400}
1401
1402#[allow(clippy::too_many_arguments)]
1403async fn run_axum_server_tls(
1404    listener: std::net::TcpListener,
1405    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1406    registry: HttpRouteRegistry,
1407    max_request_body: usize,
1408    max_response_body: usize,
1409    inflight: Arc<tokio::sync::Semaphore>,
1410    runtime: Arc<dyn RuntimeObservability>,
1411    route_id: String,
1412) {
1413    let state = AppState {
1414        registry,
1415        max_request_body,
1416        max_response_body,
1417        inflight,
1418    };
1419    let app = Router::new()
1420        .fallback(dispatch_handler)
1421        .with_state(state)
1422        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1423            StatusCode::REQUEST_TIMEOUT,
1424            CONSUMER_REQUEST_TIMEOUT,
1425        ));
1426
1427    // RustlsConfig is now constructed once in get_or_spawn and retained on
1428    // ServerHandle so the reload handler can call reload_from_config() on it.
1429
1430    // axum-server 0.8: from_tcp_rustls is fallible (TLS acceptor setup).
1431    let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1432        Ok(server) => server,
1433        Err(e) => {
1434            runtime
1435                .metrics()
1436                .increment_errors(&route_id, "e:http:accept-tls");
1437            // log-policy: outside-contract
1438            tracing::error!(error = %e, "Axum TLS server setup error");
1439            return;
1440        }
1441    };
1442
1443    server
1444        .serve(app.into_make_service())
1445        .await
1446        .unwrap_or_else(|e| {
1447            runtime
1448                .metrics()
1449                .increment_errors(&route_id, "e:http:accept-tls");
1450            // log-policy: outside-contract
1451            tracing::error!(error = %e, "Axum TLS server error");
1452        });
1453}
1454
1455/// Monitors the shared Axum server task of one (host, port).
1456///
1457/// On unexpected exit (panic or abort) it records the structured error
1458/// event and cancels the server's `server_exited` token. Every
1459/// `HttpConsumer` hosted on that server observes the cancellation in its
1460/// `start()` loop and returns `Err`, which camel-core's consumer watcher
1461/// turns into a per-route `CrashNotification` → `FailRoute` → supervision
1462/// backoff restart (ADR-0007). A clean exit (`Ok(())` — process shutdown)
1463/// cancels nothing: route stops own their termination.
1464async fn monitor_axum_task(
1465    handle: tokio::task::JoinHandle<()>,
1466    addr: String,
1467    runtime: Arc<dyn RuntimeObservability>,
1468    route_id: String,
1469    server_exited: tokio_util::sync::CancellationToken,
1470) {
1471    match handle.await {
1472        Ok(()) => {
1473            // Clean exit (process shutdown or normal stop)
1474        }
1475        Err(join_err) => {
1476            runtime
1477                .metrics()
1478                .increment_errors(&route_id, "e:http:server-task-exited");
1479            // log-policy: outside-contract
1480            tracing::error!(
1481                addr = %addr,
1482                error = %join_err,
1483                "Axum server task exited unexpectedly — all routes on this port are now dead"
1484            );
1485            // Fail every hosted route's consumer: each `start()` returns Err
1486            // and camel-core emits one CrashNotification per route (ADR-0007
1487            // parity with per-route transport death).
1488            server_exited.cancel();
1489        }
1490    }
1491}
1492
1493/// Load a rustls ServerConfig from PEM cert/key files.
1494/// Adapted from camel-ws lib.rs load_tls_config.
1495fn load_tls_config(
1496    cert_path: &str,
1497    key_path: &str,
1498) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1499    use std::fs::File;
1500    use std::io::BufReader;
1501
1502    let cert_file = File::open(cert_path)
1503        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1504    let key_file = File::open(key_path)
1505        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1506
1507    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1508        .collect::<Result<Vec<_>, _>>()
1509        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1510
1511    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1512        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1513        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1514
1515    tokio_rustls::rustls::ServerConfig::builder()
1516        .with_no_client_auth()
1517        .with_single_cert(certs, key)
1518        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1519}
1520
1521async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1522    let path = req.uri().path().to_owned();
1523    let method = req.method().to_string();
1524
1525    // Dispatch precedence (spec §7.2 / ADR-0009):
1526    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1527    //   2. Templated API path match (REST, method-aware, by specificity)
1528    //   3. Static mount longest-prefix
1529    //   4. SPA fallback
1530    //
1531    // Legacy exact runs first: it is a cheap HashMap get, and the two
1532    // registries are mutually exclusive per route — a legacy route carries
1533    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1534    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1535    // exact hit can never shadow a REST route that should have matched,
1536    // and running exact-first honours the documented precedence (the prior
1537    // REST-first order let a templated `GET /api/{resource}` steal a
1538    // request meant for an exact `GET /api/users`). Intra-REST method
1539    // disambiguation is handled inside `match_endpoint`, not by this
1540    // ordering. Review C2.
1541    let api_sender = {
1542        let inner = state.registry.inner.read().await;
1543        inner.api_routes.get(&path).cloned()
1544    }; // lock released BEFORE any IO
1545
1546    let (rest_sender, path_params) = if api_sender.is_some() {
1547        // Exact legacy match won — skip the templated scan entirely.
1548        (None, Default::default())
1549    } else {
1550        let inner = state.registry.inner.read().await;
1551        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1552            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1553            rest_match::MatchOutcome::Ambiguous => {
1554                // Ambiguous registration should have been rejected at
1555                // lowering time (rest.rs). Reaching here means two
1556                // equal-specificity templates matched one request —
1557                // surface a loud error rather than a silent 404. Review C3.
1558                // log-policy: handler-owned
1559                tracing::warn!(
1560                    method = %method,
1561                    path = %path,
1562                    "ambiguous REST template match — returning 500"
1563                );
1564                return Response::builder()
1565                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1566                    .body(AxumBody::from("Internal Server Error"))
1567                    .expect("infallible"); // allow-unwrap
1568            }
1569            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1570        }
1571    }; // lock released BEFORE any IO
1572
1573    let sender = api_sender.or(rest_sender);
1574
1575    if let Some(sender) = sender {
1576        let query = req.uri().query().unwrap_or("").to_string();
1577        let headers = req.headers().clone();
1578
1579        // Check Content-Length against limit BEFORE opening the stream
1580        let content_length: Option<u64> = headers
1581            .get(http::header::CONTENT_LENGTH)
1582            .and_then(|v| v.to_str().ok())
1583            .and_then(|s| s.parse().ok());
1584
1585        if let Some(len) = content_length
1586            && len > state.max_request_body as u64
1587        {
1588            return Response::builder()
1589                .status(StatusCode::PAYLOAD_TOO_LARGE)
1590                .body(AxumBody::from("Request body exceeds configured limit"))
1591                .expect("infallible"); // allow-unwrap
1592        }
1593
1594        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1595            Ok(permit) => permit,
1596            Err(_) => {
1597                return Response::builder()
1598                    .status(StatusCode::SERVICE_UNAVAILABLE)
1599                    .body(AxumBody::from("Service Unavailable"))
1600                    .expect("infallible"); // allow-unwrap
1601            }
1602        };
1603
1604        // Build StreamBody from Axum body WITHOUT materializing.
1605        // SECURITY (audit 2026-08-31, F2-1): the Content-Length pre-check above
1606        // cannot see chunked/no-length requests. Wrap the stream with a hard
1607        // byte cap so ANY downstream consumption fails closed once
1608        // max_request_body is exceeded — the cap travels with the body.
1609        let content_type = headers
1610            .get(http::header::CONTENT_TYPE)
1611            .and_then(|v| v.to_str().ok())
1612            .map(|s| s.to_string());
1613
1614        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1615        let max_body = state.max_request_body;
1616        let mut seen: u64 = 0;
1617        let capped_stream =
1618            data_stream
1619                .map_err(|e| CamelError::Io(e.to_string()))
1620                .map(move |chunk| match chunk {
1621                    Ok(bytes) => {
1622                        seen = seen.saturating_add(bytes.len() as u64);
1623                        if seen > max_body as u64 {
1624                            Err(CamelError::ProcessorError(format!(
1625                                "Request body exceeds configured limit of {max_body} bytes"
1626                            )))
1627                        } else {
1628                            Ok(bytes)
1629                        }
1630                    }
1631                    Err(e) => Err(e),
1632                });
1633        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1634
1635        let stream_body = StreamBody {
1636            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1637            metadata: StreamMetadata {
1638                size_hint: content_length,
1639                content_type,
1640                origin: None,
1641            },
1642        };
1643
1644        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1645        let envelope = RequestEnvelope {
1646            method,
1647            path,
1648            query,
1649            headers,
1650            body: stream_body,
1651            path_params,
1652            reply_tx,
1653        };
1654
1655        if sender.send(envelope).await.is_err() {
1656            return Response::builder()
1657                .status(StatusCode::SERVICE_UNAVAILABLE)
1658                .body(AxumBody::from("Consumer unavailable"))
1659                .expect("infallible"); // allow-unwrap
1660        }
1661
1662        match reply_rx.await {
1663            Ok(reply) => {
1664                let reply = match reply.body {
1665                    HttpReplyBody::Bytes(b)
1666                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1667                    {
1668                        HttpReply {
1669                            status: 500,
1670                            headers: vec![],
1671                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1672                                "Response body exceeds configured limit",
1673                            )),
1674                        }
1675                    }
1676                    _ => reply,
1677                };
1678
1679                let status =
1680                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1681                let mut builder = Response::builder().status(status);
1682                for (k, v) in &reply.headers {
1683                    builder = builder.header(k.as_str(), v.as_str());
1684                }
1685                match reply.body {
1686                    HttpReplyBody::Bytes(b) => {
1687                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1688                            Response::builder()
1689                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1690                                .body(AxumBody::from("Invalid response headers from consumer"))
1691                                .expect("infallible") // allow-unwrap
1692                        })
1693                    }
1694                    HttpReplyBody::Stream(stream) => builder
1695                        .body(AxumBody::from_stream(stream))
1696                        .unwrap_or_else(|_| {
1697                            Response::builder()
1698                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1699                                .body(AxumBody::from("Invalid response headers from consumer"))
1700                                .expect("infallible") // allow-unwrap
1701                        }),
1702                }
1703            }
1704            Err(_) => Response::builder()
1705                .status(StatusCode::INTERNAL_SERVER_ERROR)
1706                .body(AxumBody::from("Pipeline error"))
1707                .expect("infallible"), // allow-unwrap
1708        }
1709    } else {
1710        // No API route matched — try static mounts
1711        static_dispatch::dispatch_static(&state, req, &path).await
1712    }
1713}
1714
1715fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1716    len > max
1717}
1718
1719fn title_case_header(name: &str) -> String {
1720    name.split('-')
1721        .map(|part| {
1722            let mut chars = part.chars();
1723            match chars.next() {
1724                None => String::new(),
1725                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1726            }
1727        })
1728        .collect::<Vec<_>>()
1729        .join("-")
1730}
1731
1732// ---------------------------------------------------------------------------
1733// HttpConsumer
1734// ---------------------------------------------------------------------------
1735
1736/// Kernel authentication state captured from a route's [`SecurityContext`]
1737/// (`unify-transport-auth`, Task 2.9).
1738///
1739/// Same construction-order lifecycle as gRPC's `GrpcKernelAuth` (Task 2.1):
1740/// the compiled plan and the provider registry arrive via
1741/// `Consumer::set_security_context` before `start()` accepts requests. A
1742/// context lacking either piece keeps `kernel = None` — a plan without
1743/// providers can never mint a principal (fail-closed, never a silently
1744/// unauthenticated route: the controller's strict-mode dispatch check then
1745/// denies carrier-less Exchanges on non-Public plans).
1746pub(crate) struct HttpKernelAuth {
1747    pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1748    pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1749}
1750
1751impl HttpKernelAuth {
1752    /// Capture the kernel state from a route's security context.
1753    ///
1754    /// `None` unless both the compiled plan and the provider registry are
1755    /// present.
1756    pub(crate) fn from_security_context(
1757        ctx: &camel_component_api::SecurityContext,
1758    ) -> Option<Self> {
1759        Some(Self {
1760            plan: ctx.plan.clone()?,
1761            providers: ctx.providers.clone()?,
1762        })
1763    }
1764}
1765
1766/// Capacity for the per-route RequestEnvelope channel.
1767///
1768/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1769/// permit from before `send()` until its reply, so at most N envelopes can be
1770/// outstanding at any time. A buffer of N therefore can never fill before the
1771/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1772/// and the semaphore stays the single, URI-configurable backpressure point.
1773/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1774/// (rc-3y6j: 64 vs default 1024 permits).
1775///
1776/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1777/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1778/// start panic-free (the empty semaphore still 503s every request).
1779fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1780    max_inflight_requests.max(1)
1781}
1782
1783pub struct HttpConsumer {
1784    config: HttpServerConfig,
1785    /// Runtime observability handle for ADR-0012 metrics and health calls.
1786    runtime: Arc<dyn RuntimeObservability>,
1787    /// Kernel authentication state (plan + providers), set via
1788    /// `set_security_context` before `start()` (Task 2.9). `None` for routes
1789    /// without route-level security (Public under the per-bind gate).
1790    kernel: Option<Arc<HttpKernelAuth>>,
1791}
1792
1793impl HttpConsumer {
1794    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1795        Self {
1796            config,
1797            runtime,
1798            kernel: None,
1799        }
1800    }
1801}
1802
1803#[async_trait::async_trait]
1804impl Consumer for HttpConsumer {
1805    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1806        use camel_component_api::{Body, Exchange, Message};
1807
1808        let registry = ServerRegistry::global()
1809            .get_or_spawn(
1810                &self.config.host,
1811                self.config.port,
1812                self.config.max_request_body,
1813                self.config.max_response_body,
1814                self.config.max_inflight_requests,
1815                self.runtime.clone(),
1816                ctx.route_id().to_string(),
1817                self.config.tls_config.clone(),
1818            )
1819            .await?;
1820
1821        // Create channel for this path and register it. Capacity matches the
1822        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1823        // the channel can never become a second backpressure point.
1824        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1825            envelope_channel_capacity(self.config.max_inflight_requests),
1826        );
1827        // When the from-URI carries `httpMethod=...` (REST-lowered
1828        // route), register the consumer as a method-aware REST endpoint
1829        // so the dispatcher can route by (method, path template).
1830        // Otherwise fall back to the legacy path-only api_routes
1831        // registry. The two registries never overlap for the same
1832        // route: each consumer registers in exactly one of them.
1833        if let Some(method) = self.config.method.clone() {
1834            let segments = rest_match::parse_path_template(&self.config.path);
1835            registry
1836                .register_rest_endpoint(method, segments, env_tx)
1837                .await;
1838        } else {
1839            registry
1840                .register_api_route(self.config.path.clone(), env_tx)
1841                .await;
1842        }
1843
1844        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1845        // (inside get_or_spawn above), (2) the axum server task was spawned,
1846        // and (3) this route's path/REST endpoint was registered. At this
1847        // point the listener is genuinely accepting connections and any
1848        // request to this route will be dispatched (not 404'd). The runtime
1849        // uses this signal to publish RouteStarted and to release
1850        // ctx.start() so external benchmarks can emit a reliable
1851        // listener-bound marker.
1852        ctx.mark_ready();
1853
1854        let path = self.config.path.clone();
1855        let registry_for_cleanup = registry.clone();
1856        let server_exited = registry.server_exited.clone();
1857        let cancel_token = ctx.cancel_token();
1858        let kernel = self.kernel.clone();
1859        // Set when the loop exits because the shared server died. The
1860        // post-loop cleanup still runs, then `start()` returns Err so
1861        // camel-core's consumer watcher emits a CrashNotification for THIS
1862        // route and supervision backoff engages (ADR-0007).
1863        let mut server_died = false;
1864        loop {
1865            tokio::select! {
1866                _ = ctx.cancelled() => {
1867                    break;
1868                }
1869                _ = server_exited.cancelled() => {
1870                    // Shared transport death: this route's consumer cannot
1871                    // continue. Fail (do NOT hang in Running) — parity with
1872                    // per-route transport death, which also surfaces as a
1873                    // consumer-task error.
1874                    server_died = true;
1875                    break;
1876                }
1877                envelope = env_rx.recv() => {
1878                    let Some(envelope) = envelope else { break; };
1879
1880                    // Build Exchange from HTTP request
1881                    let mut msg = Message::default();
1882
1883                    // Set standard Camel HTTP headers
1884                    msg.set_header("CamelHttpMethod",
1885                        serde_json::Value::String(envelope.method.clone()));
1886                    msg.set_header("CamelHttpPath",
1887                        serde_json::Value::String(envelope.path.clone()));
1888                    msg.set_header("CamelHttpQuery",
1889                        serde_json::Value::String(envelope.query.clone()));
1890
1891                    // Set path-parameter headers from REST template
1892                    // match. Expert guidance E2: the consumer is
1893                    // responsible for translating the dispatcher's
1894                    // matched params into `CamelHttpPath_<param>`
1895                    // headers on the Exchange, matching the convention
1896                    // used by Camel HTTP for templated routes.
1897                    for (param_name, param_value) in &envelope.path_params {
1898                        msg.set_header(
1899                            format!("CamelHttpPath_{param_name}"),
1900                            serde_json::Value::String(param_value.clone()),
1901                        );
1902                    }
1903
1904                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1905                    for (k, v) in &envelope.headers {
1906                        if let Ok(val_str) = v.to_str() {
1907                            msg.set_header(
1908                                title_case_header(k.as_str()),
1909                                serde_json::Value::String(val_str.to_string()),
1910                            );
1911                        }
1912                    }
1913
1914                    // Body: always arrives as Body::Stream (native streaming)
1915                    // Routes can call into_bytes() if they need to materialize
1916                    msg.body = Body::Stream(envelope.body);
1917
1918                    #[allow(unused_mut)]
1919                    let mut exchange = Exchange::new(msg);
1920
1921                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1922                    #[cfg(feature = "otel")]
1923                    {
1924                        let headers: HashMap<String, String> = envelope
1925                            .headers
1926                            .iter()
1927                            .filter_map(|(k, v)| {
1928                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1929                            })
1930                            .collect();
1931                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1932                    }
1933
1934                    let reply_tx = envelope.reply_tx;
1935                    let sender = ctx.sender().clone();
1936                    let path_clone = path.clone();
1937                    let cancel = cancel_token.clone();
1938                    // Task 2.9 boundary-auth inputs: the raw header map and
1939                    // the request URI (path + query) feed kernel credential
1940                    // extraction inside the per-request task.
1941                    let auth_headers = envelope.headers.clone();
1942                    let auth_uri: http::Uri = {
1943                        let full = if envelope.query.is_empty() {
1944                            envelope.path.clone()
1945                        } else {
1946                            format!("{}?{}", envelope.path, envelope.query)
1947                        };
1948                        // A malformed path cannot become a valid `Uri`; the
1949                        // empty default then carries no credentials, so
1950                        // extraction finds nothing and authn fails closed.
1951                        full.parse().unwrap_or_default()
1952                    };
1953                    let kernel = kernel.clone();
1954
1955                    // Spawn a task to handle this request concurrently
1956                    //
1957                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1958                    // true concurrent request processing. This change was introduced as part of the
1959                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1960                    //
1961                    // Rationale:
1962                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1963                    //    the consumer's main loop until the pipeline processing completes
1964                    // 2. This blocking would prevent multiple HTTP requests from being processed
1965                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1966                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1967                    //    defeating the purpose of pipeline-side concurrency
1968                    // 4. By spawning a task per request, we allow the consumer loop to continue
1969                    //    accepting new requests while existing ones are processed in the pipeline
1970                    //
1971                    // This approach effectively decouples request acceptance from pipeline processing,
1972                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1973                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1974                    tokio::spawn(async move {
1975                        // Check for cancellation before sending to pipeline.
1976                        // Returns 503 (Service Unavailable) instead of letting the request
1977                        // enter a shutting-down pipeline. This is a behavioral change from
1978                        // the pre-concurrency implementation where cancellation during
1979                        // processing would result in a 500 (Internal Server Error).
1980                        // 503 is more semantically correct: the server is temporarily
1981                        // unable to handle the request due to shutdown.
1982                        if cancel.is_cancelled() {
1983                            let _ = reply_tx.send(HttpReply {
1984                                status: 503,
1985                                headers: vec![],
1986                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1987                            });
1988                            return;
1989                        }
1990
1991                        // ADR-0061 Task 2.9: kernel authentication at the
1992                        // request boundary. A `Public` plan passes through
1993                        // with no extraction; any other mode extracts per
1994                        // the plan's sources, authenticates through the
1995                        // kernel, and installs the typed carrier BEFORE the
1996                        // pipeline runs. A denial renders in the HTTP idiom
1997                        // (401 via `pipeline_error_to_reply`) and the route
1998                        // body never sees the request.
1999                        if let Some(kernel) = kernel.as_ref()
2000                            && !matches!(
2001                                kernel.plan.access_mode,
2002                                camel_api::security_policy::AccessMode::Public
2003                            )
2004                        {
2005                            let principal = match camel_auth::extract_token_multi(
2006                                &auth_headers,
2007                                &auth_uri,
2008                                &kernel.plan.credential_sources,
2009                            ) {
2010                                Some(extracted) => {
2011                                    match camel_auth::kernel_authenticate(
2012                                        &kernel.plan,
2013                                        &kernel.providers,
2014                                        &extracted,
2015                                    )
2016                                    .await
2017                                    {
2018                                        Ok(principal) => principal,
2019                                        Err(e) => {
2020                                            // log-policy: handler-owned
2021                                            tracing::warn!(
2022                                                path = %path_clone,
2023                                                error = %e,
2024                                                "HTTP request authentication failed"
2025                                            );
2026                                            let _ = reply_tx.send(pipeline_error_to_reply(
2027                                                e,
2028                                                &path_clone,
2029                                            ));
2030                                            return;
2031                                        }
2032                                    }
2033                                }
2034                                None => {
2035                                    // log-policy: handler-owned
2036                                    tracing::warn!(
2037                                        path = %path_clone,
2038                                        "HTTP request rejected: no credential found in any source"
2039                                    );
2040                                    let _ = reply_tx.send(pipeline_error_to_reply(
2041                                        CamelError::Unauthenticated(
2042                                            "no credential found in any source".to_string(),
2043                                        ),
2044                                        &path_clone,
2045                                    ));
2046                                    return;
2047                                }
2048                            };
2049                            camel_auth::install_carrier(&mut exchange, &principal);
2050                        }
2051
2052                        // Send through pipeline and await result
2053                        let (tx, rx) = tokio::sync::oneshot::channel();
2054                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
2055                            exchange,
2056                            reply_tx: Some(tx),
2057                        };
2058
2059                        let result = match sender.send(envelope).await {
2060                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2061                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2062                        }
2063                        .and_then(|r| r);
2064
2065                        let reply = match result {
2066                            Ok(out) => {
2067                                let status = out
2068                                    .input
2069                                    .header("CamelHttpResponseCode")
2070                                    .and_then(|v| {
2071                                        let raw = v.as_u64()
2072                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2073                                        let code = raw as u16;
2074                                        (100..1000).contains(&code).then_some(code)
2075                                    })
2076                                    .unwrap_or(200);
2077
2078                                let user_content_type = out
2079                                    .input
2080                                    .header("Content-Type")
2081                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2082
2083                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2084                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2085                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2086                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2087                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2088                                        v.to_string().into_bytes(),
2089                                    )), Some("application/json".to_string())),
2090                                    Body::Stream(s) => {
2091                                        let ct = s.metadata.content_type.clone();
2092                                        match s.stream.lock().await.take() {
2093                                            Some(stream) => (
2094                                                HttpReplyBody::Stream(stream),
2095                                                ct,
2096                                            ),
2097                                            None => {
2098                                                // log-policy: system-broken
2099                                                tracing::error!(
2100                                                    "Body::Stream already consumed before HTTP reply — returning 500"
2101                                                );
2102                                                let error_reply = HttpReply {
2103                                                    status: 500,
2104                                                    headers: vec![],
2105                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2106                                                };
2107                                                if reply_tx.send(error_reply).is_err() {
2108                                                    debug!("reply_tx dropped before error reply could be sent");
2109                                                }
2110                                                return;
2111                                            }
2112                                        }
2113                                    }
2114                                    // Empty and future variants produce an empty reply body.
2115                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2116                                };
2117
2118                                let resp_headers = select_response_headers(
2119                                    &out.input.headers,
2120                                    user_content_type,
2121                                    inferred_content_type,
2122                                );
2123
2124                                HttpReply {
2125                                    status,
2126                                    headers: resp_headers,
2127                                    body: reply_body,
2128                                }
2129                            }
2130                            Err(e) => {
2131                                pipeline_error_to_reply(e, &path_clone)
2132                            }
2133                        };
2134
2135                        // Reply to Axum handler (ignore error if client disconnected)
2136                        let _ = reply_tx.send(reply);
2137                    });
2138                }
2139            }
2140        }
2141
2142        // Deregister this consumer. Mirror the registration choice:
2143        // REST-registered consumers remove their (method, path) endpoint
2144        // WITHOUT touching sibling verbs on the same template (review C1);
2145        // legacy consumers clean up api_routes.
2146        if let Some(method) = &self.config.method {
2147            registry_for_cleanup
2148                .unregister_rest_endpoint(method, &path)
2149                .await;
2150        } else {
2151            registry_for_cleanup.unregister_api_route(&path).await;
2152        }
2153
2154        // Leave the shared-server entry: `unregister` is a no-op today (no
2155        // refcount exists — stale D-L10 wording removed, rc-szmob review).
2156        // Dead servers are evicted lazily by `get_or_spawn_internal`, which
2157        // checks `monitor_task.is_finished()` and rebinds on the next spawn
2158        // (e.g. a supervision restart after this consumer's Err).
2159        ServerRegistry::global()
2160            .unregister(&self.config.host, self.config.port)
2161            .await;
2162
2163        if server_died {
2164            // log-policy: system-broken
2165            tracing::error!(
2166                host = %self.config.host,
2167                port = self.config.port,
2168                path = %path,
2169                "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2170            );
2171            return Err(CamelError::RouteError(format!(
2172                "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2173                self.config.host, self.config.port
2174            )));
2175        }
2176
2177        Ok(())
2178    }
2179
2180    async fn stop(&mut self) -> Result<(), CamelError> {
2181        Ok(())
2182    }
2183
2184    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2185        camel_component_api::ConcurrencyModel::Concurrent { max: None }
2186    }
2187
2188    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
2189    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
2190    // Opting into Explicit startup makes ctx.start() await the bind+register
2191    // completion so listeners fail fast on bind errors (previously a silent
2192    // background log) and external markers can reliably detect listener-bound
2193    // state.
2194    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2195        camel_component_api::ConsumerStartupMode::Explicit
2196    }
2197
2198    // Task 2.9: capture the kernel state (compiled plan + provider registry)
2199    // wired by the route controller before start(). See `HttpKernelAuth`.
2200    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2201        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2202    }
2203}
2204
2205// ---------------------------------------------------------------------------
2206// HttpComponent / HttpsComponent
2207// ---------------------------------------------------------------------------
2208
2209pub struct HttpComponent {
2210    config: HttpConfig,
2211    pinned_cache: std::sync::Arc<PinnedClientCache>,
2212    client: reqwest::Client,
2213    /// Set at construction when `tls.strict` is on and the configured
2214    /// material fails to load; surfaced as an endpoint-creation failure
2215    /// (rc-ayrwk).
2216    strict_tls_error: Option<CamelError>,
2217}
2218
2219#[cfg(test)]
2220thread_local! {
2221    static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2222}
2223
2224pub(crate) fn build_client(
2225    config: &HttpConfig,
2226    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2227) -> reqwest::Client {
2228    #[cfg(test)]
2229    BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2230
2231    let mut builder = reqwest::Client::builder()
2232        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
2233        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2234        .pool_max_idle_per_host(config.pool_max_idle_per_host)
2235        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2236
2237    // Redirects are always handled manually in the producer's send path
2238    // so that each hop can be SSRF-validated. reqwest's built-in redirect
2239    // policy is sync and cannot perform async DNS resolution or SSRF checks.
2240    builder = builder.redirect(reqwest::redirect::Policy::none());
2241
2242    if let Some((host, addrs)) = resolve_override {
2243        builder = builder.resolve_to_addrs(host, addrs);
2244    }
2245
2246    if let Some(tls) = &config.tls
2247        && tls.enabled
2248    {
2249        if tls.insecure || !tls.verify_peer {
2250            // log-policy: handler-owned
2251            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2252            builder = builder.danger_accept_invalid_certs(true);
2253        }
2254
2255        if let Some(ca_path) = &tls.ca_cert_path {
2256            // Audit 2026-08-31, F2-7: a configured CA that fails to load must
2257            // never degrade silently to system roots. Loud warn (config error
2258            // class: fail-fast would break existing deployments relying on the
2259            // fallback; the warning is the operator signal).
2260            match std::fs::read(ca_path) {
2261                Ok(ca_bytes) => {
2262                    // Under the rustls backend `Certificate::from_pem`
2263                    // never fails (it defers parsing), so the parse-error
2264                    // warn below is effectively dead and a file with zero
2265                    // parseable PEM CERTIFICATE sections would silently
2266                    // contribute no roots. Warn on that case explicitly
2267                    // (e_glm stage-4 finding 1).
2268                    let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2269                        .filter(|r| r.is_ok())
2270                        .count();
2271                    if pem_sections == 0 {
2272                        // log-policy: handler-owned
2273                        tracing::warn!(
2274                            "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2275                        );
2276                    }
2277                    match reqwest::Certificate::from_pem(&ca_bytes)
2278                        .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2279                    {
2280                        Ok(ca_cert) => {
2281                            builder = builder.add_root_certificate(ca_cert);
2282                        }
2283                        Err(e) => {
2284                            // log-policy: handler-owned
2285                            tracing::warn!(
2286                                error = %e,
2287                                "configured CA certificate failed to parse — falling back to system roots"
2288                            );
2289                        }
2290                    }
2291                }
2292                Err(e) => {
2293                    // log-policy: handler-owned
2294                    tracing::warn!(
2295                        error = %e,
2296                        "configured CA certificate file unreadable — falling back to system roots"
2297                    );
2298                }
2299            }
2300        }
2301
2302        // mTLS identity: BOTH files must load and parse, or the identity is
2303        // absent. A partial failure previously meant silently downgrading to
2304        // non-mTLS — now loud.
2305        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2306            match (std::fs::read(cert_path), std::fs::read(key_path)) {
2307                (Ok(cert_bytes), Ok(key_bytes)) => {
2308                    let mut identity_pem = cert_bytes;
2309                    identity_pem.extend_from_slice(&key_bytes);
2310                    match reqwest::Identity::from_pem(&identity_pem) {
2311                        Ok(identity) => {
2312                            builder = builder.identity(identity);
2313                        }
2314                        Err(e) => {
2315                            // log-policy: handler-owned
2316                            tracing::warn!(
2317                                error = %e,
2318                                "configured mTLS identity failed to parse — client certificate NOT used"
2319                            );
2320                        }
2321                    }
2322                }
2323                (cert_r, key_r) => {
2324                    // log-policy: handler-owned
2325                    tracing::warn!(
2326                        cert_ok = cert_r.is_ok(),
2327                        key_ok = key_r.is_ok(),
2328                        "configured mTLS cert/key file unreadable — client certificate NOT used"
2329                    );
2330                }
2331            }
2332        }
2333    }
2334
2335    builder
2336        .build()
2337        .expect("reqwest::Client::build() with valid config should not fail") // allow-unwrap
2338}
2339
2340/// Eagerly load and parse the configured TLS material when strict mode is
2341/// on (audit 2026-08-31 R3 / rc-ayrwk). Returns the first failure as an
2342/// `EndpointCreationFailed` error; `None` when the material loads, or when
2343/// strict mode is off (the permissive F2-7 fallback with its loud warns
2344/// stays the default for back-compat).
2345///
2346/// Mirrors the four load sites in [`build_client`]: CA unreadable, CA
2347/// unparseable, mTLS cert/key unreadable, mTLS identity unparseable.
2348fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2349    let tls = config.tls.as_ref()?;
2350    if !tls.enabled || !tls.strict {
2351        return None;
2352    }
2353    if let Some(ca_path) = &tls.ca_cert_path {
2354        match std::fs::read(ca_path) {
2355            Ok(ca_bytes) => {
2356                // `reqwest::Certificate::{from_pem,from_der}` defer parsing
2357                // under rustls, and unparseable entries are silently
2358                // skipped at client build — so strict validation must be
2359                // eager AND match what the backend actually enforces:
2360                // a PEM bundle with at least one parseable CERTIFICATE
2361                // section (rustls-pemfile). A raw-DER file is rejected
2362                // outright: the rustls backend never honors lone-DER
2363                // bytes here (they wrap unvalidated and are dropped at
2364                // root-store insertion), so certifying one under strict
2365                // would certify an unenforced config (e_glm stage-4
2366                // finding 1). Operators convert DER bundles to PEM.
2367                let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2368                    .filter(|r| r.is_ok())
2369                    .count();
2370                if pem_sections == 0 {
2371                    return Some(CamelError::EndpointCreationFailed(format!(
2372                        "tls.strict: configured CA certificate '{ca_path}' has no \
2373                         parseable PEM CERTIFICATE section (DER bundles are not \
2374                         enforced by the TLS backend — convert to PEM)"
2375                    )));
2376                }
2377            }
2378            Err(e) => {
2379                return Some(CamelError::EndpointCreationFailed(format!(
2380                    "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
2381                )));
2382            }
2383        }
2384    }
2385    // A half-configured mTLS pair (cert XOR key) previously degraded
2386    // silently to non-mTLS even under strict — reject it (e_glm stage-4
2387    // finding 2).
2388    if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
2389        return Some(CamelError::EndpointCreationFailed(
2390            "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
2391        ));
2392    }
2393    if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2394        match (std::fs::read(cert_path), std::fs::read(key_path)) {
2395            (Ok(mut cert_bytes), Ok(key_bytes)) => {
2396                cert_bytes.extend_from_slice(&key_bytes);
2397                if reqwest::Identity::from_pem(&cert_bytes).is_err() {
2398                    return Some(CamelError::EndpointCreationFailed(
2399                        "tls.strict: configured mTLS identity failed to parse".to_string(),
2400                    ));
2401                }
2402            }
2403            _ => {
2404                return Some(CamelError::EndpointCreationFailed(
2405                    "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
2406                ));
2407            }
2408        }
2409    }
2410    None
2411}
2412
2413#[cfg(test)]
2414pub(crate) fn build_client_call_count() -> u64 {
2415    BUILD_CLIENT_CALLS.with(|c| c.get())
2416}
2417
2418impl HttpComponent {
2419    pub fn new() -> Self {
2420        let config = HttpConfig::default();
2421        let strict_err = strict_tls_error(&config);
2422        Self {
2423            client: build_client(&config, None),
2424            config,
2425            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2426                PINNED_CLIENT_TTL,
2427                PINNED_CLIENT_MAX_ENTRIES,
2428            )),
2429            strict_tls_error: strict_err,
2430        }
2431    }
2432
2433    pub fn with_config(config: HttpConfig) -> Self {
2434        let strict_err = strict_tls_error(&config);
2435        Self {
2436            client: build_client(&config, None),
2437            config,
2438            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2439                PINNED_CLIENT_TTL,
2440                PINNED_CLIENT_MAX_ENTRIES,
2441            )),
2442            strict_tls_error: strict_err,
2443        }
2444    }
2445
2446    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2447        match config {
2448            Some(cfg) => Self::with_config(cfg),
2449            None => Self::new(),
2450        }
2451    }
2452}
2453
2454impl Default for HttpComponent {
2455    fn default() -> Self {
2456        Self::new()
2457    }
2458}
2459
2460impl Component for HttpComponent {
2461    fn scheme(&self) -> &str {
2462        "http"
2463    }
2464
2465    fn metadata(&self) -> ComponentMetadata {
2466        HttpEndpointConfig::metadata()
2467    }
2468
2469    fn create_endpoint(
2470        &self,
2471        uri: &str,
2472        ctx: &dyn camel_component_api::ComponentContext,
2473    ) -> Result<Box<dyn Endpoint>, CamelError> {
2474        if let Some(err) = &self.strict_tls_error {
2475            return Err(err.clone());
2476        }
2477        self.config.validate()?;
2478        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2479        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2480        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2481            server_config.host.clone(),
2482            server_config.port,
2483        )));
2484        self.pinned_cache
2485            .wire(HttpComponentKind::Http, ctx.metrics());
2486        Ok(Box::new(HttpEndpoint {
2487            uri: uri.to_string(),
2488            config,
2489            server_config,
2490            client: self.client.clone(),
2491            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2492            http_config: self.config.clone(),
2493        }))
2494    }
2495}
2496
2497pub struct HttpsComponent {
2498    config: HttpConfig,
2499    pinned_cache: std::sync::Arc<PinnedClientCache>,
2500    client: reqwest::Client,
2501    /// Set at construction when `tls.strict` is on and the configured
2502    /// material fails to load; surfaced as an endpoint-creation failure
2503    /// (rc-ayrwk).
2504    strict_tls_error: Option<CamelError>,
2505}
2506
2507impl HttpsComponent {
2508    pub fn new() -> Self {
2509        let config = HttpConfig::default();
2510        let strict_err = strict_tls_error(&config);
2511        Self {
2512            client: build_client(&config, None),
2513            config,
2514            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2515                PINNED_CLIENT_TTL,
2516                PINNED_CLIENT_MAX_ENTRIES,
2517            )),
2518            strict_tls_error: strict_err,
2519        }
2520    }
2521
2522    pub fn with_config(config: HttpConfig) -> Self {
2523        let strict_err = strict_tls_error(&config);
2524        Self {
2525            client: build_client(&config, None),
2526            config,
2527            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2528                PINNED_CLIENT_TTL,
2529                PINNED_CLIENT_MAX_ENTRIES,
2530            )),
2531            strict_tls_error: strict_err,
2532        }
2533    }
2534
2535    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2536        match config {
2537            Some(cfg) => Self::with_config(cfg),
2538            None => Self::new(),
2539        }
2540    }
2541}
2542
2543impl Default for HttpsComponent {
2544    fn default() -> Self {
2545        Self::new()
2546    }
2547}
2548
2549impl Component for HttpsComponent {
2550    fn scheme(&self) -> &str {
2551        "https"
2552    }
2553
2554    fn metadata(&self) -> ComponentMetadata {
2555        // HTTPS shares the same URI option surface and capabilities as HTTP.
2556        // Only the scheme and description differ.
2557        let mut meta = HttpEndpointConfig::metadata();
2558        meta.scheme = "https".to_string();
2559        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2560        meta
2561    }
2562
2563    fn create_endpoint(
2564        &self,
2565        uri: &str,
2566        ctx: &dyn camel_component_api::ComponentContext,
2567    ) -> Result<Box<dyn Endpoint>, CamelError> {
2568        if let Some(err) = &self.strict_tls_error {
2569            return Err(err.clone());
2570        }
2571        self.config.validate()?;
2572        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2573        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2574        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2575            server_config.host.clone(),
2576            server_config.port,
2577        )));
2578        self.pinned_cache
2579            .wire(HttpComponentKind::Https, ctx.metrics());
2580        Ok(Box::new(HttpEndpoint {
2581            uri: uri.to_string(),
2582            config,
2583            server_config,
2584            client: self.client.clone(),
2585            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2586            http_config: self.config.clone(),
2587        }))
2588    }
2589}
2590
2591// ---------------------------------------------------------------------------
2592// HttpEndpoint
2593// ---------------------------------------------------------------------------
2594
2595struct HttpEndpoint {
2596    uri: String,
2597    config: HttpEndpointConfig,
2598    server_config: HttpServerConfig,
2599    client: reqwest::Client,
2600    pinned_cache: std::sync::Arc<PinnedClientCache>,
2601    http_config: HttpConfig,
2602}
2603
2604impl Endpoint for HttpEndpoint {
2605    fn uri(&self) -> &str {
2606        &self.uri
2607    }
2608
2609    fn create_consumer(
2610        &self,
2611        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2612    ) -> Result<Box<dyn Consumer>, CamelError> {
2613        // Scheme/config consistency check (spec §5) — uses parsed scheme
2614        // from HttpServerConfig, not a fragile port-443 heuristic.
2615        let scheme_is_https = self.server_config.scheme == "https";
2616        let has_tls = self.server_config.tls_config.is_some();
2617
2618        if scheme_is_https && !has_tls {
2619            return Err(CamelError::EndpointCreationFailed(
2620                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2621            ));
2622        }
2623        if !scheme_is_https && has_tls {
2624            return Err(CamelError::EndpointCreationFailed(
2625                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2626            ));
2627        }
2628        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2629    }
2630
2631    fn create_producer(
2632        &self,
2633        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2634        _ctx: &ProducerContext,
2635    ) -> Result<BoxProcessor, CamelError> {
2636        let producer = HttpProducer {
2637            config: Arc::new(self.config.clone()),
2638            client: self.client.clone(),
2639            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2640            http_config: Arc::new(self.http_config.clone()),
2641            runtime: rt,
2642        };
2643        if let Some(ref provider) = self.config.token_provider {
2644            let layer = BearerTokenLayer::new(Arc::clone(provider));
2645            Ok(BoxProcessor::new(layer.layer(producer)))
2646        } else {
2647            Ok(BoxProcessor::new(producer))
2648        }
2649    }
2650}
2651
2652// ---------------------------------------------------------------------------
2653// HttpProducer
2654// ---------------------------------------------------------------------------
2655
2656#[derive(Clone)]
2657struct HttpProducer {
2658    config: Arc<HttpEndpointConfig>,
2659    client: reqwest::Client,
2660    pinned_cache: std::sync::Arc<PinnedClientCache>,
2661    http_config: Arc<HttpConfig>,
2662    /// Runtime observability handle powering the component-ops facade at
2663    /// the request boundary (`("http","request")`, dashboard-observability
2664    /// Task 4.3). The retained `e:http:accept*` labels are consumer-side
2665    /// (server accept loop) — different boundary, no collision with
2666    /// `e:http:request`.
2667    runtime: Arc<dyn RuntimeObservability>,
2668}
2669
2670impl HttpProducer {
2671    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2672        if let Some(ref method) = config.http_method {
2673            return method.to_uppercase();
2674        }
2675        if let Some(method) = exchange
2676            .input
2677            .header("CamelHttpMethod")
2678            .and_then(|v| v.as_str())
2679        {
2680            return method.to_uppercase();
2681        }
2682        if !exchange.input.body.is_empty() {
2683            return "POST".to_string();
2684        }
2685        "GET".to_string()
2686    }
2687
2688    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2689        // bridgeEndpoint=true: exchange URL headers (CamelHttpUri,
2690        // CamelHttpPath, CamelHttpQuery) are ignored per Apache Camel
2691        // bridging semantics. The endpoint's own query still rides: the
2692        // same raw-preserving, consumed-option-filtered query as the
2693        // non-bridge path (bridgeEndpoint itself is a consumed option),
2694        // with programmatic query_params appending absent keys after the
2695        // raw base. This check MUST come before the CamelHttpUri override
2696        // so bridging wins over that header.
2697        if config.bridge_endpoint {
2698            let Some(query) = resolve_endpoint_query(config)? else {
2699                return Ok(config.base_url.clone());
2700            };
2701            // Validation only (rc-ph7z2): a malformed base still errors
2702            // through the redacted-diagnostic path below. The parsed value
2703            // is NEVER re-emitted — assembly is verbatim string
2704            // composition, authored bytes end-to-end: no WHATWG
2705            // normalization (dot-segment collapse, default-port strip,
2706            // scheme/host lowercasing), matching every other arm (Papal
2707            // Direction A).
2708            let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2709                CamelError::ProcessorError(format!(
2710                    "invalid base URL '{}': {e}",
2711                    redact_url_for_diagnostics(&config.base_url)
2712                ))
2713            })?;
2714            let mut url = config.base_url.clone();
2715            url.push('?');
2716            url.push_str(&query);
2717            return Ok(url);
2718        }
2719
2720        if let Some(uri) = exchange
2721            .input
2722            .header("CamelHttpUri")
2723            .and_then(|v| v.as_str())
2724        {
2725            // Host fence (allowedUriHosts): opt-in, fail-closed. Evaluated
2726            // on the raw override before any path/query assembly; a
2727            // rejection renders the URL only through the diagnostics
2728            // redaction path (ADR-0051).
2729            if let Some(fence) = &config.allowed_uri_hosts
2730                && !uri_host_allowed(uri, fence)?
2731            {
2732                return Err(CamelError::ProcessorError(format!(
2733                    "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2734                    redact_url_for_diagnostics(uri)
2735                )));
2736            }
2737            // The override replaces the base URL; its own query is the
2738            // higher-precedence source for composition (ADR-0071) — the
2739            // endpoint base query does not ride an override. Split at the
2740            // first `?` so CamelHttpPath applies to the path component
2741            // and the queries merge at pair level, never a second `?`
2742            // marker.
2743            let (base, override_query) = match uri.split_once('?') {
2744                Some((base, query)) => (base, Some(query)),
2745                None => (uri, None),
2746            };
2747            // Resolve-time span validation for the override URI's own query
2748            // (rc-m4xk1): a forbidden byte is a resolve error naming the
2749            // byte, never a verbatim ride that later surfaces as a reqwest
2750            // send error. Covers both downstream arms — the verbatim push
2751            // and merge_header_query, which validates only the header side.
2752            if let Some(query) = override_query {
2753                for (_key, span) in raw_query_pairs(query)? {
2754                    validate_raw_query_span(span)?;
2755                }
2756            }
2757            let mut url = base.to_string();
2758            if let Some(path) = exchange
2759                .input
2760                .header("CamelHttpPath")
2761                .and_then(|v| v.as_str())
2762            {
2763                if !url.ends_with('/') && !path.starts_with('/') {
2764                    url.push('/');
2765                }
2766                url.push_str(path);
2767            }
2768            if let Some(query) = exchange
2769                .input
2770                .header("CamelHttpQuery")
2771                .and_then(|v| v.as_str())
2772            {
2773                if let Some(merged) = merge_header_query(override_query, query)? {
2774                    url.push('?');
2775                    url.push_str(&merged);
2776                }
2777                return Ok(url);
2778            }
2779            if let Some(query) = override_query {
2780                url.push('?');
2781                url.push_str(query);
2782            }
2783            return Ok(url);
2784        }
2785
2786        let mut url = config.base_url.clone();
2787
2788        if let Some(path) = exchange
2789            .input
2790            .header("CamelHttpPath")
2791            .and_then(|v| v.as_str())
2792        {
2793            if !url.ends_with('/') && !path.starts_with('/') {
2794                url.push('/');
2795            }
2796            url.push_str(path);
2797        }
2798
2799        if let Some(query) = exchange
2800            .input
2801            .header("CamelHttpQuery")
2802            .and_then(|v| v.as_str())
2803        {
2804            // Compose: the endpoint query (raw-preserving,
2805            // consumed-option-filtered) comes first and wins collisions;
2806            // header pairs append verbatim for absent keys (ADR-0071).
2807            // An empty header leaves the endpoint query unchanged.
2808            if let Some(merged) =
2809                merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2810            {
2811                url.push('?');
2812                url.push_str(&merged);
2813            }
2814            return Ok(url);
2815        }
2816
2817        if let Some(query) = resolve_endpoint_query(config)? {
2818            url.push('?');
2819            url.push_str(&query);
2820        }
2821
2822        Ok(url)
2823    }
2824
2825    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2826        status >= range.0 && status <= range.1
2827    }
2828}
2829
2830/// One allowlist entry of the `CamelHttpUri` host fence (`allowedUriHosts`
2831/// endpoint option). DNS hosts are stored ASCII-lowercased; IPv6 literals
2832/// in bracketed canonical form (the `url` crate's host serialization). A
2833/// `port` of `None` is a host-only entry and permits any port.
2834#[derive(Clone, Debug, PartialEq, Eq)]
2835pub struct AllowedUriHost {
2836    /// Canonical host: lowercased DNS name or bracketed IPv6 literal.
2837    pub host: String,
2838    /// `Some` pins the entry to one effective port; `None` permits any.
2839    pub port: Option<u16>,
2840}
2841
2842/// Parse the `allowedUriHosts` option value: comma-separated `host` or
2843/// `host:port` entries. Bracketed IPv6 is supported (`[::1]:8443`, bare
2844/// `[::1]` host-only). Empty segments are dropped. Segments are parsed
2845/// through the `url` crate (with an `http://` scheme injected) so DNS
2846/// names are lowercased and ports range-checked; anything it rejects is a
2847/// malformed entry. A value yielding zero valid entries is also an error.
2848/// Both failure modes fail endpoint creation (fail-closed).
2849fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2850    let mut entries = Vec::new();
2851    for segment in raw.split(',') {
2852        let segment = segment.trim();
2853        if segment.is_empty() {
2854            continue;
2855        }
2856        let parsed = url::Url::parse(&format!("http://{segment}"))
2857            .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2858        // A segment carrying a path or userinfo is a typo'd entry — the
2859        // spec's "any other malformed entry" clause. Silently narrowing it
2860        // to its hostname would widen or skew the fence.
2861        if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2862            return Err(invalid_allowed_uri_host_entry(segment));
2863        }
2864        let Some(host) = parsed.host_str() else {
2865            return Err(invalid_allowed_uri_host_entry(segment));
2866        };
2867        entries.push(AllowedUriHost {
2868            host: host.to_string(),
2869            port: parsed.port(),
2870        });
2871    }
2872    if entries.is_empty() {
2873        return Err(CamelError::InvalidUri(
2874            "allowedUriHosts declares no valid host entries".to_string(),
2875        ));
2876    }
2877    Ok(entries)
2878}
2879
2880fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2881    CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2882}
2883
2884/// Whether `url_str` matches the fence. Parse failure or a host-less URL
2885/// is fail-closed (`Ok(false)`). DNS hosts compare case-insensitively
2886/// (both sides are lowercased by the `url` crate); IPv6 compares in
2887/// bracketed canonical form. A host-only entry permits any port; a
2888/// `host:port` entry matches only the effective port — the explicit port
2889/// or the scheme default (443 for https, 80 for http).
2890pub(crate) fn uri_host_allowed(
2891    url_str: &str,
2892    fence: &[AllowedUriHost],
2893) -> Result<bool, CamelError> {
2894    let Ok(parsed) = url::Url::parse(url_str) else {
2895        return Ok(false);
2896    };
2897    let Some(host) = parsed.host_str() else {
2898        return Ok(false);
2899    };
2900    let effective_port = parsed.port().or(match parsed.scheme() {
2901        "https" => Some(443_u16),
2902        "http" => Some(80),
2903        _ => None,
2904    });
2905    Ok(fence.iter().any(|entry| {
2906        entry.host == host
2907            && match entry.port {
2908                None => true,
2909                Some(port) => effective_port == Some(port),
2910            }
2911    }))
2912}
2913
2914/// Serialize the outbound query for the endpoint base.
2915///
2916/// Authored raw pairs come first, byte-for-byte minus consumed option keys
2917/// (order, separators and authored escapes — including `RAW(...)` text —
2918/// preserved); then programmatic `query_params` entries whose key is absent
2919/// from the authored pairs, in declaration order with minimal RFC-3986
2920/// encoding (`%20`, never `+`). Authored keys always win — no duplication,
2921/// no override.
2922///
2923/// Returns `Ok(None)` when no query component is emitted: no pairs at all,
2924/// or a non-empty raw query whose every pair was consumed. A bare `?`
2925/// marker (`raw_query == Some("")`) always emits the query component.
2926fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2927    let mut parts: Vec<String> = Vec::new();
2928    let mut authored_keys = std::collections::HashSet::new();
2929
2930    if let Some(raw) = config.raw_query.as_deref() {
2931        for (key, span) in raw_query_pairs(raw)? {
2932            authored_keys.insert(key.clone());
2933            if is_consumed_option(&key) {
2934                continue;
2935            }
2936            validate_raw_query_span(span)?;
2937            parts.push(span.to_string());
2938        }
2939    }
2940
2941    for (key, value) in &config.query_params {
2942        if !authored_keys.contains(key.as_str()) {
2943            parts.push(format!(
2944                "{}={}",
2945                encode_query_component(key),
2946                encode_query_component(value)
2947            ));
2948        }
2949    }
2950
2951    if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2952        return Ok(None);
2953    }
2954    Ok(Some(parts.join("&")))
2955}
2956
2957/// Compose the outbound query when a `CamelHttpQuery` exchange header is
2958/// present (ADR-0071). `higher_precedence` — the endpoint query in the
2959/// base arm, the override URI's own query in the override arm — comes
2960/// first and wins any key collision; header pairs append verbatim for
2961/// absent keys only. An empty header leaves the higher-precedence query
2962/// unchanged (no additional `?` marker). Header spans are validated, not
2963/// re-encoded: a byte forbidden in a query component is a resolve error
2964/// naming the byte (Wave-A law).
2965fn merge_header_query(
2966    higher_precedence: Option<&str>,
2967    header_query: &str,
2968) -> Result<Option<String>, CamelError> {
2969    if header_query.is_empty() {
2970        return Ok(higher_precedence.map(str::to_string));
2971    }
2972    let mut parts: Vec<String> = Vec::new();
2973    let mut higher_keys = std::collections::HashSet::new();
2974    for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2975        higher_keys.insert(key);
2976        parts.push(span.to_string());
2977    }
2978    for (key, span) in raw_query_pairs(header_query)? {
2979        validate_raw_query_span(span)?;
2980        if !higher_keys.contains(key.as_str()) {
2981            parts.push(span.to_string());
2982        }
2983    }
2984    if parts.is_empty() {
2985        return Ok(None);
2986    }
2987    Ok(Some(parts.join("&")))
2988}
2989
2990/// Bytes that may appear unescaped in a URI query component. RFC 3986
2991/// (`query = *( pchar / "/" / "?" )`) admits unreserved, sub-delims, `:`,
2992/// `@`, `/`, `?`, and `%` — with ONE deliberate exclusion from the RFC set:
2993/// the apostrophe (`'`, 0x27). reqwest's WHATWG URL parser re-encodes 0x27
2994/// to `%27` in the special-query percent-encode set (http/https), so an
2995/// authored apostrophe can never ride the wire verbatim; admitting it would
2996/// silently normalize authored bytes (rc-nmupb). Authors write `%27`
2997/// explicitly when they mean the byte on the wire. The WHATWG set's other
2998/// extras (`"`, `` ` ``, `<`, `>`) are already rejected here — they are not
2999/// RFC 3986 query-legal bytes, so no special exclusion is needed for them.
3000fn is_legal_query_byte(byte: u8) -> bool {
3001    matches!(byte,
3002        b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
3003        | b'-' | b'.' | b'_' | b'~'
3004        | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
3005        | b':' | b'@' | b'/' | b'?'
3006        | b'%')
3007}
3008
3009/// Reject an authored raw pair carrying a byte that is not legal in a query
3010/// component (e.g. literal space, `#`, non-ASCII). The serializer never
3011/// silently re-encodes operator-authored bytes: "byte-for-byte" is bounded
3012/// to wire-legal bytes, and the check fires before the resolved string
3013/// reaches any consumer (SSRF pre-check, diagnostics redaction).
3014fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
3015    for &byte in span.as_bytes() {
3016        if !is_legal_query_byte(byte) {
3017            return Err(CamelError::ProcessorError(format!(
3018                "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
3019            )));
3020        }
3021    }
3022    Ok(())
3023}
3024
3025/// Minimal RFC-3986 percent-encoding for one programmatic query component:
3026/// unreserved bytes pass through, every other byte encodes as uppercase
3027/// hex. A space encodes as `%20`, never `+`.
3028fn encode_query_component(component: &str) -> String {
3029    const HEX: &[u8; 16] = b"0123456789ABCDEF";
3030    let mut out = String::with_capacity(component.len());
3031    for &byte in component.as_bytes() {
3032        match byte {
3033            b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3034                out.push(byte as char);
3035            }
3036            _ => {
3037                out.push('%');
3038                out.push(HEX[(byte >> 4) as usize] as char);
3039                out.push(HEX[(byte & 0x0f) as usize] as char);
3040            }
3041        }
3042    }
3043    out
3044}
3045
3046/// Redact credentials from a URL before it reaches logs or error values
3047/// (ADR-0051 redact-by-construction). Masks userinfo (`user:pass@`) and
3048/// the query string (which commonly carries API keys/tokens). Host and
3049/// path stay visible for diagnosability. Fragments are never echoed: a
3050/// fragment (OAuth2 callback tokens such as `#access_token=...`) is
3051/// dropped and replaced with the `#[redacted]` sentinel in both the
3052/// parsed arm and the unparseable arm. Fail-closed: when the parse fails
3053/// and any authority window contains `@`, only the `[redacted]`
3054/// sentinel is returned. Every authority window is scanned: windows are
3055/// enumerated over maximal runs of `/` and `\` — pure-slash runs of two
3056/// or more characters, backslash-bearing runs only behind an RFC 3986
3057/// scheme prefix (see [`camel_api::redact`] for the canonical window
3058/// rule) — each window starts immediately after the run (so evaders like
3059/// `scheme:////user:pass@evil/` cannot hide a `@` behind a slash run)
3060/// and ends at the next `/`, `?`, or `#`; scanning all windows keeps
3061/// later `//user:pass@` substrings from hiding behind a benign first
3062/// window.
3063///
3064/// The parsed arm keeps `url::Url::parse` (the authority can only be
3065/// judged by the parser) and masks the real authority accessors, then
3066/// delegates wholesale to the canonical string surgery in
3067/// [`camel_api::redact::redact_url`]: rust-url can park later-window
3068/// userinfo bytes in the path (`https://h//user:pass@evil/`), and the
3069/// canonical helper owns window masking, `?`/`#` sentinel composition
3070/// (one per distinct introducer, first-occurrence order), and the
3071/// 256-byte UTF-8 cap. The unparseable arm delegates to
3072/// [`camel_api::redact::redact_url_fail_closed`].
3073pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3074    match url::Url::parse(raw) {
3075        Ok(mut u) => {
3076            // Fail closed when an authority marker was accepted but no
3077            // host was stored: userinfo-shaped bytes can hide in the path
3078            // behind the marker, and empty-host schemes (`file:///us@r/x`,
3079            // `unix:///@socket`) can put a `@` in that window too. Such
3080            // inputs are sentineled wholesale — deliberate fail-closed
3081            // over-redaction per ADR-0051.
3082            if !u.cannot_be_a_base()
3083                && u.host_str().is_none()
3084                && camel_api::redact::window_has_at_sign(raw)
3085            {
3086                return "[redacted]".to_string();
3087            }
3088            if !u.username().is_empty() || u.password().is_some() {
3089                let _ = u.set_username("***");
3090                let _ = u.set_password(None);
3091            }
3092            // Query and fragment stay on the rendered URL; the canonical
3093            // redactor drops them and composes the sentinels.
3094            let s = u.to_string();
3095            camel_api::redact::redact_url(&s)
3096        }
3097        Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3098    }
3099}
3100
3101/// Maximum bytes of an upstream error response body embedded into
3102/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
3103/// malicious or compromised upstream), so it is truncated and lossy-decoded to
3104/// bound log injection / DLQ payload size.
3105const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3106
3107fn truncate_error_body(body: &[u8]) -> String {
3108    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3109        String::from_utf8_lossy(body).into_owned()
3110    } else {
3111        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3112        s.push_str("...[truncated]");
3113        s
3114    }
3115}
3116
3117impl HttpProducer {
3118    /// Whether the HTTP method is entity-enclosing (may carry a request
3119    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
3120    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
3121    /// §9.3.1/§9.3.2).
3122    fn is_entity_enclosing(method: &str) -> bool {
3123        matches!(method, "POST" | "PUT" | "PATCH")
3124    }
3125}
3126
3127impl Service<Exchange> for HttpProducer {
3128    type Response = Exchange;
3129    type Error = CamelError;
3130    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3131
3132    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3133        Poll::Ready(Ok(()))
3134    }
3135
3136    fn call(&mut self, exchange: Exchange) -> Self::Future {
3137        let config = self.config.clone();
3138        let shared_client = self.client.clone();
3139        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3140        let http_config = self.http_config.clone();
3141        let component_metrics = self.runtime.component_metrics();
3142
3143        Box::pin(async move {
3144            let mut exchange = exchange;
3145            let outcome = async {
3146                let method_str = HttpProducer::resolve_method(&exchange, &config);
3147                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
3148                // and PATCH may carry a request body. Any other resolved method
3149                // drops the exchange body before the request is built (Apache
3150                // Camel `HttpMethods` parity).
3151                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3152                let url = HttpProducer::resolve_url(&exchange, &config)?;
3153
3154                // SECURITY: Validate URL for SSRF
3155                ssrf::validate_url_for_ssrf(&url, &config)?;
3156
3157                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
3158                // (L-H2). When the URL uses a domain name and SSRF protection is active,
3159                // reuse the endpoint's cached DNS-pinned client for that validated
3160                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
3161                // repeated requests keep one connection pool without re-resolving DNS.
3162                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
3163                // URLs use the endpoint's unpinned shared client.
3164                let resolved = ssrf::resolve_initial_url_for_ssrf(
3165                    &url,
3166                    config.allow_internal,
3167                    config.allow_cleartext,
3168                )
3169                .await?;
3170                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3171                    pinned_cache
3172                        .get_or_build(host.as_str(), addrs, || {
3173                            build_client(&http_config, Some((host.as_str(), addrs)))
3174                        })
3175                        .await
3176                } else {
3177                    shared_client.clone()
3178                };
3179
3180                debug!(
3181                    correlation_id = %exchange.correlation_id(),
3182                    method = %method_str,
3183                    url = %redact_url_for_diagnostics(&url),
3184                    "HTTP request"
3185                );
3186
3187                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3188                    CamelError::ProcessorError(format!(
3189                        "Invalid HTTP method '{}': {}",
3190                        method_str, e
3191                    ))
3192                })?;
3193
3194                // Collect headers for potential redirect replay
3195                let mut collected_headers: Vec<(
3196                    reqwest::header::HeaderName,
3197                    reqwest::header::HeaderValue,
3198                )> = Vec::new();
3199
3200                if let Some(user_agent) = &config.user_agent
3201                    && !config.bridge_endpoint
3202                {
3203                    match constructed_header("user-agent", user_agent) {
3204                        Ok((_, val)) => {
3205                            collected_headers.push((reqwest::header::USER_AGENT, val));
3206                        }
3207                        Err(drop) => debug!(
3208                            correlation_id = %exchange.correlation_id(),
3209                            header = %drop.name,
3210                            "outbound header dropped: {}",
3211                            drop.reason
3212                        ),
3213                    }
3214                }
3215
3216                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
3217                #[cfg(feature = "otel")]
3218                let should_inject_otel = !config.bridge_endpoint;
3219                #[cfg(feature = "otel")]
3220                if should_inject_otel {
3221                    let mut otel_headers = HashMap::new();
3222                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3223                    for (k, v) in otel_headers {
3224                        match constructed_header(&k, &v) {
3225                            Ok((name, val)) => collected_headers.push((name, val)),
3226                            Err(drop) => debug!(
3227                                correlation_id = %exchange.correlation_id(),
3228                                header = %drop.name,
3229                                "outbound header dropped: {}",
3230                                drop.reason
3231                            ),
3232                        }
3233                    }
3234                }
3235
3236                let conn_tokens = header_policy::connection_tokens(
3237                    exchange
3238                        .input
3239                        .headers
3240                        .iter()
3241                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3242                        .filter_map(|(_, v)| v.as_str()),
3243                );
3244
3245                let outbound = select_outbound_headers(
3246                    &exchange.input.headers,
3247                    &config.skip_request_headers,
3248                    &conn_tokens,
3249                );
3250                for drop in &outbound.drops {
3251                    if let Some(value_kind) = drop.value_kind {
3252                        debug!(
3253                            correlation_id = %exchange.correlation_id(),
3254                            header = %drop.name,
3255                            value_kind = value_kind,
3256                            "outbound header dropped: {}",
3257                            drop.reason
3258                        );
3259                    } else {
3260                        debug!(
3261                            correlation_id = %exchange.correlation_id(),
3262                            header = %drop.name,
3263                            "outbound header dropped: {}",
3264                            drop.reason
3265                        );
3266                    }
3267                }
3268                collected_headers.extend(outbound.accepted);
3269
3270                // Auth headers
3271                if !config.bridge_endpoint {
3272                    match &config.auth {
3273                        HttpAuth::None => {}
3274                        HttpAuth::Basic { username, password } => {
3275                            use base64::Engine;
3276                            // allow-secret: credentials combined for base64 Basic auth header
3277                            let credentials = format!("{username}:{password}");
3278                            let encoded =
3279                                base64::engine::general_purpose::STANDARD.encode(credentials);
3280                            // Base64 output is always header-safe; the guard is kept
3281                            // for uniformity with Bearer.
3282                            match constructed_header("authorization", &format!("Basic {encoded}")) {
3283                                Ok((_, val)) => {
3284                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3285                                }
3286                                Err(drop) => debug!(
3287                                    correlation_id = %exchange.correlation_id(),
3288                                    header = %drop.name,
3289                                    "outbound header dropped: {}",
3290                                    drop.reason
3291                                ),
3292                            }
3293                        }
3294                        HttpAuth::Bearer { token } => {
3295                            // allow-secret: Bearer token in Authorization header
3296                            let bearer = format!("Bearer {token}");
3297                            match constructed_header("authorization", &bearer) {
3298                                Ok((_, val)) => {
3299                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3300                                }
3301                                Err(drop) => debug!(
3302                                    correlation_id = %exchange.correlation_id(),
3303                                    header = %drop.name,
3304                                    "outbound header dropped: {}",
3305                                    drop.reason
3306                                ),
3307                            }
3308                        }
3309                    }
3310
3311                    if config.connection_close {
3312                        collected_headers.push((
3313                            reqwest::header::CONNECTION,
3314                            reqwest::header::HeaderValue::from_static("close"),
3315                        ));
3316                    }
3317                }
3318
3319                // Materialize body
3320                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3321                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3322                    if suppress_body {
3323                        // A stream body dropped under a non-entity-enclosing
3324                        // method always warns (its emptiness is unknowable) and
3325                        // stays consumed (mem::take). The stream attach arm below
3326                        // still runs its outer flag check, but the inner `if let
3327                        // Body::Stream` re-match fails on the now-Empty body, so
3328                        // no stream is attached and no AlreadyConsumed error can
3329                        // fire.
3330                        std::mem::take(&mut exchange.input.body);
3331                        // log-policy: handler-owned
3332                        tracing::warn!(
3333                            correlation_id = %exchange.correlation_id(),
3334                            method = %method_str,
3335                            "dropping request body for non-entity-enclosing HTTP method"
3336                        );
3337                    }
3338                    None // Streams can't be replayed on redirect
3339                } else {
3340                    let body = std::mem::take(&mut exchange.input.body);
3341                    let bytes = body.into_bytes(config.max_body_size).await?;
3342                    if bytes.is_empty() {
3343                        // Empty body: nothing to send and nothing to warn about.
3344                        None
3345                    } else if suppress_body {
3346                        // log-policy: handler-owned
3347                        tracing::warn!(
3348                            correlation_id = %exchange.correlation_id(),
3349                            method = %method_str,
3350                            "dropping request body for non-entity-enclosing HTTP method"
3351                        );
3352                        None
3353                    } else {
3354                        Some(bytes.to_vec())
3355                    }
3356                };
3357
3358                let response = if config.follow_redirects && !is_stream_body {
3359                    // Use manual redirect loop with per-hop SSRF validation.
3360                    // `client` is the pinned-or-shared binding for the initial
3361                    // request (a hostname initial request keeps its DNS-pinned
3362                    // client); `shared_client` is the unpinned endpoint client
3363                    // reused by IP-literal redirect hops.
3364                    ssrf::send_with_ssrf_safe_redirects(
3365                        &client,
3366                        &shared_client,
3367                        &pinned_cache,
3368                        &http_config,
3369                        &config,
3370                        method,
3371                        &url,
3372                        collected_headers,
3373                        materialized_body,
3374                        config.max_redirects,
3375                        config.response_timeout,
3376                    )
3377                    .await?
3378                } else {
3379                    // Direct send (no redirect following, or streaming body)
3380                    let mut request = client.request(method, &url);
3381
3382                    if let Some(timeout) = config.response_timeout {
3383                        request = request.timeout(timeout);
3384                    }
3385
3386                    for (name, value) in &collected_headers {
3387                        request = request.header(name, value);
3388                    }
3389
3390                    if is_stream_body {
3391                        if let Body::Stream(ref s) = exchange.input.body {
3392                            let mut stream_lock = s.stream.lock().await;
3393                            if let Some(stream) = stream_lock.take() {
3394                                request = request.body(reqwest::Body::wrap_stream(stream));
3395                            } else {
3396                                return Err(CamelError::AlreadyConsumed);
3397                            }
3398                        }
3399                    } else if let Some(ref body_bytes) = materialized_body {
3400                        request = request.body(body_bytes.clone());
3401                    }
3402
3403                    request.send().await.map_err(|e| {
3404                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3405                    })?
3406                };
3407
3408                let status_code = response.status().as_u16();
3409                let status_text = response
3410                    .status()
3411                    .canonical_reason()
3412                    .unwrap_or("Unknown")
3413                    .to_string();
3414
3415                for (key, value) in response.headers() {
3416                    if config
3417                        .skip_response_headers
3418                        .iter()
3419                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3420                    {
3421                        continue;
3422                    }
3423                    if let Ok(val_str) = value.to_str() {
3424                        exchange.input.set_header(
3425                            title_case_header(key.as_str()),
3426                            serde_json::Value::String(val_str.to_string()),
3427                        );
3428                    }
3429                }
3430
3431                exchange.input.set_header(
3432                    "CamelHttpResponseCode",
3433                    serde_json::Value::Number(status_code.into()),
3434                );
3435                exchange.input.set_header(
3436                    "CamelHttpResponseText",
3437                    serde_json::Value::String(status_text.clone()),
3438                );
3439
3440                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
3441                let read_timeout = Duration::from_millis(config.read_timeout_ms);
3442                let response_body = tokio::time::timeout(read_timeout, async {
3443                    // Check Content-Length header before allocating
3444                    if let Some(content_len) = response.content_length()
3445                        && content_len > config.max_response_bytes as u64
3446                    {
3447                        return Err(CamelError::ProcessorError(format!(
3448                            "Response body too large: {} bytes exceeds limit of {} bytes",
3449                            content_len, config.max_response_bytes
3450                        )));
3451                    }
3452                    // Use bytes_stream() for lazy streaming with size guard
3453                    use futures::TryStreamExt;
3454                    let mut stream = response.bytes_stream();
3455                    let mut total: usize = 0;
3456                    let mut collected = Vec::new();
3457                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
3458                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3459                    })? {
3460                        total += chunk.len();
3461                        if total > config.max_response_bytes {
3462                            return Err(CamelError::ProcessorError(format!(
3463                                "Response body too large: {} bytes exceeds limit of {} bytes",
3464                                total, config.max_response_bytes
3465                            )));
3466                        }
3467                        collected.push(chunk);
3468                    }
3469                    let mut result = bytes::BytesMut::with_capacity(total);
3470                    for chunk in collected {
3471                        result.extend_from_slice(&chunk);
3472                    }
3473                    Ok::<bytes::Bytes, CamelError>(result.freeze())
3474                })
3475                .await
3476                .map_err(|_| {
3477                    CamelError::ProcessorError(format!(
3478                        "Read timeout after {}ms",
3479                        config.read_timeout_ms
3480                    ))
3481                })??;
3482
3483                if config.throw_exception_on_failure
3484                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3485                {
3486                    return Err(CamelError::HttpOperationFailed {
3487                        method: method_str,
3488                        // ADR-0051 redact-by-construction: never embed
3489                        // userinfo/query credentials in the error value.
3490                        url: redact_url_for_diagnostics(&url),
3491                        status_code,
3492                        status_text,
3493                        response_body: Some(truncate_error_body(&response_body)),
3494                    });
3495                }
3496
3497                if !response_body.is_empty() {
3498                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3499                }
3500
3501                debug!(
3502                    correlation_id = %exchange.correlation_id(),
3503                    status = status_code,
3504                    url = %redact_url_for_diagnostics(&url),
3505                    "HTTP response"
3506                );
3507                Ok(exchange)
3508            }
3509            .await;
3510            // ("http","request") facade (dashboard-observability 4.3): the
3511            // request boundary is the full client round-trip — SSRF checks,
3512            // send, response read, and (with throwExceptionOnFailure) the
3513            // status gate. http runs no retry_async and the producer
3514            // previously emitted nothing, so no label collides with
3515            // e:http:request.
3516            component_metrics.observe("http", "request", outcome.is_err());
3517            outcome
3518        })
3519    }
3520}
3521
3522/// Serializes tests that mutate or depend on the global `ServerRegistry`.
3523///
3524/// `ServerRegistry::global()` is a process-wide singleton that persists
3525/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
3526/// with another test that has a live server on a fixed port (e.g. 9991),
3527/// the registry entry is removed while the OS socket is still bound, so
3528/// the next `get_or_spawn` call on that port fails with "Address already
3529/// in use". This mutex does not give blanket protection by itself. It
3530/// helps only where every participant follows the mutex law: the
3531/// consumer-test readiness helper holds it from `stage_listener` until
3532/// readiness-complete (http-test-harness spec, requirement
3533/// "Registry-mutation serialization during setup"), and each `reset()`
3534/// caller takes it before the reset.
3535#[cfg(test)]
3536pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3537
3538/// Poison-recovering acquire of REGISTRY_TEST_MUTEX (httpflake).
3539///
3540/// The mutex guards test SERIALIZATION only - the registry own data is
3541/// protected by its inner lock - so a sibling test that panics while
3542/// holding the guard must not poison the mutex and cascade failures
3543/// into every other holder. Recovery via into_inner is therefore safe
3544/// and keeps one failing test failing as ONE test.
3545#[cfg(test)]
3546pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3547    REGISTRY_TEST_MUTEX
3548        .lock()
3549        .unwrap_or_else(|poisoned| poisoned.into_inner())
3550}
3551
3552/// Map a pipeline error to an HTTP reply.
3553///
3554/// Extracted from the inline `match` in `dispatch_handler` for unit
3555/// testability (rc-1dk4). Client-fault errors map to their 4xx codes
3556/// with a structured JSON error body: `TypeConversionFailed`/
3557/// `ValidationError` → 400, `UnsupportedMediaType` → 415 (media
3558/// negotiation gate, REST lowering), `NotAcceptable` → 406 (same
3559/// gate); `Unauthenticated`/`Unauthorized` keep their `401`/`403`
3560/// mappings; all other errors map to `500 Internal Server Error`.
3561fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3562    match e {
3563        CamelError::Unauthenticated(msg) => {
3564            tracing::warn!(error = %msg, path = %path, "Authentication failed");
3565            HttpReply {
3566                status: 401,
3567                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3568                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3569            }
3570        }
3571        CamelError::Unauthorized(msg) => {
3572            tracing::warn!(error = %msg, path = %path, "Authorization failed");
3573            HttpReply {
3574                status: 403,
3575                headers: vec![],
3576                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3577            }
3578        }
3579        CamelError::TypeConversionFailed(msg) => {
3580            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3581            json_error_reply(400, "bad_request", msg)
3582        }
3583        CamelError::ValidationError(msg) => {
3584            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3585            json_error_reply(400, "validation_error", msg)
3586        }
3587        CamelError::ConsumerStopping => {
3588            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3589            HttpReply {
3590                status: 503,
3591                headers: vec![],
3592                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3593            }
3594        }
3595        CamelError::UnsupportedMediaType { consumed, declared } => {
3596            tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3597            json_error_reply(
3598                415,
3599                "unsupported_media_type",
3600                format!("consumed {consumed}, declared {declared}"),
3601            )
3602        }
3603        CamelError::NotAcceptable { accept, produced } => {
3604            tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3605            json_error_reply(
3606                406,
3607                "not_acceptable",
3608                format!("accept {accept}, produced {produced}"),
3609            )
3610        }
3611        e => {
3612            // log-policy: handler-owned
3613            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3614            HttpReply {
3615                status: 500,
3616                headers: vec![],
3617                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3618            }
3619        }
3620    }
3621}
3622
3623/// Build a JSON error reply with the given status, error code, and message.
3624///
3625/// Shared by the `TypeConversionFailed`/`ValidationError` (400),
3626/// `UnsupportedMediaType` (415), and `NotAcceptable` (406) arms of
3627/// `pipeline_error_to_reply` so the four replies cannot drift apart. The
3628/// `unwrap_or_else(|_| "{}".to_string())` fallback keeps the reply valid
3629/// JSON even if serialization fails.
3630fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3631    let body = serde_json::to_string(&serde_json::json!({
3632        "error": code,
3633        "message": message,
3634    }))
3635    .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3636    HttpReply {
3637        status,
3638        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3639        body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3640    }
3641}
3642
3643/// Lowercase kind name for a JSON value, used in drop diagnostics so log
3644/// readers see *why* a header had no scalar string form without the value
3645/// itself ever entering diagnostics.
3646const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3647    match v {
3648        serde_json::Value::Null => "null",
3649        serde_json::Value::Bool(_) => "bool",
3650        serde_json::Value::Number(_) => "number",
3651        serde_json::Value::String(_) => "string",
3652        serde_json::Value::Array(_) => "array",
3653        serde_json::Value::Object(_) => "object",
3654    }
3655}
3656
3657/// Scalar string form of a JSON value: strings pass through, `Number` and
3658/// `Bool` are stringified, everything else has no single-value form.
3659/// Shared by the consumer reply finaliser and the producer outbound filter
3660/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
3661fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3662    match v {
3663        serde_json::Value::String(s) => Some(s.clone()),
3664        serde_json::Value::Number(n) => Some(n.to_string()),
3665        serde_json::Value::Bool(b) => Some(b.to_string()),
3666        _ => None,
3667    }
3668}
3669
3670/// Select the HTTP response headers emitted by the consumer reply finaliser
3671/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
3672/// `dispatch_handler` for unit testability.
3673///
3674/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
3675/// server-owned headers, plus `content-length`/`content-type` (re-derived),
3676/// and any header named by a `Connection` token. Scalar non-string values
3677/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3678/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
3679/// and arrays have no single-value form and are dropped. Every drop is
3680/// logged at DEBUG with the header name and reason — names only, never
3681/// values, so credentials cannot leak into diagnostics (ADR-0051).
3682/// Appends a single `Content-Type` from `user_content_type` falling back to
3683/// `inferred_content_type` when either is present.
3684fn select_response_headers(
3685    headers: &HashMap<String, serde_json::Value>,
3686    user_content_type: Option<String>,
3687    inferred_content_type: Option<String>,
3688) -> Vec<(String, String)> {
3689    let conn_tokens = header_policy::connection_tokens(
3690        headers
3691            .iter()
3692            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3693            .filter_map(|(_, v)| v.as_str()),
3694    );
3695    let mut selected: Vec<(String, String)> = Vec::new();
3696    for (k, v) in headers {
3697        if k.starts_with("Camel") {
3698            debug!(header = %k, "reply header dropped: Camel namespace");
3699            continue;
3700        }
3701        if header_policy::excluded_response(k, &conn_tokens) {
3702            debug!(header = %k, "reply header dropped: emission policy");
3703            continue;
3704        }
3705        match scalar_string_form(v) {
3706            Some(s) => selected.push((k.clone(), s)),
3707            None => debug!(
3708                header = %k,
3709                value_kind = json_value_kind(v),
3710                "reply header dropped: no scalar string form"
3711            ),
3712        }
3713    }
3714    if let Some(ct) = user_content_type.or(inferred_content_type) {
3715        selected.push(("Content-Type".to_string(), ct));
3716    }
3717    selected
3718}
3719
3720/// One outbound header drop: the exchange header name, a stable reason
3721/// string, and — when the drop was caused by the value having no scalar
3722/// string form — the JSON value kind. Names and kinds only, never values
3723/// (ADR-0051).
3724#[derive(Debug)]
3725struct OutboundHeaderDrop<'a> {
3726    name: &'a str,
3727    reason: &'static str,
3728    value_kind: Option<&'static str>,
3729}
3730
3731/// Outbound exchange-header selection result: headers accepted for the
3732/// wire plus drop records for call-site DEBUG logging.
3733struct OutboundHeaderSelection<'a> {
3734    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3735    drops: Vec<OutboundHeaderDrop<'a>>,
3736}
3737
3738/// Select the exchange headers the HTTP producer forwards on the outbound
3739/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
3740/// `HttpProducer::call` for unit testability.
3741///
3742/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
3743/// hop-by-hop/framing and connection-token-named headers excluded by the
3744/// outbound emission policy, and headers whose name or stringified value
3745/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
3746/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3747/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
3748/// and arrays have no single-value form and are dropped. Drops are returned
3749/// rather than logged so the call site can attach the correlation id; log
3750/// consumers see names and kinds only, never values (ADR-0051).
3751fn select_outbound_headers<'a>(
3752    headers: &'a HashMap<String, serde_json::Value>,
3753    skip_request_headers: &[String],
3754    conn_tokens: &[String],
3755) -> OutboundHeaderSelection<'a> {
3756    let mut accepted = Vec::new();
3757    let mut drops = Vec::new();
3758    for (key, value) in headers {
3759        if key.starts_with("Camel") {
3760            drops.push(OutboundHeaderDrop {
3761                name: key,
3762                reason: "Camel namespace",
3763                value_kind: None,
3764            });
3765            continue;
3766        }
3767        if skip_request_headers
3768            .iter()
3769            .any(|h| h.eq_ignore_ascii_case(key))
3770        {
3771            drops.push(OutboundHeaderDrop {
3772                name: key,
3773                reason: "skip_request_headers",
3774                value_kind: None,
3775            });
3776            continue;
3777        }
3778        if header_policy::excluded_outbound(key, conn_tokens) {
3779            drops.push(OutboundHeaderDrop {
3780                name: key,
3781                reason: "outbound emission policy",
3782                value_kind: None,
3783            });
3784            continue;
3785        }
3786        let Some(val_str) = scalar_string_form(value) else {
3787            drops.push(OutboundHeaderDrop {
3788                name: key,
3789                reason: "no scalar string form",
3790                value_kind: Some(json_value_kind(value)),
3791            });
3792            continue;
3793        };
3794        match constructed_header(key, &val_str) {
3795            Ok((name, val)) => accepted.push((name, val)),
3796            Err(drop) => drops.push(drop),
3797        }
3798    }
3799    OutboundHeaderSelection { accepted, drops }
3800}
3801
3802/// Construct a wire-ready `(HeaderName, HeaderValue)` pair for one outbound
3803/// header, or a drop record when the name or value fails construction
3804/// (rc-jbs1v). Drop records carry name and reason only, never values
3805/// (ADR-0051).
3806fn constructed_header<'a>(
3807    name: &'a str,
3808    value: &str,
3809) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3810    let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3811        Ok(header_name) => header_name,
3812        Err(_) => {
3813            return Err(OutboundHeaderDrop {
3814                name,
3815                reason: "invalid header name",
3816                value_kind: None,
3817            });
3818        }
3819    };
3820    let header_value = match reqwest::header::HeaderValue::from_str(value) {
3821        Ok(header_value) => header_value,
3822        Err(_) => {
3823            return Err(OutboundHeaderDrop {
3824                name,
3825                reason: "invalid header value",
3826                value_kind: None,
3827            });
3828        }
3829    };
3830    Ok((header_name, header_value))
3831}
3832
3833#[cfg(test)]
3834mod tests {
3835    use camel_component_api::test_support::NoopRuntimeObservability;
3836
3837    // Producer/consumer tests drive the component-ops facade on every
3838    // call (dashboard-observability 4.3), so even non-observability tests
3839    // must supply a collector-returning runtime — Noop everywhere.
3840    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3841        std::sync::Arc::new(NoopRuntimeObservability)
3842    }
3843    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3844        std::sync::Arc::new(NoopRuntimeObservability)
3845    }
3846    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3847        std::sync::Arc::new(NoopRuntimeObservability)
3848    }
3849
3850    use super::*;
3851    use crate::config::TlsConfig;
3852    use crate::rest_match::PathSegment;
3853    use camel_component_api::{Message, NoOpComponentContext};
3854    use std::sync::Arc;
3855    use std::time::Duration;
3856
3857    fn test_producer_ctx() -> ProducerContext {
3858        ProducerContext::new()
3859    }
3860
3861    // -----------------------------------------------------------------------
3862    // Security: credential redaction (audit 2026-08-31, finding F3-1)
3863    // -----------------------------------------------------------------------
3864
3865    #[test]
3866    fn redact_url_drops_oauth2_fragment_access_token() {
3867        let redacted =
3868            redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
3869        assert!(
3870            !redacted.contains("SECRET"),
3871            "fragment access token leaked: {redacted}"
3872        );
3873        assert!(
3874            !redacted.contains("access_token"),
3875            "fragment key leaked: {redacted}"
3876        );
3877        assert!(
3878            redacted.ends_with("#[redacted]"),
3879            "fragment must be replaced with the sentinel: {redacted}"
3880        );
3881    }
3882
3883    #[test]
3884    fn redact_url_drops_oauth2_fragment_id_token() {
3885        let redacted =
3886            redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
3887        assert!(
3888            !redacted.contains("eyJhbG"),
3889            "id token payload leaked: {redacted}"
3890        );
3891        assert!(
3892            !redacted.contains("id_token"),
3893            "id token key leaked: {redacted}"
3894        );
3895        assert!(
3896            !redacted.contains("SECRET"),
3897            "id token signature leaked: {redacted}"
3898        );
3899        assert!(
3900            redacted.ends_with("#[redacted]"),
3901            "fragment must be replaced with the sentinel: {redacted}"
3902        );
3903    }
3904
3905    #[test]
3906    fn redact_url_drops_generic_fragment_kv() {
3907        let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
3908        assert!(
3909            !redacted.contains("abc123"),
3910            "fragment value leaked: {redacted}"
3911        );
3912        assert!(
3913            !redacted.contains("session="),
3914            "fragment key leaked: {redacted}"
3915        );
3916        assert!(
3917            redacted.contains("#[redacted]"),
3918            "fragment must be replaced with the sentinel: {redacted}"
3919        );
3920    }
3921
3922    #[test]
3923    fn redact_url_query_and_fragment_sentinels_compose() {
3924        let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
3925        assert_eq!(
3926            redacted, "https://h.example/p?[redacted]#[redacted]",
3927            "query and fragment sentinels must compose: {redacted}"
3928        );
3929    }
3930
3931    #[test]
3932    fn redact_url_drops_benign_fragment_too() {
3933        // Fragments never reach the wire, so nothing in them is diagnostic:
3934        // strictest-wins drops benign fragments too.
3935        let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
3936        assert_eq!(
3937            redacted, "https://h.example/docs#[redacted]",
3938            "benign fragment must still be dropped: {redacted}"
3939        );
3940    }
3941
3942    #[test]
3943    fn redact_url_unparseable_fragment_credentials_dropped() {
3944        let raw = "ht tps://app.example/cb#access_token=SECRET";
3945        assert!(
3946            url::Url::parse(raw).is_err(),
3947            "fixture must be unparseable: {raw}"
3948        );
3949        let redacted = redact_url_for_diagnostics(raw);
3950        assert!(
3951            !redacted.contains("SECRET"),
3952            "unparseable fragment token leaked: {redacted}"
3953        );
3954        assert!(
3955            !redacted.contains("access_token"),
3956            "unparseable fragment bytes leaked: {redacted}"
3957        );
3958        assert!(
3959            redacted.contains("#[redacted]"),
3960            "unparseable fragment must end in the sentinel: {redacted}"
3961        );
3962    }
3963
3964    #[test]
3965    fn redact_url_double_slash_evader_sentinel() {
3966        // url::Url::parse accepts this (empty host allowed for non-special
3967        // schemes), parking userinfo-shaped bytes in the opaque path.
3968        let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
3969        assert_eq!(
3970            redacted, "[redacted]",
3971            "double-slash evader must fail closed: {redacted}"
3972        );
3973    }
3974
3975    #[test]
3976    fn redact_url_triple_slash_evader_sentinel() {
3977        let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
3978        assert_eq!(
3979            redacted, "[redacted]",
3980            "triple-slash evader must fail closed: {redacted}"
3981        );
3982    }
3983
3984    #[test]
3985    fn redact_url_bare_protocol_relative_userinfo_sentinel() {
3986        let redacted = redact_url_for_diagnostics("//user:pass@evil");
3987        assert_eq!(
3988            redacted, "[redacted]",
3989            "protocol-relative userinfo must fail closed: {redacted}"
3990        );
3991    }
3992
3993    #[test]
3994    fn redact_url_empty_host_userinfo_sentinel() {
3995        // url::Url::parse rejects this with EmptyHost; the failure arm must
3996        // fail closed without panicking on the empty host.
3997        let redacted = redact_url_for_diagnostics("scheme://user@");
3998        assert_eq!(
3999            redacted, "[redacted]",
4000            "empty-host userinfo must fail closed: {redacted}"
4001        );
4002    }
4003
4004    #[test]
4005    fn redact_url_unparseable_slash_run_evader_sentinel() {
4006        // Unlike `scheme:////user:pass@evil/` (parses Ok, host=None, and
4007        // hits the parsed-arm guard), the space in the scheme forces the
4008        // parse to fail, driving the failure arm's slash-run skip directly.
4009        let raw = "schem e:////user:pass@evil/";
4010        assert!(
4011            url::Url::parse(raw).is_err(),
4012            "fixture must be unparseable: {raw}"
4013        );
4014        let redacted = redact_url_for_diagnostics(raw);
4015        assert_eq!(
4016            redacted, "[redacted]",
4017            "unparseable slash-run evader must fail closed: {redacted}"
4018        );
4019    }
4020
4021    #[test]
4022    fn redact_url_unparseable_later_window_userinfo_sentinel() {
4023        // The first `//` window ("ho st") carries no `@`, but a later
4024        // `//user:pass@evil/` window does. The scan must consider every
4025        // `//` window, not just the first, or the credentials echo.
4026        let raw = "http://ho st/a//user:pass@evil/";
4027        assert!(
4028            url::Url::parse(raw).is_err(),
4029            "fixture must be unparseable: {raw}"
4030        );
4031        let redacted = redact_url_for_diagnostics(raw);
4032        assert_eq!(
4033            redacted, "[redacted]",
4034            "userinfo in a later // window must fail closed: {redacted}"
4035        );
4036    }
4037
4038    #[test]
4039    fn redact_url_parsed_later_window_userinfo_masked() {
4040        // rust-url accepts this with host `h` and parks the userinfo bytes
4041        // in the path, so the accessor mask never fires. The parsed arm
4042        // must apply the same window-masking surgery as the string-based
4043        // redactors or the later window renders verbatim.
4044        let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4045        assert!(
4046            !redacted.contains("user:pass"),
4047            "parsed later-window userinfo leaked: {redacted}"
4048        );
4049        assert!(
4050            redacted.contains("h//***@evil/"),
4051            "later window must be masked in place: {redacted}"
4052        );
4053    }
4054
4055    #[test]
4056    fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4057        // Real userinfo is masked by the accessor step; the window surgery
4058        // on the rendered string must not double-mask it (`***@h` stays),
4059        // and the later `x@y` path window must still be masked.
4060        let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4061        assert!(
4062            redacted.contains("***@h"),
4063            "accessor mask must survive the window surgery: {redacted}"
4064        );
4065        assert!(
4066            !redacted.contains("user:pass"),
4067            "real userinfo leaked: {redacted}"
4068        );
4069        assert!(
4070            !redacted.contains("x@y"),
4071            "later path window leaked: {redacted}"
4072        );
4073    }
4074
4075    #[test]
4076    fn redact_url_backslash_authority_ruling() {
4077        // Probe outcome: url::Url::parse accepts this input. http is a
4078        // special scheme, so backslashes normalize to slashes and the
4079        // credentials land in real userinfo
4080        // (`http://user:pass@evil/path`). The parsed arm must mask them
4081        // like any other userinfo.
4082        let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4083        assert!(
4084            redacted.contains("***@"),
4085            "backslash authority must be userinfo-masked: {redacted}"
4086        );
4087        assert!(
4088            !redacted.contains("user:pass"),
4089            "backslash authority must not leak credentials: {redacted}"
4090        );
4091    }
4092
4093    #[test]
4094    fn non_special_backslash_authority_masked() {
4095        // Non-special scheme: the url crate does not normalize the
4096        // backslashes, so the string carries no `//` run — the
4097        // scheme-prefixed backslash window must still suppress the
4098        // credentials.
4099        let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4100        assert!(
4101            !redacted.contains("user:pass"),
4102            "non-special backslash authority leaked: {redacted}"
4103        );
4104        assert!(
4105            !redacted.contains("pass"),
4106            "non-special backslash authority leaked a credential byte: {redacted}"
4107        );
4108        // Clean sibling stays visible (spec scenario's second given).
4109        assert_eq!(
4110            redact_url_for_diagnostics("foo:\\clean/path"),
4111            "foo:\\clean/path"
4112        );
4113    }
4114
4115    #[test]
4116    fn one_char_scheme_credential_content_masked() {
4117        // Single backslash after the one-character scheme `x:` with
4118        // credential-shaped window content (`:` before the last `@`).
4119        let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4120        assert!(
4121            !redacted.contains("user:pass"),
4122            "one-char-scheme backslash authority leaked: {redacted}"
4123        );
4124        assert!(
4125            !redacted.contains("pass"),
4126            "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4127        );
4128    }
4129
4130    #[test]
4131    fn drive_and_unc_inputs_stay_visible() {
4132        // Drive path: single backslash after a one-character scheme, no
4133        // `:` in the candidate window — no qualifying backslash window.
4134        // The parse-success arm lowercases the scheme (`C:` → `c:`); the
4135        // diagnostic content must stay visible with no sentinel and no
4136        // mask (spec scenario: query-redaction/cap rules only).
4137        let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4138        assert!(
4139            !drive.contains("[redacted]"),
4140            "drive path must not be sentineled: {drive}"
4141        );
4142        assert!(
4143            !drive.contains("***"),
4144            "drive path must not be masked: {drive}"
4145        );
4146        assert!(
4147            drive.contains("x@corp"),
4148            "drive path keeps its at-sign content visible: {drive}"
4149        );
4150        // UNC path: no scheme prefix before the backslash run; the
4151        // unparseable arm renders it byte-identically.
4152        let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4153        assert_eq!(unc, "\\\\server\\x@y");
4154        assert!(
4155            !unc.contains("[redacted]"),
4156            "UNC path must not be sentineled: {unc}"
4157        );
4158    }
4159
4160    #[test]
4161    fn redact_url_masks_userinfo_and_query() {
4162        let redacted =
4163            redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4164        assert!(
4165            !redacted.contains("secretpass"),
4166            "password must be masked: {redacted}"
4167        );
4168        assert!(
4169            !redacted.contains("token=abc123"),
4170            "query must be masked: {redacted}"
4171        );
4172        assert!(
4173            !redacted.contains("user@"),
4174            "username must be masked: {redacted}"
4175        );
4176        assert!(
4177            redacted.contains("internal.example"),
4178            "host stays visible: {redacted}"
4179        );
4180        assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4181    }
4182
4183    #[test]
4184    fn redact_url_keeps_clean_urls_visible() {
4185        let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4186        assert_eq!(redacted, "https://api.example.com/v1/items");
4187    }
4188
4189    #[test]
4190    fn redact_url_masks_password_only_userinfo() {
4191        let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4192        assert!(
4193            !redacted.contains("pwsecret"),
4194            "password-only userinfo leaked: {redacted}"
4195        );
4196        assert_eq!(redacted, "http://***@host.example/");
4197
4198        let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4199        assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4200        assert_eq!(redacted, "http://***@host.example/api");
4201
4202        let redacted = redact_url_for_diagnostics("http://host.example/api");
4203        assert_eq!(redacted, "http://host.example/api");
4204    }
4205
4206    #[test]
4207    fn redact_url_truncates_unparseable() {
4208        let long = "x".repeat(1000);
4209        let redacted = redact_url_for_diagnostics(&long);
4210        assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4211    }
4212
4213    /// e_gpt stage-4: the 256-byte cap must not split an appended sentinel.
4214    /// The base is truncated at `256 - sentinel_len` BEFORE the sentinel is
4215    /// appended, so the sentinel always renders intact and the total stays
4216    /// ≤ 256. Both arms (parsed and unparseable) are exercised.
4217    #[test]
4218    fn redact_url_keeps_sentinels_intact_under_256_cap() {
4219        // Parsed arm: base (scheme+host+path) is 250 bytes, so byte 256
4220        // lands inside the appended `?[redacted]` (starts at 250) pre-fix.
4221        let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4222        assert!(
4223            url::Url::parse(&parsed).is_ok(),
4224            "fixture must parse: {parsed}"
4225        );
4226        let redacted = redact_url_for_diagnostics(&parsed);
4227        assert!(redacted.len() <= 256, "len={}", redacted.len());
4228        assert!(
4229            redacted.ends_with("?[redacted]"),
4230            "parsed-arm sentinel must render intact: {redacted}"
4231        );
4232
4233        // Unparseable arm: base is 249 bytes, so byte 256 lands inside the
4234        // appended `?[redacted]` (starts at 249) pre-fix.
4235        let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4236        assert!(
4237            url::Url::parse(&unparseable).is_err(),
4238            "fixture must not parse: {unparseable}"
4239        );
4240        let redacted = redact_url_for_diagnostics(&unparseable);
4241        assert!(redacted.len() <= 256, "len={}", redacted.len());
4242        assert!(
4243            redacted.ends_with("?[redacted]"),
4244            "unparseable-arm sentinel must render intact: {redacted}"
4245        );
4246    }
4247
4248    #[test]
4249    fn redact_url_suppresses_unparseable_authority_credentials() {
4250        let fixtures = [
4251            "http://u:secretpw@/x",
4252            "http://u:secretpw@host:99999/x",
4253            "http://u:secretpw@host:99999",
4254            "//u:secretpw@h/x",
4255        ];
4256        for fixture in fixtures {
4257            assert!(
4258                url::Url::parse(fixture).is_err(),
4259                "fixture must be unparseable: {fixture}"
4260            );
4261            let redacted = redact_url_for_diagnostics(fixture);
4262            assert_eq!(
4263                redacted, "[redacted]",
4264                "credential-bearing authority must be suppressed: {fixture}"
4265            );
4266        }
4267    }
4268
4269    #[test]
4270    fn redact_url_bd_repro_never_leaks_credentials() {
4271        let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4272        assert!(
4273            !redacted.contains("user:pa%ss"),
4274            "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4275        );
4276        assert!(
4277            !redacted.contains("pa%ss"),
4278            "bd rc-2i5c5 repro leaked password: {redacted}"
4279        );
4280    }
4281
4282    #[test]
4283    fn redact_url_unparseable_query_redacted_short_and_long() {
4284        let short = "http://host:99999/path?token=shortsecret";
4285        assert!(
4286            url::Url::parse(short).is_err(),
4287            "fixture must be unparseable: {short}"
4288        );
4289        let redacted = redact_url_for_diagnostics(short);
4290        assert_eq!(
4291            redacted, "http://host:99999/path?[redacted]",
4292            "short unparseable query must end with the suffix: {redacted}"
4293        );
4294
4295        let mut long = String::from("http://host:99999/");
4296        long.push_str(&"a".repeat(300));
4297        long.push_str("?token=longsecret");
4298        assert!(
4299            url::Url::parse(&long).is_err(),
4300            "fixture must be unparseable: {long}"
4301        );
4302        let redacted = redact_url_for_diagnostics(&long);
4303        assert!(
4304            !redacted.contains("longsecret"),
4305            "long unparseable query leaked a query byte: {redacted}"
4306        );
4307        assert!(
4308            redacted.len() <= 256,
4309            "long unparseable query must be capped: {} bytes",
4310            redacted.len()
4311        );
4312    }
4313
4314    #[test]
4315    fn redact_url_unparseable_sentinels_compose_both() {
4316        // Compose-both rule: one sentinel per distinct introducer found in
4317        // the raw string, in first-occurrence order.
4318        let raw = "ht tp://h.example/p?a=1#tok=x";
4319        assert!(
4320            url::Url::parse(raw).is_err(),
4321            "fixture must be unparseable: {raw}"
4322        );
4323        assert_eq!(
4324            redact_url_for_diagnostics(raw),
4325            "ht tp://h.example/p?[redacted]#[redacted]",
4326            "query and fragment sentinels must compose: {raw}"
4327        );
4328    }
4329
4330    #[test]
4331    fn redact_url_unparseable_sentinels_compose_fragment_first() {
4332        let raw = "ht tp://h.example/p#tok=x?a=1";
4333        assert!(
4334            url::Url::parse(raw).is_err(),
4335            "fixture must be unparseable: {raw}"
4336        );
4337        assert_eq!(
4338            redact_url_for_diagnostics(raw),
4339            "ht tp://h.example/p#[redacted]?[redacted]",
4340            "sentinels must follow the introducers' first-occurrence order: {raw}"
4341        );
4342    }
4343
4344    #[test]
4345    fn redact_url_unparseable_utf8_straddle_no_panic() {
4346        let fixture = format!("a{}", "é".repeat(200));
4347        let redacted = redact_url_for_diagnostics(&fixture);
4348        assert!(
4349            redacted.len() <= 256,
4350            "straddle fixture must be capped: {} bytes",
4351            redacted.len()
4352        );
4353        assert!(
4354            redacted.len() >= 253,
4355            "straddle fixture must not over-truncate: {} bytes",
4356            redacted.len()
4357        );
4358        assert!(
4359            fixture.is_char_boundary(redacted.len()),
4360            "cut must land on a UTF-8 char boundary: {} bytes",
4361            redacted.len()
4362        );
4363    }
4364
4365    #[test]
4366    fn redact_url_at_sign_outside_authority_window_visible() {
4367        let at_sign_in_path = "http://host:99999/x@y";
4368        assert!(
4369            url::Url::parse(at_sign_in_path).is_err(),
4370            "fixture must be unparseable: {at_sign_in_path}"
4371        );
4372        assert_eq!(
4373            redact_url_for_diagnostics(at_sign_in_path),
4374            at_sign_in_path,
4375            "at-sign in path must not be suppressed"
4376        );
4377        // mailto parses as a cannot-be-a-base URL (no is_err precondition).
4378        assert_eq!(
4379            redact_url_for_diagnostics("mailto:user@example.com"),
4380            "mailto:user@example.com",
4381            "at-sign in mailto must round-trip byte-identically"
4382        );
4383    }
4384
4385    #[test]
4386    fn parse_success_fragment_composes() {
4387        // Parsed arm: the fragment stays on the rendered URL and the
4388        // canonical redactor drops it and appends the sentinel.
4389        assert_eq!(
4390            redact_url_for_diagnostics("https://h/p#access_token=x"),
4391            "https://h/p#[redacted]"
4392        );
4393        // A `?` inside the fragment composes both sentinels, in
4394        // first-occurrence order (# before ?).
4395        assert_eq!(
4396            redact_url_for_diagnostics("https://h/cb#f?state=x"),
4397            "https://h/cb#[redacted]?[redacted]"
4398        );
4399    }
4400
4401    #[test]
4402    fn err_arm_delegation_pin() {
4403        // Unparseable (port 99999) with userinfo in the authority window:
4404        // the Err arm delegates wholesale to the fail-closed canonical
4405        // redactor — nothing of the URL is rendered.
4406        assert_eq!(
4407            redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
4408            "[redacted]"
4409        );
4410        // Cross-surface fixture: same unparseable port without userinfo —
4411        // drop at `?`, append the query sentinel.
4412        assert_eq!(
4413            redact_url_for_diagnostics("http://h:99999/p?token=secret"),
4414            "http://h:99999/p?[redacted]"
4415        );
4416    }
4417
4418    #[test]
4419    fn truncate_error_body_caps_attacker_body() {
4420        let big = vec![b'A'; 10 * 1024 * 1024];
4421        let truncated = truncate_error_body(&big);
4422        assert!(
4423            truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
4424            "body must be capped near {} bytes, got {}",
4425            MAX_ERROR_RESPONSE_BODY_BYTES,
4426            truncated.len()
4427        );
4428        assert!(truncated.ends_with("...[truncated]"));
4429    }
4430
4431    #[test]
4432    fn truncate_error_body_keeps_small_body() {
4433        assert_eq!(truncate_error_body(b"boom"), "boom");
4434    }
4435
4436    #[test]
4437    fn test_http_config_defaults() {
4438        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
4439        assert_eq!(config.base_url, "http://localhost:8080/api");
4440        assert!(config.http_method.is_none());
4441        assert!(config.throw_exception_on_failure);
4442        assert_eq!(config.ok_status_code_range, (200, 299));
4443        assert!(config.response_timeout.is_none());
4444        assert!(matches!(config.auth, HttpAuth::None));
4445        assert!(!config.bridge_endpoint);
4446        assert!(!config.connection_close);
4447    }
4448
4449    #[test]
4450    fn test_http_config_scheme() {
4451        // UriConfig trait method returns "http" as primary scheme
4452        assert_eq!(HttpEndpointConfig::scheme(), "http");
4453    }
4454
4455    #[test]
4456    fn test_http_config_from_components() {
4457        // Test from_components directly (trait method)
4458        let components = camel_component_api::UriComponents {
4459            scheme: "https".to_string(),
4460            path: "//api.example.com/v1".to_string(),
4461            params: std::collections::HashMap::from([(
4462                "httpMethod".to_string(),
4463                "POST".to_string(),
4464            )]),
4465            raw_query: None,
4466        };
4467        let config = HttpEndpointConfig::from_components(components).unwrap();
4468        assert_eq!(config.base_url, "https://api.example.com/v1");
4469        assert_eq!(config.http_method, Some("POST".to_string()));
4470    }
4471
4472    #[test]
4473    fn test_http_config_with_options() {
4474        let config = HttpEndpointConfig::from_uri(
4475            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
4476        ).unwrap();
4477        assert_eq!(config.base_url, "https://api.example.com/v1");
4478        assert_eq!(config.http_method, Some("PUT".to_string()));
4479        assert!(!config.throw_exception_on_failure);
4480        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
4481    }
4482
4483    #[test]
4484    fn test_http_endpoint_config_auth_and_headers_options() {
4485        let config = HttpEndpointConfig::from_uri(
4486            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
4487        )
4488        .unwrap();
4489
4490        assert!(matches!(
4491            config.auth,
4492            HttpAuth::Basic { username, password } if username == "u" && password == "p"
4493        ));
4494        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
4495        assert!(config.bridge_endpoint);
4496        assert!(config.connection_close);
4497        assert_eq!(
4498            config.skip_request_headers,
4499            vec!["authorization".to_string(), "x-secret".to_string()]
4500        );
4501        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
4502    }
4503
4504    #[test]
4505    fn test_http_endpoint_config_bearer_auth() {
4506        let config = HttpEndpointConfig::from_uri(
4507            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
4508        )
4509        .unwrap();
4510        assert!(matches!(
4511            config.auth,
4512            HttpAuth::Bearer { token } if token == "t"
4513        ));
4514    }
4515
4516    #[test]
4517    fn rejects_cookie_handling_inmemory() {
4518        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
4519        match result {
4520            Err(CamelError::InvalidUri(msg)) => {
4521                assert!(
4522                    msg.contains("cookieHandling is not supported"),
4523                    "expected rejection message, got: {msg}"
4524                );
4525            }
4526            other => panic!("expected InvalidUri error, got: {other:?}"),
4527        }
4528    }
4529
4530    #[test]
4531    fn rejects_cookie_handling_disabled() {
4532        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
4533        match result {
4534            Err(CamelError::InvalidUri(msg)) => {
4535                assert!(
4536                    msg.contains("cookieHandling is not supported"),
4537                    "expected rejection message, got: {msg}"
4538                );
4539            }
4540            other => panic!("expected InvalidUri error, got: {other:?}"),
4541        }
4542    }
4543
4544    #[test]
4545    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
4546        let config = HttpConfig::default()
4547            .with_response_timeout_ms(999)
4548            .with_allow_internal(true)
4549            .with_blocked_hosts(vec!["evil.com".to_string()])
4550            .with_max_body_size(12345);
4551        let endpoint =
4552            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4553        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4554        assert!(endpoint.allow_internal);
4555        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4556        assert_eq!(endpoint.max_body_size, 12345);
4557    }
4558
4559    #[test]
4560    fn test_from_uri_with_defaults_uri_overrides_config() {
4561        let config = HttpConfig::default()
4562            .with_response_timeout_ms(999)
4563            .with_allow_internal(true)
4564            .with_blocked_hosts(vec!["evil.com".to_string()])
4565            .with_max_body_size(12345);
4566        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4567            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4568            &config,
4569        )
4570        .unwrap();
4571        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4572        assert!(!endpoint.allow_internal);
4573        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4574        assert_eq!(endpoint.max_body_size, 99);
4575    }
4576
4577    #[test]
4578    fn test_http_config_ok_status_range() {
4579        let config =
4580            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4581        assert_eq!(config.ok_status_code_range, (200, 204));
4582    }
4583
4584    #[test]
4585    fn test_http_config_wrong_scheme() {
4586        let result = HttpEndpointConfig::from_uri("file:/tmp");
4587        assert!(result.is_err());
4588    }
4589
4590    #[test]
4591    fn test_http_component_scheme() {
4592        let component = HttpComponent::new();
4593        assert_eq!(component.scheme(), "http");
4594    }
4595
4596    // -----------------------------------------------------------------------
4597    // tls.strict — fail-closed knob (audit 2026-08-31 R3 / rc-ayrwk).
4598    // Default stays permissive (F2-7 warns); strict fails endpoint creation
4599    // on any CA/mTLS load failure.
4600    // -----------------------------------------------------------------------
4601
4602    #[test]
4603    fn tls_strict_defaults_false_on_deserialize() {
4604        let tls: TlsConfig = serde_json::from_value(serde_json::json!({
4605            "enabled": true
4606        }))
4607        .unwrap();
4608        assert!(!tls.strict, "absent strict must default to false");
4609    }
4610
4611    fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
4612        HttpConfig {
4613            tls: Some(TlsConfig {
4614                enabled: true,
4615                strict,
4616                ca_cert_path: ca_path.map(|p| p.to_string()),
4617                ..TlsConfig::default()
4618            }),
4619            ..HttpConfig::default()
4620        }
4621    }
4622
4623    #[test]
4624    fn strict_tls_missing_ca_fails_endpoint_creation() {
4625        let component =
4626            HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
4627        let err = component
4628            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4629            .err()
4630            .expect("strict + missing CA must fail endpoint creation");
4631        assert!(
4632            err.to_string().contains("tls.strict"),
4633            "must name the strict knob: {err}"
4634        );
4635        assert!(
4636            err.to_string().contains("unreadable"),
4637            "must name the failure class: {err}"
4638        );
4639    }
4640
4641    #[test]
4642    fn strict_tls_unparseable_ca_fails_endpoint_creation() {
4643        let path = camel_component_api::test_support::tls::write_pem_tmp(
4644            "strict-bad-ca.pem",
4645            "not a certificate",
4646        );
4647        let component =
4648            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4649        let err = component
4650            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4651            .err()
4652            .expect("strict + unparseable CA must fail endpoint creation");
4653        assert!(
4654            err.to_string()
4655                .contains("no parseable PEM CERTIFICATE section"),
4656            "must name the failure class: {err}"
4657        );
4658    }
4659
4660    #[test]
4661    fn strict_tls_der_file_rejected_not_certified() {
4662        // e_glm stage-4 finding 1: a DER-looking file (first byte 0x30 =
4663        // ASCII '0') must NOT pass strict — the rustls backend never
4664        // enforces lone-DER bundles, so certifying one would certify an
4665        // unenforced config.
4666        let path = camel_component_api::test_support::tls::write_pem_tmp(
4667            "strict-der-ca.pem",
4668            "00garbage-bytes",
4669        );
4670        let component =
4671            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4672        let err = component
4673            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4674            .err()
4675            .expect("strict + DER file must fail endpoint creation");
4676        assert!(
4677            err.to_string().contains("convert to PEM"),
4678            "must tell the operator to convert: {err}"
4679        );
4680    }
4681
4682    #[test]
4683    fn strict_tls_half_mtls_pair_rejected() {
4684        // e_glm stage-4 finding 2: cert XOR key must fail under strict,
4685        // not silently degrade to non-mTLS.
4686        let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
4687        let component = HttpComponent::with_config(cfg);
4688        let err = component
4689            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4690            .err()
4691            .expect("strict + half mTLS pair must fail endpoint creation");
4692        assert!(
4693            err.to_string().contains("BOTH"),
4694            "must name the pair requirement: {err}"
4695        );
4696    }
4697
4698    #[test]
4699    fn strict_tls_valid_material_allows_endpoint_creation() {
4700        let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
4701        let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
4702        let component =
4703            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4704        assert!(
4705            component
4706                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4707                .is_ok(),
4708            "valid CA under strict must create the endpoint"
4709        );
4710    }
4711
4712    #[test]
4713    fn permissive_missing_ca_keeps_back_compat() {
4714        // strict absent (false): the F2-7 warn-and-fallback behavior stays;
4715        // endpoint creation succeeds.
4716        let component =
4717            HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
4718        assert!(
4719            component
4720                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4721                .is_ok(),
4722            "permissive mode must keep the back-compat fallback"
4723        );
4724    }
4725
4726    fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
4727        HttpConfig {
4728            tls: Some(TlsConfig {
4729                enabled: true,
4730                strict: true,
4731                client_cert_path: cert_path.map(|p| p.to_string()),
4732                client_key_path: key_path.map(|p| p.to_string()),
4733                ..TlsConfig::default()
4734            }),
4735            ..HttpConfig::default()
4736        }
4737    }
4738
4739    #[test]
4740    fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
4741        // Key present, cert file missing: a half-readable mTLS pair must
4742        // fail creation under strict, not silently drop the identity.
4743        let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4744        let key_path =
4745            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
4746        let component = HttpComponent::with_config(strict_mtls_config(
4747            Some("/nonexistent/cert.pem"),
4748            Some(key_path.to_str().unwrap()),
4749        ));
4750        let err = component
4751            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4752            .err()
4753            .expect("strict + unreadable mTLS pair must fail endpoint creation");
4754        assert!(
4755            err.to_string().contains("tls.strict"),
4756            "must name the strict knob: {err}"
4757        );
4758        assert!(
4759            err.to_string().contains("unreadable"),
4760            "must name the failure class: {err}"
4761        );
4762    }
4763
4764    #[test]
4765    fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
4766        let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4767        let cert_path =
4768            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
4769        let key_path =
4770            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
4771        let component = HttpComponent::with_config(strict_mtls_config(
4772            Some(cert_path.to_str().unwrap()),
4773            Some(key_path.to_str().unwrap()),
4774        ));
4775        assert!(
4776            component
4777                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4778                .is_ok(),
4779            "valid mTLS pair under strict must create the endpoint"
4780        );
4781    }
4782
4783    #[test]
4784    fn test_https_component_scheme() {
4785        let component = HttpsComponent::new();
4786        assert_eq!(component.scheme(), "https");
4787    }
4788
4789    #[test]
4790    fn test_http_endpoint_creates_consumer() {
4791        let component = HttpComponent::new();
4792        let ctx = NoOpComponentContext;
4793        let endpoint = component
4794            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
4795            .unwrap();
4796        assert!(endpoint.create_consumer(rt()).is_ok());
4797    }
4798
4799    #[test]
4800    fn test_https_endpoint_creates_consumer_errors_without_tls() {
4801        let component = HttpsComponent::new();
4802        let ctx = NoOpComponentContext;
4803        let endpoint = component
4804            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
4805            .unwrap();
4806        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
4807        assert!(endpoint.create_consumer(rt()).is_err());
4808    }
4809
4810    #[test]
4811    fn test_http_endpoint_creates_producer() {
4812        let ctx = test_producer_ctx();
4813        let component = HttpComponent::new();
4814        let endpoint_ctx = NoOpComponentContext;
4815        let endpoint = component
4816            .create_endpoint("http://localhost/api", &endpoint_ctx)
4817            .unwrap();
4818        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
4819    }
4820
4821    // -----------------------------------------------------------------------
4822    // Producer tests
4823    // -----------------------------------------------------------------------
4824
4825    #[tokio::test]
4826    async fn test_producer_with_token_provider() {
4827        use camel_auth::oauth2::TokenProvider;
4828        use tower::ServiceExt;
4829
4830        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
4831            Arc::new(std::sync::Mutex::new(None));
4832        let captured_clone = Arc::clone(&captured_auth);
4833
4834        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4835        let port = listener.local_addr().unwrap().port();
4836
4837        let _handle = tokio::spawn(async move {
4838            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4839            if let Ok((mut stream, _)) = listener.accept().await {
4840                let mut buf = vec![0u8; 8192];
4841                let n = stream.read(&mut buf).await.unwrap_or(0);
4842                let request = String::from_utf8_lossy(&buf[..n]).to_string();
4843                let auth = request
4844                    .lines()
4845                    .find(|l| l.to_lowercase().starts_with("authorization:"))
4846                    .map(|l| {
4847                        l.split(':')
4848                            .nth(1)
4849                            .map(|s| s.trim().to_string())
4850                            .unwrap_or_default()
4851                    });
4852                *captured_clone.lock().unwrap() = auth;
4853                let body = r#"{"echo":"ok"}"#;
4854                let resp = format!(
4855                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4856                    body.len(),
4857                    body
4858                );
4859                let _ = stream.write_all(resp.as_bytes()).await;
4860            }
4861        });
4862
4863        #[derive(Debug)]
4864        struct StaticProvider;
4865        #[async_trait::async_trait]
4866        impl TokenProvider for StaticProvider {
4867            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
4868                Ok("injected-token".into())
4869            }
4870        }
4871
4872        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
4873        let ctx = test_producer_ctx();
4874        let component = HttpComponent::new();
4875        let endpoint_ctx = NoOpComponentContext;
4876        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
4877        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4878
4879        let exchange = Exchange::new(Message::new("hello"));
4880
4881        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
4882        let mut layered = layer.layer(producer);
4883        let result = layered.ready().await.unwrap().call(exchange).await;
4884        assert!(result.is_ok(), "producer call failed: {:?}", result);
4885
4886        tokio::time::sleep(Duration::from_millis(100)).await;
4887        let auth = captured_auth.lock().unwrap().take();
4888        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4889    }
4890
4891    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4892        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4893        let addr = listener.local_addr().unwrap();
4894        let url = format!("http://127.0.0.1:{}", addr.port());
4895
4896        let handle = tokio::spawn(async move {
4897            loop {
4898                if let Ok((mut stream, _)) = listener.accept().await {
4899                    tokio::spawn(async move {
4900                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4901                        let mut buf = vec![0u8; 4096];
4902                        let n = stream.read(&mut buf).await.unwrap_or(0);
4903                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
4904
4905                        let method = request.split_whitespace().next().unwrap_or("GET");
4906
4907                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4908                        let response = format!(
4909                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4910                            body.len(),
4911                            body
4912                        );
4913                        let _ = stream.write_all(response.as_bytes()).await;
4914                    });
4915                }
4916            }
4917        });
4918
4919        (url, handle)
4920    }
4921
4922    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4923        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4924        let addr = listener.local_addr().unwrap();
4925        let url = format!("http://127.0.0.1:{}", addr.port());
4926
4927        let handle = tokio::spawn(async move {
4928            loop {
4929                if let Ok((mut stream, _)) = listener.accept().await {
4930                    let status = status;
4931                    tokio::spawn(async move {
4932                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4933                        let mut buf = vec![0u8; 4096];
4934                        let _ = stream.read(&mut buf).await;
4935
4936                        let status_text = match status {
4937                            404 => "Not Found",
4938                            500 => "Internal Server Error",
4939                            _ => "Error",
4940                        };
4941                        let body = "error body";
4942                        let response = format!(
4943                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4944                            status,
4945                            status_text,
4946                            body.len(),
4947                            body
4948                        );
4949                        let _ = stream.write_all(response.as_bytes()).await;
4950                    });
4951                }
4952            }
4953        });
4954
4955        (url, handle)
4956    }
4957
4958    async fn start_request_capturing_server() -> (
4959        String,
4960        Arc<std::sync::Mutex<Option<String>>>,
4961        tokio::task::JoinHandle<()>,
4962    ) {
4963        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4964        let port = listener.local_addr().unwrap().port();
4965        let url = format!("http://127.0.0.1:{port}");
4966        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
4967        let captured_clone = Arc::clone(&captured);
4968        let handle = tokio::spawn(async move {
4969            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4970            if let Ok((mut stream, _)) = listener.accept().await {
4971                let mut buf = vec![0u8; 16384];
4972                let n = stream.read(&mut buf).await.unwrap_or(0);
4973                let request = String::from_utf8_lossy(&buf[..n]).to_string();
4974                if request.contains("\r\n\r\n") {
4975                    *captured_clone.lock().unwrap() = Some(request);
4976                }
4977                let body = r#"{"echo":"ok"}"#;
4978                let resp = format!(
4979                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4980                    body.len(),
4981                    body
4982                );
4983                let _ = stream.write_all(resp.as_bytes()).await;
4984            }
4985        });
4986        (url, captured, handle)
4987    }
4988
4989    #[tokio::test]
4990    async fn test_http_producer_get_request() {
4991        use tower::ServiceExt;
4992
4993        let (url, _handle) = start_test_server().await;
4994        let ctx = test_producer_ctx();
4995
4996        let component = HttpComponent::new();
4997        let endpoint_ctx = NoOpComponentContext;
4998        let endpoint = component
4999            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5000            .unwrap();
5001        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5002
5003        let exchange = Exchange::new(Message::default());
5004        let result = producer.oneshot(exchange).await.unwrap();
5005
5006        let status = result
5007            .input
5008            .header("CamelHttpResponseCode")
5009            .and_then(|v| v.as_u64())
5010            .unwrap();
5011        assert_eq!(status, 200);
5012
5013        assert!(!result.input.body.is_empty());
5014    }
5015
5016    #[tokio::test]
5017    async fn producer_excludes_host_and_framing() {
5018        use tower::ServiceExt;
5019
5020        let (url, captured, _handle) = start_request_capturing_server().await;
5021        let ctx = test_producer_ctx();
5022        let component = HttpComponent::new();
5023        let endpoint_ctx = NoOpComponentContext;
5024        let endpoint = component
5025            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5026            .unwrap();
5027        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5028
5029        let mut exchange = Exchange::new(Message::default());
5030        exchange.input.set_header("Host", "localhost");
5031        exchange.input.set_header("Content-Length", "42");
5032        exchange.input.set_header("Connection", "keep-alive");
5033        exchange.input.set_header("Upgrade", "h2c");
5034
5035        let result = producer.oneshot(exchange).await;
5036        assert!(result.is_ok(), "producer call failed: {:?}", result);
5037
5038        tokio::time::sleep(Duration::from_millis(100)).await;
5039        let request = captured
5040            .lock()
5041            .unwrap()
5042            .take()
5043            .expect("no outbound request captured");
5044        let lower = request.to_ascii_lowercase();
5045        assert!(
5046            !lower.contains("\r\nhost: localhost"),
5047            "forwarded Host: localhost must be stripped\n{request}"
5048        );
5049        assert!(
5050            !lower.contains("content-length: 42"),
5051            "exchange Content-Length must not be copied\n{request}"
5052        );
5053        assert!(
5054            !lower.lines().any(|l| l.starts_with("connection:")),
5055            "Connection header must not be forwarded\n{request}"
5056        );
5057        assert!(
5058            !lower.lines().any(|l| l.starts_with("upgrade:")),
5059            "Upgrade header must not be forwarded\n{request}"
5060        );
5061        let host_header = lower
5062            .lines()
5063            .find(|l| l.starts_with("host:"))
5064            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5065            .expect("outbound Host header must be set by reqwest");
5066        assert!(
5067            host_header.starts_with("127.0.0.1:"),
5068            "outbound Host '{host_header}' must match the capture-server address"
5069        );
5070    }
5071
5072    #[tokio::test]
5073    async fn producer_forwards_request_only_headers() {
5074        use tower::ServiceExt;
5075
5076        let (url, captured, _handle) = start_request_capturing_server().await;
5077        let ctx = test_producer_ctx();
5078        let component = HttpComponent::new();
5079        let endpoint_ctx = NoOpComponentContext;
5080        let endpoint = component
5081            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5082            .unwrap();
5083        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5084
5085        let mut exchange = Exchange::new(Message::default());
5086        exchange.input.set_header("Accept", "application/json");
5087        exchange.input.set_header("User-Agent", "myclient/1.0");
5088
5089        let result = producer.oneshot(exchange).await;
5090        assert!(result.is_ok(), "producer call failed: {:?}", result);
5091
5092        tokio::time::sleep(Duration::from_millis(100)).await;
5093        let request = captured
5094            .lock()
5095            .unwrap()
5096            .take()
5097            .expect("no outbound request captured");
5098        let lower = request.to_ascii_lowercase();
5099        assert!(
5100            lower.contains("accept: application/json"),
5101            "request-only Accept header must be forwarded\n{request}"
5102        );
5103        assert!(
5104            lower.contains("user-agent: myclient/1.0"),
5105            "request-only User-Agent header must be forwarded\n{request}"
5106        );
5107    }
5108
5109    // -----------------------------------------------------------------------
5110    // Configured-header construction failures are surfaced, never silent
5111    // (rc-jbs1v)
5112    // -----------------------------------------------------------------------
5113
5114    /// Build an endpoint whose URI parses normally but whose `user_agent`
5115    /// and `auth` are then overridden programmatically, so CRLF-bearing
5116    /// test values never pass through URI parsing.
5117    fn endpoint_with_config_overrides(
5118        base_url: &str,
5119        user_agent: Option<String>,
5120        auth: HttpAuth,
5121    ) -> HttpEndpoint {
5122        let uri = format!("{base_url}/api/test?allowInternal=true");
5123        let mut config =
5124            HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5125        config.user_agent = user_agent;
5126        config.auth = auth;
5127        HttpEndpoint {
5128            uri: uri.clone(),
5129            config,
5130            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5131            client: reqwest::Client::new(),
5132            pinned_cache: Arc::new(PinnedClientCache::new(
5133                PINNED_CLIENT_TTL,
5134                PINNED_CLIENT_MAX_ENTRIES,
5135            )),
5136            http_config: HttpConfig::default(),
5137        }
5138    }
5139
5140    /// A configured user-agent / bearer token that fails `HeaderValue`
5141    /// construction must be dropped with a DEBUG record (name + reason
5142    /// only, never the value — ADR-0051) and reach the wire absent, while
5143    /// a valid config passes through unchanged.
5144    #[tracing_test::traced_test]
5145    #[tokio::test]
5146    async fn producer_invalid_configured_headers_surfaced() {
5147        use tower::ServiceExt;
5148
5149        let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5150        let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5151        let ctx = test_producer_ctx();
5152
5153        let bad_producer = endpoint_with_config_overrides(
5154            &bad_url,
5155            Some("bad\r\nua".to_string()),
5156            HttpAuth::Bearer {
5157                token: "tok\r\nen".to_string(),
5158            },
5159        )
5160        .create_producer(rt(), &ctx)
5161        .unwrap();
5162        let ok_producer = endpoint_with_config_overrides(
5163            &ok_url,
5164            Some("httpsweep-ok/1".to_string()),
5165            HttpAuth::Bearer {
5166                token: "valid-token".to_string(),
5167            },
5168        )
5169        .create_producer(rt(), &ctx)
5170        .unwrap();
5171
5172        let bad_exchange = Exchange::new(Message::default());
5173        let ok_exchange = Exchange::new(Message::default());
5174        let bad_cid = bad_exchange.correlation_id().to_string();
5175        let ok_cid = ok_exchange.correlation_id().to_string();
5176
5177        let bad_result = bad_producer.oneshot(bad_exchange).await;
5178        assert!(
5179            bad_result.is_ok(),
5180            "invalid-config producer call failed: {bad_result:?}"
5181        );
5182        let ok_result = ok_producer.oneshot(ok_exchange).await;
5183        assert!(
5184            ok_result.is_ok(),
5185            "valid-config producer call failed: {ok_result:?}"
5186        );
5187
5188        tokio::time::sleep(Duration::from_millis(100)).await;
5189        let bad_request = bad_captured
5190            .lock()
5191            .unwrap()
5192            .take()
5193            .expect("no outbound request captured");
5194        let ok_request = ok_captured
5195            .lock()
5196            .unwrap()
5197            .take()
5198            .expect("no outbound request captured");
5199
5200        // Invalid config: neither header reaches the wire. Value-absence,
5201        // not "any UA" — reqwest may inject a default user-agent.
5202        let bad_lower = bad_request.to_ascii_lowercase();
5203        assert!(
5204            !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5205            "invalid Bearer token must not reach the wire\n{bad_request}"
5206        );
5207        assert!(
5208            !bad_request.contains("bad\r\nua"),
5209            "invalid configured user-agent must not reach the wire\n{bad_request}"
5210        );
5211
5212        logs_assert(|lines: &[&str]| {
5213            let drops: Vec<&&str> = lines
5214                .iter()
5215                .filter(|l| {
5216                    l.contains("outbound header dropped")
5217                        && l.contains(&format!("correlation_id={bad_cid}"))
5218                })
5219                .collect();
5220            if drops.len() != 2 {
5221                return Err(format!(
5222                    "expected exactly 2 drop records for {bad_cid}, found {}",
5223                    drops.len()
5224                ));
5225            }
5226            let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5227            let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5228            let reason_ok = drops
5229                .iter()
5230                .all(|l| l.contains("outbound header dropped: invalid header value"));
5231            match (has_ua, has_auth, reason_ok) {
5232                (true, true, true) => Ok(()),
5233                _ => Err(format!(
5234                    "drop records mismatched: user-agent={has_ua} \
5235                     authorization={has_auth} reason-ok={reason_ok}"
5236                )),
5237            }
5238        });
5239        logs_assert(|lines: &[&str]| {
5240            if lines
5241                .iter()
5242                .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5243            {
5244                Err("sentinel CRLF values leaked into logs".to_string())
5245            } else {
5246                Ok(())
5247            }
5248        });
5249
5250        // Valid config: both headers reach the wire exactly as configured,
5251        // with zero drop records.
5252        let ok_lower = ok_request.to_ascii_lowercase();
5253        assert!(
5254            ok_lower.contains("user-agent: httpsweep-ok/1"),
5255            "valid configured user-agent must reach the wire\n{ok_request}"
5256        );
5257        assert!(
5258            ok_lower.contains("authorization: bearer valid-token"),
5259            "valid Bearer token must reach the wire\n{ok_request}"
5260        );
5261        logs_assert(|lines: &[&str]| {
5262            let hits = lines
5263                .iter()
5264                .filter(|l| {
5265                    l.contains("outbound header dropped")
5266                        && l.contains(&format!("correlation_id={ok_cid}"))
5267                })
5268                .count();
5269            match hits {
5270                0 => Ok(()),
5271                n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5272            }
5273        });
5274    }
5275
5276    #[tokio::test]
5277    async fn producer_honours_skip_request_headers() {
5278        use tower::ServiceExt;
5279
5280        let (url, captured, _handle) = start_request_capturing_server().await;
5281        let ctx = test_producer_ctx();
5282        let component = HttpComponent::new();
5283        let endpoint_ctx = NoOpComponentContext;
5284        let endpoint = component
5285            .create_endpoint(
5286                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5287                &endpoint_ctx,
5288            )
5289            .unwrap();
5290        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5291
5292        let mut exchange = Exchange::new(Message::default());
5293        exchange.input.set_header("Authorization", "Bearer x");
5294
5295        let result = producer.oneshot(exchange).await;
5296        assert!(result.is_ok(), "producer call failed: {:?}", result);
5297
5298        tokio::time::sleep(Duration::from_millis(100)).await;
5299        let request = captured
5300            .lock()
5301            .unwrap()
5302            .take()
5303            .expect("no outbound request captured");
5304        assert!(
5305            !request.to_ascii_lowercase().contains("authorization"),
5306            "Authorization must be stripped by skipRequestHeaders\n{request}"
5307        );
5308    }
5309
5310    #[tokio::test]
5311    async fn producer_stringifies_scalar_header_values_on_wire() {
5312        use tower::ServiceExt;
5313
5314        let (url, captured, _handle) = start_request_capturing_server().await;
5315        let ctx = test_producer_ctx();
5316        let component = HttpComponent::new();
5317        let endpoint_ctx = NoOpComponentContext;
5318        let endpoint = component
5319            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5320            .unwrap();
5321        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5322
5323        let mut exchange = Exchange::new(Message::default());
5324        exchange.input.set_header("X-Retries", serde_json::json!(3));
5325        exchange
5326            .input
5327            .set_header("X-Enabled", serde_json::json!(true));
5328        exchange
5329            .input
5330            .set_header("X-Obj", serde_json::json!({"a": 1}));
5331
5332        let result = producer.oneshot(exchange).await;
5333        assert!(result.is_ok(), "producer call failed: {:?}", result);
5334
5335        tokio::time::sleep(Duration::from_millis(100)).await;
5336        let request = captured
5337            .lock()
5338            .unwrap()
5339            .take()
5340            .expect("no outbound request captured");
5341        let lower = request.to_ascii_lowercase();
5342        assert!(
5343            lower.contains("x-retries: 3"),
5344            "numeric header must reach the wire stringified\n{request}"
5345        );
5346        assert!(
5347            lower.contains("x-enabled: true"),
5348            "bool header must reach the wire stringified\n{request}"
5349        );
5350        assert!(
5351            !lower.contains("x-obj:"),
5352            "object header has no single-value form and must not reach the wire\n{request}"
5353        );
5354    }
5355
5356    #[tokio::test]
5357    async fn test_http_producer_post_with_body() {
5358        use tower::ServiceExt;
5359
5360        let (url, _handle) = start_test_server().await;
5361        let ctx = test_producer_ctx();
5362
5363        let component = HttpComponent::new();
5364        let endpoint_ctx = NoOpComponentContext;
5365        let endpoint = component
5366            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
5367            .unwrap();
5368        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5369
5370        let exchange = Exchange::new(Message::new("request body"));
5371        let result = producer.oneshot(exchange).await.unwrap();
5372
5373        let status = result
5374            .input
5375            .header("CamelHttpResponseCode")
5376            .and_then(|v| v.as_u64())
5377            .unwrap();
5378        assert_eq!(status, 200);
5379    }
5380
5381    #[tokio::test]
5382    async fn test_http_producer_method_from_header() {
5383        use tower::ServiceExt;
5384
5385        let (url, _handle) = start_test_server().await;
5386        let ctx = test_producer_ctx();
5387
5388        let component = HttpComponent::new();
5389        let endpoint_ctx = NoOpComponentContext;
5390        let endpoint = component
5391            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5392            .unwrap();
5393        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5394
5395        let mut exchange = Exchange::new(Message::default());
5396        exchange.input.set_header(
5397            "CamelHttpMethod",
5398            serde_json::Value::String("DELETE".to_string()),
5399        );
5400
5401        let result = producer.oneshot(exchange).await.unwrap();
5402        let status = result
5403            .input
5404            .header("CamelHttpResponseCode")
5405            .and_then(|v| v.as_u64())
5406            .unwrap();
5407        assert_eq!(status, 200);
5408    }
5409
5410    #[tokio::test]
5411    async fn test_http_producer_forced_method() {
5412        use tower::ServiceExt;
5413
5414        let (url, _handle) = start_test_server().await;
5415        let ctx = test_producer_ctx();
5416
5417        let component = HttpComponent::new();
5418        let endpoint_ctx = NoOpComponentContext;
5419        let endpoint = component
5420            .create_endpoint(
5421                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
5422                &endpoint_ctx,
5423            )
5424            .unwrap();
5425        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5426
5427        let exchange = Exchange::new(Message::default());
5428        let result = producer.oneshot(exchange).await.unwrap();
5429
5430        let status = result
5431            .input
5432            .header("CamelHttpResponseCode")
5433            .and_then(|v| v.as_u64())
5434            .unwrap();
5435        assert_eq!(status, 200);
5436    }
5437
5438    #[tokio::test]
5439    async fn test_http_producer_throw_exception_on_failure() {
5440        use tower::ServiceExt;
5441
5442        let (url, _handle) = start_status_server(404).await;
5443        let ctx = test_producer_ctx();
5444
5445        let component = HttpComponent::new();
5446        let endpoint_ctx = NoOpComponentContext;
5447        let endpoint = component
5448            .create_endpoint(
5449                &format!("{url}/not-found?allowInternal=true"),
5450                &endpoint_ctx,
5451            )
5452            .unwrap();
5453        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5454
5455        let exchange = Exchange::new(Message::default());
5456        let result = producer.oneshot(exchange).await;
5457        assert!(result.is_err());
5458
5459        match result.unwrap_err() {
5460            CamelError::HttpOperationFailed { status_code, .. } => {
5461                assert_eq!(status_code, 404);
5462            }
5463            e => panic!("Expected HttpOperationFailed, got: {e}"),
5464        }
5465    }
5466
5467    #[tokio::test]
5468    async fn test_http_producer_no_throw_on_failure() {
5469        use tower::ServiceExt;
5470
5471        let (url, _handle) = start_status_server(500).await;
5472        let ctx = test_producer_ctx();
5473
5474        let component = HttpComponent::new();
5475        let endpoint_ctx = NoOpComponentContext;
5476        let endpoint = component
5477            .create_endpoint(
5478                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
5479                &endpoint_ctx,
5480            )
5481            .unwrap();
5482        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5483
5484        let exchange = Exchange::new(Message::default());
5485        let result = producer.oneshot(exchange).await.unwrap();
5486
5487        let status = result
5488            .input
5489            .header("CamelHttpResponseCode")
5490            .and_then(|v| v.as_u64())
5491            .unwrap();
5492        assert_eq!(status, 500);
5493    }
5494
5495    #[tokio::test]
5496    async fn test_http_producer_uri_override() {
5497        use tower::ServiceExt;
5498
5499        let (url, _handle) = start_test_server().await;
5500        let ctx = test_producer_ctx();
5501
5502        let component = HttpComponent::new();
5503        let endpoint_ctx = NoOpComponentContext;
5504        let endpoint = component
5505            .create_endpoint(
5506                "http://localhost:1/does-not-exist?allowInternal=true",
5507                &endpoint_ctx,
5508            )
5509            .unwrap();
5510        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5511
5512        let mut exchange = Exchange::new(Message::default());
5513        exchange.input.set_header(
5514            "CamelHttpUri",
5515            serde_json::Value::String(format!("{url}/api")),
5516        );
5517
5518        let result = producer.oneshot(exchange).await.unwrap();
5519        let status = result
5520            .input
5521            .header("CamelHttpResponseCode")
5522            .and_then(|v| v.as_u64())
5523            .unwrap();
5524        assert_eq!(status, 200);
5525    }
5526
5527    #[tokio::test]
5528    async fn test_http_producer_response_headers_mapped() {
5529        use tower::ServiceExt;
5530
5531        let (url, _handle) = start_test_server().await;
5532        let ctx = test_producer_ctx();
5533
5534        let component = HttpComponent::new();
5535        let endpoint_ctx = NoOpComponentContext;
5536        let endpoint = component
5537            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5538            .unwrap();
5539        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5540
5541        let exchange = Exchange::new(Message::default());
5542        let result = producer.oneshot(exchange).await.unwrap();
5543
5544        assert!(
5545            result.input.header("Content-Type").is_some(),
5546            "Response should have Content-Type header"
5547        );
5548        assert!(result.input.header("CamelHttpResponseText").is_some());
5549    }
5550
5551    // -----------------------------------------------------------------------
5552    // Bug fix tests: Client configuration per-endpoint
5553    // -----------------------------------------------------------------------
5554
5555    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
5556        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5557        let addr = listener.local_addr().unwrap();
5558        let url = format!("http://127.0.0.1:{}", addr.port());
5559
5560        let handle = tokio::spawn(async move {
5561            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5562            loop {
5563                if let Ok((mut stream, _)) = listener.accept().await {
5564                    tokio::spawn(async move {
5565                        let mut buf = vec![0u8; 4096];
5566                        let n = stream.read(&mut buf).await.unwrap_or(0);
5567                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
5568
5569                        // Check if this is a request to /final
5570                        if request.contains("GET /final") {
5571                            let body = r#"{"status":"final"}"#;
5572                            let response = format!(
5573                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5574                                body.len(),
5575                                body
5576                            );
5577                            let _ = stream.write_all(response.as_bytes()).await;
5578                        } else {
5579                            // Redirect to /final
5580                            // Connection: close stops the client pooling the
5581                            // connection the server drops right after this
5582                            // response (pooled-race, rc-u3aw class).
5583                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5584                            let _ = stream.write_all(response.as_bytes()).await;
5585                        }
5586                    });
5587                }
5588            }
5589        });
5590
5591        (url, handle)
5592    }
5593
5594    struct CapturedRequest {
5595        method: String,
5596        path: String,
5597        body: Vec<u8>,
5598        content_length: Option<String>,
5599        transfer_encoding: Option<String>,
5600    }
5601
5602    /// Parse a request head plus its Content-Length-driven body from a freshly
5603    /// accepted connection. Returns `None` if the client closes before sending
5604    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
5605    /// keep-alive connections and never sends FIN) and does NOT rely on a
5606    /// single fixed-size read (a segmented small body would flake).
5607    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
5608        use tokio::io::AsyncReadExt;
5609
5610        // Read the request head (up to and including the terminating CRLF CRLF).
5611        let mut buf: Vec<u8> = Vec::new();
5612        let mut chunk = [0u8; 4096];
5613        let head_end: usize;
5614        loop {
5615            let n = stream.read(&mut chunk).await.unwrap_or(0);
5616            if n == 0 {
5617                return None;
5618            }
5619            buf.extend_from_slice(&chunk[..n]);
5620            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
5621                head_end = pos + 4;
5622                break;
5623            }
5624        }
5625
5626        // Parse the request head.
5627        let head = String::from_utf8_lossy(&buf[..head_end]);
5628        let mut lines = head.split("\r\n");
5629        let request_line = lines.next().unwrap_or("");
5630        let mut parts = request_line.split_whitespace();
5631        let method = parts.next().unwrap_or("").to_string();
5632        let path = parts.next().unwrap_or("").to_string();
5633
5634        let mut content_length: Option<String> = None;
5635        let mut transfer_encoding: Option<String> = None;
5636        for line in lines {
5637            if let Some((name, value)) = line.split_once(':') {
5638                let name = name.trim().to_ascii_lowercase();
5639                let value = value.trim().to_string();
5640                if name == "content-length" {
5641                    content_length = Some(value);
5642                } else if name == "transfer-encoding" {
5643                    transfer_encoding = Some(value);
5644                }
5645            }
5646        }
5647
5648        // Content-Length-driven exact read. A missing header means a 0-length body.
5649        let body_len: usize = content_length
5650            .as_deref()
5651            .and_then(|v| v.parse::<usize>().ok())
5652            .unwrap_or(0);
5653
5654        let mut body: Vec<u8> = buf[head_end..].to_vec();
5655        while body.len() < body_len {
5656            let n = stream.read(&mut chunk).await.unwrap_or(0);
5657            if n == 0 {
5658                break;
5659            }
5660            body.extend_from_slice(&chunk[..n]);
5661        }
5662        body.truncate(body_len);
5663
5664        Some(CapturedRequest {
5665            method,
5666            path,
5667            body,
5668            content_length,
5669            transfer_encoding,
5670        })
5671    }
5672
5673    /// A raw-TCP capture server. Each connection parses the request head, then
5674    /// performs a Content-Length-driven exact read of the body (see
5675    /// [`capture_request`]). Each connection is dropped after the response so
5676    /// every hop opens a fresh connection.
5677    async fn start_capture_server() -> (
5678        String,
5679        tokio::task::JoinHandle<()>,
5680        Arc<Mutex<Vec<CapturedRequest>>>,
5681    ) {
5682        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5683        let addr = listener.local_addr().unwrap();
5684        let url = format!("http://127.0.0.1:{}", addr.port());
5685
5686        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5687        let captured_for_return = Arc::clone(&captured);
5688
5689        let handle = tokio::spawn(async move {
5690            use tokio::io::AsyncWriteExt;
5691            loop {
5692                if let Ok((mut stream, _)) = listener.accept().await {
5693                    let captured = Arc::clone(&captured);
5694                    tokio::spawn(async move {
5695                        let Some(req) = capture_request(&mut stream).await else {
5696                            return;
5697                        };
5698                        captured.lock().unwrap().push(req);
5699
5700                        // 200 OK with Content-Length: 0 and no body, then drop
5701                        // the stream so the client opens a fresh connection.
5702                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
5703                        let _ = stream.write_all(response.as_bytes()).await;
5704                    });
5705                }
5706            }
5707        });
5708
5709        (url, handle, captured_for_return)
5710    }
5711
5712    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
5713    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
5714    /// whose `/final` path answers `200 OK` with an empty body. Every hop
5715    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
5716    /// the connection after responding so each hop is a fresh connection.
5717    async fn start_redirect_capture_server() -> (
5718        String,
5719        tokio::task::JoinHandle<()>,
5720        Arc<Mutex<Vec<CapturedRequest>>>,
5721    ) {
5722        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5723        let addr = listener.local_addr().unwrap();
5724        let url = format!("http://127.0.0.1:{}", addr.port());
5725
5726        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5727        let captured_for_return = Arc::clone(&captured);
5728
5729        let handle = tokio::spawn(async move {
5730            use tokio::io::AsyncWriteExt;
5731            loop {
5732                if let Ok((mut stream, _)) = listener.accept().await {
5733                    let captured = Arc::clone(&captured);
5734                    tokio::spawn(async move {
5735                        let Some(req) = capture_request(&mut stream).await else {
5736                            return;
5737                        };
5738                        let path = req.path.clone();
5739                        captured.lock().unwrap().push(req);
5740
5741                        let (status_line, location) = match path.as_str() {
5742                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5743                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5744                            "/final" => ("HTTP/1.1 200 OK", None),
5745                            _ => ("HTTP/1.1 404 Not Found", None),
5746                        };
5747
5748                        let response = match location {
5749                            // Connection: close stops the client pooling the
5750                            // connection this handler drops right after the
5751                            // response (pooled-race, rc-u3aw class).
5752                            Some(loc) => format!(
5753                                "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5754                            ),
5755                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5756                        };
5757                        let _ = stream.write_all(response.as_bytes()).await;
5758                    });
5759                }
5760            }
5761        });
5762
5763        (url, handle, captured_for_return)
5764    }
5765
5766    #[tokio::test]
5767    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5768        use tower::ServiceExt;
5769
5770        let (url, _handle, captured) = start_capture_server().await;
5771        let ctx = test_producer_ctx();
5772
5773        let component = HttpComponent::with_config(HttpConfig::default());
5774        let endpoint_ctx = NoOpComponentContext;
5775        let endpoint = component
5776            .create_endpoint(
5777                &format!("{url}?httpMethod=GET&allowInternal=true"),
5778                &endpoint_ctx,
5779            )
5780            .unwrap();
5781        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5782
5783        let mut exchange = Exchange::new(Message::default());
5784        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5785
5786        let result = producer.oneshot(exchange).await.unwrap();
5787
5788        let status = result
5789            .input
5790            .header("CamelHttpResponseCode")
5791            .and_then(|v| v.as_u64())
5792            .unwrap();
5793        assert_eq!(status, 200);
5794
5795        let captured = captured.lock().unwrap();
5796        assert_eq!(captured.len(), 1, "expected exactly one captured request");
5797        let req = &captured[0];
5798        assert_eq!(req.method, "GET");
5799        // `httpMethod`/`allowInternal` are URI options, not request-target
5800        // query params, so the origin-form target is just "/".
5801        assert_eq!(req.path, "/");
5802        assert!(req.body.is_empty(), "GET must not carry a body");
5803        assert!(
5804            req.content_length.is_none(),
5805            "suppressed request must not carry Content-Length"
5806        );
5807        assert!(
5808            req.transfer_encoding.is_none(),
5809            "suppressed request must not carry Transfer-Encoding"
5810        );
5811
5812        // The exchange body is consumed by the producer (std::mem::take).
5813        assert!(
5814            result.input.body.is_empty(),
5815            "exchange body must be consumed"
5816        );
5817    }
5818
5819    #[tokio::test]
5820    async fn test_head_with_body_suppressed_via_header() {
5821        use tower::ServiceExt;
5822
5823        let (url, _handle, captured) = start_capture_server().await;
5824        let ctx = test_producer_ctx();
5825
5826        let component = HttpComponent::with_config(HttpConfig::default());
5827        let endpoint_ctx = NoOpComponentContext;
5828        let endpoint = component
5829            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5830            .unwrap();
5831        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5832
5833        let mut exchange = Exchange::new(Message::default());
5834        exchange.input.set_header(
5835            "CamelHttpMethod",
5836            serde_json::Value::String("HEAD".to_string()),
5837        );
5838        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5839
5840        let result = producer.oneshot(exchange).await.unwrap();
5841        let status = result
5842            .input
5843            .header("CamelHttpResponseCode")
5844            .and_then(|v| v.as_u64())
5845            .unwrap();
5846        assert_eq!(status, 200);
5847
5848        let captured = captured.lock().unwrap();
5849        assert_eq!(captured.len(), 1);
5850        let req = &captured[0];
5851        assert_eq!(req.method, "HEAD");
5852        assert!(req.body.is_empty(), "HEAD must not carry a body");
5853    }
5854
5855    #[tokio::test]
5856    async fn test_delete_options_trace_with_body_suppressed() {
5857        use tower::ServiceExt;
5858
5859        let (url, _handle, captured) = start_capture_server().await;
5860        let ctx = test_producer_ctx();
5861        let component = HttpComponent::with_config(HttpConfig::default());
5862        let endpoint_ctx = NoOpComponentContext;
5863
5864        for method in ["DELETE", "OPTIONS", "TRACE"] {
5865            let endpoint = component
5866                .create_endpoint(
5867                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5868                    &endpoint_ctx,
5869                )
5870                .unwrap();
5871            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5872
5873            let mut exchange = Exchange::new(Message::default());
5874            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5875
5876            let result = producer.oneshot(exchange).await.unwrap();
5877            let status = result
5878                .input
5879                .header("CamelHttpResponseCode")
5880                .and_then(|v| v.as_u64())
5881                .unwrap();
5882            assert_eq!(status, 200, "method {method} should succeed");
5883        }
5884
5885        let captured = captured.lock().unwrap();
5886        assert_eq!(captured.len(), 3, "expected three captured requests");
5887        for method in ["DELETE", "OPTIONS", "TRACE"] {
5888            let req = captured
5889                .iter()
5890                .find(|r| r.method == method)
5891                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5892            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5893        }
5894    }
5895
5896    #[tokio::test]
5897    async fn test_post_put_patch_with_body_still_sent() {
5898        use tower::ServiceExt;
5899
5900        let (url, _handle, captured) = start_capture_server().await;
5901        let ctx = test_producer_ctx();
5902        let component = HttpComponent::with_config(HttpConfig::default());
5903        let endpoint_ctx = NoOpComponentContext;
5904
5905        for method in ["POST", "PUT", "PATCH"] {
5906            let endpoint = component
5907                .create_endpoint(
5908                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5909                    &endpoint_ctx,
5910                )
5911                .unwrap();
5912            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5913
5914            let payload = format!("body-for-{method}");
5915            let mut exchange = Exchange::new(Message::default());
5916            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5917
5918            let result = producer.oneshot(exchange).await.unwrap();
5919            let status = result
5920                .input
5921                .header("CamelHttpResponseCode")
5922                .and_then(|v| v.as_u64())
5923                .unwrap();
5924            assert_eq!(status, 200, "method {method} should succeed");
5925        }
5926
5927        let captured = captured.lock().unwrap();
5928        assert_eq!(captured.len(), 3, "expected three captured requests");
5929        for method in ["POST", "PUT", "PATCH"] {
5930            let req = captured
5931                .iter()
5932                .find(|r| r.method == method)
5933                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5934            let expected = format!("body-for-{method}");
5935            assert!(!req.body.is_empty(), "{method} must still carry its body");
5936            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5937        }
5938    }
5939
5940    /// A GET with a stream body must not attach the stream: the entity-enclosing
5941    /// gate drops the stream (mem::take) before the request is built, leaving
5942    /// the exchange body Empty instead of a partially-consumed Body::Stream.
5943    #[tokio::test]
5944    async fn test_stream_body_under_get_not_attached() {
5945        use tower::ServiceExt;
5946
5947        let (url, _handle, captured) = start_capture_server().await;
5948        let ctx = test_producer_ctx();
5949
5950        let component = HttpComponent::with_config(HttpConfig::default());
5951        let endpoint_ctx = NoOpComponentContext;
5952        let endpoint = component
5953            .create_endpoint(
5954                &format!("{url}?httpMethod=GET&allowInternal=true"),
5955                &endpoint_ctx,
5956            )
5957            .unwrap();
5958        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5959
5960        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5961            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
5962        let stream = Box::pin(futures::stream::iter(chunks));
5963        let mut exchange = Exchange::new(Message::default());
5964        exchange.input.body = Body::Stream(StreamBody {
5965            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5966            metadata: StreamMetadata::default(),
5967        });
5968
5969        let result = producer.oneshot(exchange).await.unwrap();
5970
5971        let status = result
5972            .input
5973            .header("CamelHttpResponseCode")
5974            .and_then(|v| v.as_u64())
5975            .unwrap();
5976        assert_eq!(status, 200);
5977
5978        let captured = captured.lock().unwrap();
5979        assert_eq!(captured.len(), 1, "expected exactly one captured request");
5980        assert!(
5981            captured[0].body.is_empty(),
5982            "GET must not carry a stream body"
5983        );
5984        assert!(
5985            captured[0].transfer_encoding.is_none(),
5986            "suppressed request must not carry Transfer-Encoding"
5987        );
5988        assert!(
5989            captured[0].content_length.is_none(),
5990            "suppressed request must not carry Content-Length"
5991        );
5992        assert!(
5993            result.input.body.is_empty(),
5994            "exchange body must be consumed to Empty, not left as a stream"
5995        );
5996    }
5997
5998    /// A suppressed body must never be replayed across 307/308 redirect hops:
5999    /// the gate empties `materialized_body` before the redirect loop runs, so
6000    /// neither the first hop nor the final hop carries the body.
6001    #[tokio::test]
6002    async fn test_redirect_hops_never_replay_suppressed_body() {
6003        use tower::ServiceExt;
6004
6005        let (url, _handle, captured) = start_redirect_capture_server().await;
6006        let ctx = test_producer_ctx();
6007
6008        let component =
6009            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6010        let endpoint_ctx = NoOpComponentContext;
6011
6012        for path in ["/hop307", "/hop308"] {
6013            let endpoint = component
6014                .create_endpoint(
6015                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
6016                    &endpoint_ctx,
6017                )
6018                .unwrap();
6019            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6020
6021            let mut exchange = Exchange::new(Message::default());
6022            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6023
6024            let result = producer.oneshot(exchange).await.unwrap();
6025            let status = result
6026                .input
6027                .header("CamelHttpResponseCode")
6028                .and_then(|v| v.as_u64())
6029                .unwrap();
6030            assert_eq!(
6031                status, 200,
6032                "redirect chain for {path} should end at /final"
6033            );
6034        }
6035
6036        // Two chains (307 and 308), each with two hops (redirect + final).
6037        let captured = captured.lock().unwrap();
6038        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6039        for req in captured.iter() {
6040            assert!(
6041                req.body.is_empty(),
6042                "hop {} {} must not carry a body",
6043                req.method,
6044                req.path
6045            );
6046        }
6047    }
6048
6049    /// The warn! emitted on a suppressed body renders three distinguishable
6050    /// substrings in the log line (tracing-subscriber default field format):
6051    ///   - the message:       "dropping request body ..."
6052    ///   - `method = %method_str`            → `method=GET`
6053    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
6054    /// The closure matches all three so exactly one warn per suppressed
6055    /// request is required (the "HTTP request" debug! also carries
6056    /// `method=GET` and the same `correlation_id=`, but not the message).
6057    #[tracing_test::traced_test]
6058    #[tokio::test]
6059    async fn test_suppressed_body_logs_exactly_one_warn() {
6060        use tower::ServiceExt;
6061
6062        let (url, _handle, _captured) = start_capture_server().await;
6063        let ctx = test_producer_ctx();
6064
6065        let component = HttpComponent::with_config(HttpConfig::default());
6066        let endpoint_ctx = NoOpComponentContext;
6067        let endpoint = component
6068            .create_endpoint(
6069                &format!("{url}?httpMethod=GET&allowInternal=true"),
6070                &endpoint_ctx,
6071            )
6072            .unwrap();
6073        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6074
6075        let mut exchange = Exchange::new(Message::default());
6076        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6077        let correlation_id = exchange.correlation_id().to_string();
6078
6079        let result = producer.oneshot(exchange).await.unwrap();
6080        let status = result
6081            .input
6082            .header("CamelHttpResponseCode")
6083            .and_then(|v| v.as_u64())
6084            .unwrap();
6085        assert_eq!(status, 200);
6086
6087        logs_assert(|lines: &[&str]| {
6088            let hits = lines
6089                .iter()
6090                .filter(|l| {
6091                    l.contains("dropping request body")
6092                        && l.contains("method=GET")
6093                        && l.contains(&format!("correlation_id={correlation_id}"))
6094                })
6095                .count();
6096            match hits {
6097                1 => Ok(()),
6098                n => Err(format!("expected exactly one body-drop warn, found {n}")),
6099            }
6100        });
6101    }
6102
6103    #[tracing_test::traced_test]
6104    #[tokio::test]
6105    async fn test_empty_body_get_emits_no_warn() {
6106        use tower::ServiceExt;
6107
6108        let (url, _handle, _captured) = start_capture_server().await;
6109        let ctx = test_producer_ctx();
6110
6111        let component = HttpComponent::with_config(HttpConfig::default());
6112        let endpoint_ctx = NoOpComponentContext;
6113        let endpoint = component
6114            .create_endpoint(
6115                &format!("{url}?httpMethod=GET&allowInternal=true"),
6116                &endpoint_ctx,
6117            )
6118            .unwrap();
6119        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6120
6121        let exchange = Exchange::new(Message::default());
6122        let result = producer.oneshot(exchange).await.unwrap();
6123        let status = result
6124            .input
6125            .header("CamelHttpResponseCode")
6126            .and_then(|v| v.as_u64())
6127            .unwrap();
6128        assert_eq!(status, 200);
6129
6130        logs_assert(|lines: &[&str]| {
6131            let hits = lines
6132                .iter()
6133                .filter(|l| l.contains("dropping request body"))
6134                .count();
6135            match hits {
6136                0 => Ok(()),
6137                n => Err(format!("expected no body-drop warn, found {n}")),
6138            }
6139        });
6140    }
6141
6142    #[tokio::test]
6143    async fn test_follow_redirects_false_does_not_follow() {
6144        use tower::ServiceExt;
6145
6146        let (url, _handle) = start_redirect_server().await;
6147        let ctx = test_producer_ctx();
6148
6149        let component =
6150            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6151        let endpoint_ctx = NoOpComponentContext;
6152        let endpoint = component
6153            .create_endpoint(
6154                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6155                &endpoint_ctx,
6156            )
6157            .unwrap();
6158        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6159
6160        let exchange = Exchange::new(Message::default());
6161        let result = producer.oneshot(exchange).await.unwrap();
6162
6163        // Should get 302, NOT follow redirect to 200
6164        let status = result
6165            .input
6166            .header("CamelHttpResponseCode")
6167            .and_then(|v| v.as_u64())
6168            .unwrap();
6169        assert_eq!(
6170            status, 302,
6171            "Should NOT follow redirect when followRedirects=false"
6172        );
6173    }
6174
6175    #[tokio::test]
6176    async fn test_follow_redirects_true_follows_redirect() {
6177        use tower::ServiceExt;
6178
6179        let (url, _handle) = start_redirect_server().await;
6180        let ctx = test_producer_ctx();
6181
6182        let component =
6183            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6184        let endpoint_ctx = NoOpComponentContext;
6185        let endpoint = component
6186            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6187            .unwrap();
6188        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6189
6190        let exchange = Exchange::new(Message::default());
6191        let result = producer.oneshot(exchange).await.unwrap();
6192
6193        // Should follow redirect and get 200
6194        let status = result
6195            .input
6196            .header("CamelHttpResponseCode")
6197            .and_then(|v| v.as_u64())
6198            .unwrap();
6199        assert_eq!(
6200            status, 200,
6201            "Should follow redirect when followRedirects=true"
6202        );
6203    }
6204
6205    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
6206    /// This verifies the manual redirect loop executes correctly.
6207    #[tokio::test]
6208    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6209        use tower::ServiceExt;
6210
6211        // Use the existing redirect server which redirects to /final on the same server
6212        let (url, _handle) = start_redirect_server().await;
6213        let ctx = test_producer_ctx();
6214
6215        let component =
6216            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6217        let endpoint_ctx = NoOpComponentContext;
6218        let endpoint = component
6219            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6220            .unwrap();
6221        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6222
6223        let exchange = Exchange::new(Message::default());
6224        let result = producer.oneshot(exchange).await;
6225
6226        // With allowInternal=true, the redirect should succeed
6227        assert!(
6228            result.is_ok(),
6229            "Redirect should succeed with allowInternal=true, got: {:?}",
6230            result
6231        );
6232        let exchange = result.unwrap();
6233        let status = exchange
6234            .input
6235            .header("CamelHttpResponseCode")
6236            .and_then(|v| v.as_u64())
6237            .unwrap();
6238        assert_eq!(status, 200, "Should follow redirect to /final");
6239    }
6240
6241    /// With allowInternal=true, redirects to private IPs should be followed.
6242    #[tokio::test]
6243    async fn test_redirect_to_private_ip_allowed_when_configured() {
6244        use tower::ServiceExt;
6245
6246        // Start a server that redirects to /final on the same server (127.0.0.1)
6247        let (url, _handle) = start_redirect_server().await;
6248        let ctx = test_producer_ctx();
6249
6250        let component =
6251            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6252        let endpoint_ctx = NoOpComponentContext;
6253        let endpoint = component
6254            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6255            .unwrap();
6256        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6257
6258        let exchange = Exchange::new(Message::default());
6259        let result = producer.oneshot(exchange).await.unwrap();
6260
6261        let status = result
6262            .input
6263            .header("CamelHttpResponseCode")
6264            .and_then(|v| v.as_u64())
6265            .unwrap();
6266        assert_eq!(
6267            status, 200,
6268            "Should follow redirect to private IP when allowInternal=true"
6269        );
6270    }
6271
6272    /// Integration test: with allowInternal=false (default), a redirect to a
6273    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
6274    #[tokio::test]
6275    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
6276        use tower::ServiceExt;
6277
6278        // Server that redirects to the AWS metadata endpoint (link-local private IP)
6279        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6280        let addr = listener.local_addr().unwrap();
6281        let url = format!("http://127.0.0.1:{}", addr.port());
6282
6283        let handle = tokio::spawn(async move {
6284            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6285            loop {
6286                if let Ok((mut stream, _)) = listener.accept().await {
6287                    tokio::spawn(async move {
6288                        let mut buf = vec![0u8; 4096];
6289                        let _ = stream.read(&mut buf).await;
6290                        // Always redirect to the metadata endpoint
6291                        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";
6292                        let _ = stream.write_all(response.as_bytes()).await;
6293                    });
6294                }
6295            }
6296        });
6297
6298        let ctx = test_producer_ctx();
6299        let component =
6300            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6301        let endpoint_ctx = NoOpComponentContext;
6302        // allowInternal=false is the default — do NOT set it
6303        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
6304        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6305
6306        let exchange = Exchange::new(Message::default());
6307        let result = producer.oneshot(exchange).await;
6308
6309        // Must be an error — SSRF guard blocks the redirect target
6310        assert!(
6311            result.is_err(),
6312            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
6313        );
6314        let err = result.unwrap_err().to_string();
6315        assert!(
6316            err.contains("blocked IP")
6317                || err.contains("private IP")
6318                || err.contains("SSRF")
6319                || err.contains("not allowed"),
6320            "Error should mention SSRF/IP blocking, got: {err}"
6321        );
6322
6323        handle.abort();
6324    }
6325
6326    /// Integration test: exceeding maxRedirects produces a clear error.
6327    #[tokio::test]
6328    async fn test_too_many_redirects_returns_error() {
6329        use tower::ServiceExt;
6330
6331        // Server that always redirects to itself (infinite loop)
6332        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6333        let addr = listener.local_addr().unwrap();
6334        let url = format!("http://127.0.0.1:{}", addr.port());
6335
6336        let handle = tokio::spawn(async move {
6337            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6338            loop {
6339                if let Ok((mut stream, _)) = listener.accept().await {
6340                    tokio::spawn(async move {
6341                        let mut buf = vec![0u8; 4096];
6342                        let _ = stream.read(&mut buf).await;
6343                        // Always redirect to /loop
6344                        // Connection: close stops the client pooling the
6345                        // connection the server drops right after this
6346                        // response (pooled-race, rc-u3aw).
6347                        let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6348                        let _ = stream.write_all(response.as_bytes()).await;
6349                    });
6350                }
6351            }
6352        });
6353
6354        let ctx = test_producer_ctx();
6355        let component =
6356            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6357        let endpoint_ctx = NoOpComponentContext;
6358        let endpoint = component
6359            .create_endpoint(
6360                &format!("{url}?allowInternal=true&maxRedirects=2"),
6361                &endpoint_ctx,
6362            )
6363            .unwrap();
6364        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6365
6366        let exchange = Exchange::new(Message::default());
6367        let result = producer.oneshot(exchange).await;
6368
6369        // With the fix, exceeding max redirects returns the redirect response
6370        // as-is instead of erroring. The 302 redirect response is returned
6371        // after followRedirects exhausts the allowed redirect count (2).
6372        // Disable throwExceptionOnFailure to inspect the raw response status.
6373        //
6374        // Old behavior: Err("Too many redirects (max 2)")
6375        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
6376        match result {
6377            Err(e) => {
6378                // If throw_exception_on_failure is on, we get HttpOperationFailed
6379                let msg = e.to_string();
6380                assert!(
6381                    msg.contains("HTTP operation failed") || msg.contains("302"),
6382                    "expected redirect-after-exhaustion error, got: {msg}"
6383                );
6384            }
6385            Ok(ex) => {
6386                let response_code = ex
6387                    .input
6388                    .header("CamelHttpResponseCode")
6389                    .and_then(|v| v.as_u64());
6390                assert_eq!(
6391                    response_code,
6392                    Some(302),
6393                    "expected 302 after exhausting redirects"
6394                );
6395            }
6396        }
6397
6398        handle.abort();
6399    }
6400
6401    #[tokio::test]
6402    async fn test_query_params_forwarded_to_http_request() {
6403        use tower::ServiceExt;
6404
6405        let (url, _handle) = start_test_server().await;
6406        let ctx = test_producer_ctx();
6407
6408        let component = HttpComponent::new();
6409        let endpoint_ctx = NoOpComponentContext;
6410        // apiKey is NOT a Camel option, should be forwarded as query param
6411        let endpoint = component
6412            .create_endpoint(
6413                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
6414                &endpoint_ctx,
6415            )
6416            .unwrap();
6417        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6418
6419        let exchange = Exchange::new(Message::default());
6420        let result = producer.oneshot(exchange).await.unwrap();
6421
6422        // The test server returns the request info in response
6423        // We just verify it succeeds (the query param was sent)
6424        let status = result
6425            .input
6426            .header("CamelHttpResponseCode")
6427            .and_then(|v| v.as_u64())
6428            .unwrap();
6429        assert_eq!(status, 200);
6430    }
6431
6432    #[test]
6433    fn test_non_camel_query_params_are_forwarded() {
6434        // Authored pairs ride raw_query (the sole carrier); query_params is
6435        // programmatic-only (http-query-wire-fidelity).
6436        let config = HttpEndpointConfig::from_uri(
6437            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
6438        )
6439        .unwrap();
6440
6441        // apiKey and token are NOT camel-http options: the authored bytes
6442        // (including the interleaved httpMethod) ride raw_query verbatim.
6443        assert_eq!(
6444            config.raw_query.as_deref(),
6445            Some("apiKey=secret123&httpMethod=GET&token=abc456")
6446        );
6447        assert!(config.query_params.is_empty());
6448    }
6449
6450    #[test]
6451    fn test_authored_query_bytes_survive_resolve_url() {
6452        let config =
6453            HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
6454        let exchange = Exchange::new(Message::default());
6455
6456        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
6457
6458        // Authored bytes ride verbatim: `%20` stays `%20` (never re-encoded
6459        // to `+` or double-encoded) and `+` stays `+`.
6460        assert!(url.contains("q=hello%20world"), "url was: {url}");
6461        assert!(url.contains("tag=a+b"), "url was: {url}");
6462    }
6463
6464    // -----------------------------------------------------------------------
6465    // Timeout tests (HTTP-004)
6466    // -----------------------------------------------------------------------
6467
6468    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
6469        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6470        let addr = listener.local_addr().unwrap();
6471        let url = format!("http://127.0.0.1:{}", addr.port());
6472
6473        let handle = tokio::spawn(async move {
6474            loop {
6475                if let Ok((mut stream, _)) = listener.accept().await {
6476                    let delay = delay_ms;
6477                    tokio::spawn(async move {
6478                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6479                        let mut buf = vec![0u8; 4096];
6480                        let _ = stream.read(&mut buf).await;
6481                        // Send headers immediately (no Content-Length → chunked)
6482                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
6483                        let _ = stream.write_all(headers.as_bytes()).await;
6484                        // Delay before sending body chunk
6485                        tokio::time::sleep(Duration::from_millis(delay)).await;
6486                        let body = r#"{"status":"slow"}"#;
6487                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
6488                        let _ = stream.write_all(chunk.as_bytes()).await;
6489                    });
6490                }
6491            }
6492        });
6493
6494        (url, handle)
6495    }
6496
6497    #[tokio::test]
6498    async fn test_http_producer_timeout() {
6499        use tower::ServiceExt;
6500
6501        // Server delays 500ms, client timeout is 100ms → should timeout
6502        let (url, _handle) = start_slow_server(500).await;
6503        let ctx = test_producer_ctx();
6504
6505        let component = HttpComponent::with_config(
6506            HttpConfig::default()
6507                .with_read_timeout_ms(100)
6508                .with_response_timeout_ms(30_000), // generous response timeout
6509        );
6510        let endpoint_ctx = NoOpComponentContext;
6511        let endpoint = component
6512            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
6513            .unwrap();
6514        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6515
6516        let exchange = Exchange::new(Message::default());
6517        let result = producer.oneshot(exchange).await;
6518
6519        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
6520        let err = result.unwrap_err().to_string();
6521        assert!(
6522            err.contains("Read timeout") || err.contains("timeout"),
6523            "Error should mention timeout, got: {}",
6524            err
6525        );
6526    }
6527
6528    #[tokio::test]
6529    async fn test_http_producer_no_timeout_when_fast() {
6530        use tower::ServiceExt;
6531
6532        let (url, _handle) = start_test_server().await;
6533        let ctx = test_producer_ctx();
6534
6535        let component =
6536            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
6537        let endpoint_ctx = NoOpComponentContext;
6538        let endpoint = component
6539            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6540            .unwrap();
6541        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6542
6543        let exchange = Exchange::new(Message::default());
6544        let result = producer.oneshot(exchange).await.unwrap();
6545
6546        let status = result
6547            .input
6548            .header("CamelHttpResponseCode")
6549            .and_then(|v| v.as_u64())
6550            .unwrap();
6551        assert_eq!(status, 200);
6552    }
6553
6554    // -----------------------------------------------------------------------
6555    // SSRF Protection tests
6556    // -----------------------------------------------------------------------
6557
6558    #[tokio::test]
6559    async fn test_http_producer_blocks_metadata_endpoint() {
6560        use tower::ServiceExt;
6561
6562        let ctx = test_producer_ctx();
6563        let component = HttpComponent::new();
6564        let endpoint_ctx = NoOpComponentContext;
6565        let endpoint = component
6566            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
6567            .unwrap();
6568        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6569
6570        let mut exchange = Exchange::new(Message::default());
6571        exchange.input.set_header(
6572            "CamelHttpUri",
6573            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
6574        );
6575
6576        let result = producer.oneshot(exchange).await;
6577        assert!(result.is_err(), "Should block AWS metadata endpoint");
6578
6579        let err = result.unwrap_err();
6580        assert!(
6581            err.to_string().contains("Private IP"),
6582            "Error should mention private IP blocking, got: {}",
6583            err
6584        );
6585    }
6586
6587    #[test]
6588    fn test_ssrf_config_defaults() {
6589        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
6590        assert!(
6591            !config.allow_internal,
6592            "Private IPs should be blocked by default"
6593        );
6594        assert!(
6595            config.blocked_hosts.is_empty(),
6596            "Blocked hosts should be empty by default"
6597        );
6598    }
6599
6600    #[test]
6601    fn test_ssrf_config_allow_internal() {
6602        let config =
6603            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
6604        assert!(
6605            config.allow_internal,
6606            "Private IPs should be allowed when explicitly set"
6607        );
6608    }
6609
6610    #[test]
6611    fn test_uri_option_allow_cleartext_parses() {
6612        let config =
6613            HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=true").unwrap();
6614        assert!(
6615            config.allow_cleartext,
6616            "allowCleartext=true must parse into the endpoint config"
6617        );
6618
6619        let plain = HttpEndpointConfig::from_uri("http://example.com/").unwrap();
6620        assert!(
6621            !plain.allow_cleartext,
6622            "cleartext consent must default to false"
6623        );
6624
6625        let err =
6626            HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=banana").unwrap_err();
6627        assert!(
6628            matches!(&err, CamelError::InvalidUri(msg) if msg.contains("allowCleartext")),
6629            "bad allowCleartext value must yield InvalidUri naming the option, got: {err:?}"
6630        );
6631    }
6632
6633    /// ADR-0081: a CamelHttpUri override to a public cleartext target is
6634    /// gated by the endpoint's `allowCleartext` consent — override URLs go
6635    /// through the same `validate_url_for_ssrf` as the base URL.
6636    #[test]
6637    fn test_camel_http_uri_override_public_cleartext_follows_endpoint_flags() {
6638        let endpoint =
6639            HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=false").unwrap();
6640        let err = crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint)
6641            .expect_err("public cleartext override must be rejected without consent");
6642        assert!(
6643            err.to_string().contains("allowCleartext"),
6644            "error must name the remedy, got: {err}"
6645        );
6646
6647        let endpoint =
6648            HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=true").unwrap();
6649        assert!(
6650            crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint).is_ok(),
6651            "endpoint consent must admit a public cleartext override"
6652        );
6653    }
6654
6655    #[test]
6656    fn test_ssrf_config_blocked_hosts() {
6657        let config = HttpEndpointConfig::from_uri(
6658            "http://example.com/api?blockedHosts=evil.com,malware.net",
6659        )
6660        .unwrap();
6661        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
6662    }
6663
6664    #[tokio::test]
6665    async fn test_http_producer_blocks_localhost() {
6666        use tower::ServiceExt;
6667
6668        let ctx = test_producer_ctx();
6669        let component = HttpComponent::new();
6670        let endpoint_ctx = NoOpComponentContext;
6671        let endpoint = component
6672            .create_endpoint("http://example.com/api", &endpoint_ctx)
6673            .unwrap();
6674        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6675
6676        let mut exchange = Exchange::new(Message::default());
6677        exchange.input.set_header(
6678            "CamelHttpUri",
6679            serde_json::Value::String("http://localhost:8080/internal".to_string()),
6680        );
6681
6682        let result = producer.oneshot(exchange).await;
6683        assert!(result.is_err(), "Should block localhost");
6684    }
6685
6686    #[tokio::test]
6687    async fn test_http_producer_blocks_loopback_ip() {
6688        use tower::ServiceExt;
6689
6690        let ctx = test_producer_ctx();
6691        let component = HttpComponent::new();
6692        let endpoint_ctx = NoOpComponentContext;
6693        let endpoint = component
6694            .create_endpoint("http://example.com/api", &endpoint_ctx)
6695            .unwrap();
6696        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6697
6698        let mut exchange = Exchange::new(Message::default());
6699        exchange.input.set_header(
6700            "CamelHttpUri",
6701            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
6702        );
6703
6704        let result = producer.oneshot(exchange).await;
6705        assert!(result.is_err(), "Should block loopback IP");
6706    }
6707
6708    #[tokio::test]
6709    async fn test_http_producer_allows_private_ip_when_enabled() {
6710        use tower::ServiceExt;
6711
6712        let ctx = test_producer_ctx();
6713        let component = HttpComponent::new();
6714        let endpoint_ctx = NoOpComponentContext;
6715        // With allowInternal=true, the validation should pass
6716        // (actual connection will fail, but that's expected)
6717        let endpoint = component
6718            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
6719            .unwrap();
6720        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6721
6722        let exchange = Exchange::new(Message::default());
6723
6724        // The request will fail because we can't connect, but it should NOT fail
6725        // due to SSRF protection
6726        let result = producer.oneshot(exchange).await;
6727        // We expect connection error, not SSRF error
6728        if let Err(ref e) = result {
6729            let err_str = e.to_string();
6730            assert!(
6731                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
6732                "Should not be SSRF error, got: {}",
6733                err_str
6734            );
6735        }
6736    }
6737
6738    // -----------------------------------------------------------------------
6739    // HttpServerConfig tests
6740    // -----------------------------------------------------------------------
6741
6742    #[test]
6743    fn test_http_server_config_parse() {
6744        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
6745        assert_eq!(cfg.host, "0.0.0.0");
6746        assert_eq!(cfg.port, 8080);
6747        assert_eq!(cfg.path, "/orders");
6748        assert_eq!(cfg.max_inflight_requests, 1024);
6749    }
6750
6751    #[test]
6752    fn test_http_server_config_scheme() {
6753        // UriConfig trait method returns "http" as primary scheme
6754        assert_eq!(HttpServerConfig::scheme(), "http");
6755    }
6756
6757    #[test]
6758    fn test_http_server_config_from_components() {
6759        // Test from_components directly (trait method)
6760        let components = camel_component_api::UriComponents {
6761            scheme: "https".to_string(),
6762            path: "//0.0.0.0:8443/api".to_string(),
6763            params: std::collections::HashMap::from([
6764                ("maxRequestBody".to_string(), "5242880".to_string()),
6765                ("maxInflightRequests".to_string(), "7".to_string()),
6766            ]),
6767            raw_query: None,
6768        };
6769        let cfg = HttpServerConfig::from_components(components).unwrap();
6770        assert_eq!(cfg.host, "0.0.0.0");
6771        assert_eq!(cfg.port, 8443);
6772        assert_eq!(cfg.path, "/api");
6773        assert_eq!(cfg.max_request_body, 5242880);
6774        assert_eq!(cfg.max_inflight_requests, 7);
6775    }
6776
6777    #[test]
6778    fn test_http_server_config_default_path() {
6779        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
6780        assert_eq!(cfg.path, "/");
6781    }
6782
6783    #[test]
6784    fn test_http_server_config_wrong_scheme() {
6785        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6786    }
6787
6788    #[test]
6789    fn test_http_server_config_invalid_port() {
6790        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6791    }
6792
6793    #[test]
6794    fn test_http_server_config_default_port_by_scheme() {
6795        // HTTP without explicit port should default to 80
6796        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
6797        assert_eq!(cfg_http.port, 80);
6798
6799        // HTTPS without explicit port should default to 443
6800        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
6801        assert_eq!(cfg_https.port, 443);
6802    }
6803
6804    #[test]
6805    fn test_request_envelope_and_reply_are_send() {
6806        fn assert_send<T: Send>() {}
6807        assert_send::<RequestEnvelope>();
6808        assert_send::<HttpReply>();
6809    }
6810
6811    // -----------------------------------------------------------------------
6812    // ServerRegistry tests
6813    // -----------------------------------------------------------------------
6814
6815    #[test]
6816    fn test_server_registry_global_is_singleton() {
6817        let r1 = ServerRegistry::global();
6818        let r2 = ServerRegistry::global();
6819        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
6820    }
6821
6822    #[allow(clippy::await_holding_lock)]
6823    #[tokio::test]
6824    async fn test_concurrent_get_or_spawn_returns_same_registry() {
6825        let _guard = lock_registry_test_mutex();
6826        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6827        let port = listener.local_addr().unwrap().port();
6828        drop(listener);
6829
6830        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
6831            Arc::new(std::sync::Mutex::new(Vec::new()));
6832
6833        let mut handles = Vec::new();
6834        for _ in 0..4 {
6835            let results = results.clone();
6836            handles.push(tokio::spawn(async move {
6837                let registry = ServerRegistry::global()
6838                    .get_or_spawn(
6839                        "127.0.0.1",
6840                        port,
6841                        2 * 1024 * 1024,
6842                        10 * 1024 * 1024,
6843                        1024,
6844                        test_rt(),
6845                        "test-route".into(),
6846                        None,
6847                    )
6848                    .await
6849                    .unwrap();
6850                results.lock().unwrap().push(registry);
6851            }));
6852        }
6853
6854        for h in handles {
6855            h.await.unwrap();
6856        }
6857
6858        let registries = results.lock().unwrap();
6859        assert_eq!(registries.len(), 4);
6860        for i in 1..registries.len() {
6861            assert!(
6862                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
6863                "all concurrent callers should get same route registry"
6864            );
6865        }
6866    }
6867
6868    #[test]
6869    fn test_server_registry_distinguishes_host_and_port() {
6870        let _guard = lock_registry_test_mutex();
6871        let rt = tokio::runtime::Runtime::new().expect("runtime");
6872        rt.block_on(async {
6873            let registry = ServerRegistry::global();
6874            // Use two distinct host values with same configured port key.
6875            // Port 0 is acceptable here because the registry key uses the configured
6876            // tuple, not the OS-assigned ephemeral port.
6877            let d1 = registry
6878                .get_or_spawn(
6879                    "127.0.0.1",
6880                    0,
6881                    1024 * 1024,
6882                    10 * 1024 * 1024,
6883                    1024,
6884                    test_rt(),
6885                    "test-route-1".into(),
6886                    None,
6887                )
6888                .await;
6889            let d2 = registry
6890                .get_or_spawn(
6891                    "0.0.0.0",
6892                    0,
6893                    1024 * 1024,
6894                    10 * 1024 * 1024,
6895                    1024,
6896                    test_rt(),
6897                    "test-route-2".into(),
6898                    None,
6899                )
6900                .await;
6901            assert!(d1.is_ok());
6902            assert!(d2.is_ok());
6903            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
6904        });
6905    }
6906
6907    #[allow(clippy::await_holding_lock)]
6908    #[tokio::test]
6909    async fn test_shared_server_max_request_body_policy_is_deterministic() {
6910        let _guard = lock_registry_test_mutex();
6911        let registry = ServerRegistry::global();
6912        // First registration: maxRequestBody = 1 MB
6913        let d1 = registry
6914            .get_or_spawn(
6915                "127.0.0.1",
6916                9991,
6917                1024 * 1024,
6918                10 * 1024 * 1024,
6919                1024,
6920                test_rt(),
6921                "test-route".into(),
6922                None,
6923            )
6924            .await;
6925        assert!(d1.is_ok());
6926
6927        // Second registration on same (host,port): maxRequestBody = 2 MB
6928        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
6929        let d2 = registry
6930            .get_or_spawn(
6931                "127.0.0.1",
6932                9991,
6933                2 * 1024 * 1024,
6934                10 * 1024 * 1024,
6935                1024,
6936                test_rt(),
6937                "test-route-2".into(),
6938                None,
6939            )
6940            .await;
6941        assert!(d2.is_err());
6942        let err = d2.unwrap_err();
6943        assert!(
6944            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6945            "Expected incompatible maxRequestBody error, got: {}",
6946            err
6947        );
6948    }
6949
6950    #[test]
6951    fn test_server_registry_reset_clears_entries() {
6952        let _guard = lock_registry_test_mutex();
6953        let rt = tokio::runtime::Runtime::new().expect("runtime");
6954        rt.block_on(async {
6955            // Register something on a unique port
6956            let d1 = ServerRegistry::global()
6957                .get_or_spawn(
6958                    "127.0.0.1",
6959                    9992,
6960                    1024 * 1024,
6961                    10 * 1024 * 1024,
6962                    1024,
6963                    test_rt(),
6964                    "test-route".into(),
6965                    None,
6966                )
6967                .await;
6968            assert!(d1.is_ok());
6969
6970            // Verify entry exists
6971            let guard = ServerRegistry::global().inner.lock().expect("lock");
6972            assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
6973            drop(guard);
6974
6975            // Reset
6976            ServerRegistry::reset();
6977
6978            // Verify cleared
6979            let guard = ServerRegistry::global().inner.lock().expect("lock");
6980            assert!(
6981                guard.entries.is_empty(),
6982                "registry should be empty after reset, has {} entries",
6983                guard.entries.len()
6984            );
6985        });
6986    }
6987
6988    #[allow(clippy::await_holding_lock)]
6989    #[tokio::test]
6990    async fn registry_rejects_tls_on_plain_port() {
6991        // httpflake: this reset previously ran WITHOUT the registry test
6992        // mutex, so it could wipe another test's freshly staged entry
6993        // mid-window (traced 2026-09-14) — spec law: every reset caller
6994        // holds REGISTRY_TEST_MUTEX.
6995        let _guard = lock_registry_test_mutex();
6996        ServerRegistry::reset();
6997        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6998
6999        // First route: plain HTTP
7000        let _r1 = ServerRegistry::global()
7001            .get_or_spawn(
7002                "127.0.0.1",
7003                0,
7004                1024,
7005                1024,
7006                16,
7007                Arc::clone(&rt),
7008                "route-1".into(),
7009                None, // plain
7010            )
7011            .await;
7012
7013        // Second route: TLS on same port → must fail
7014        let result = ServerRegistry::global()
7015            .get_or_spawn(
7016                "127.0.0.1",
7017                0,
7018                1024,
7019                1024,
7020                16,
7021                Arc::clone(&rt),
7022                "route-2".into(),
7023                Some(crate::config::ServerTlsConfig {
7024                    cert_path: "/x.pem".into(),
7025                    key_path: "/y.pem".into(),
7026                }),
7027            )
7028            .await;
7029        assert!(result.is_err(), "must reject TLS on plain port");
7030    }
7031
7032    // -----------------------------------------------------------------------
7033    // D-L10: HTTP server is process-lifetime — it survives consumer
7034    // unregister (no refcount; dead servers are evicted on next spawn)
7035    // -----------------------------------------------------------------------
7036
7037    #[allow(clippy::await_holding_lock)]
7038    #[tokio::test]
7039    async fn test_unregister_last_http_route_keeps_server_alive() {
7040        let _guard = lock_registry_test_mutex();
7041        ServerRegistry::reset();
7042        let registry = ServerRegistry::global();
7043
7044        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7045        let port = listener.local_addr().unwrap().port();
7046        drop(listener); // Release — ServerRegistry will rebind
7047        let rt = test_rt();
7048
7049        // Register 2 routes on the same (host, port) — OnceCell returns the
7050        // same ServerHandle.
7051        let _r1 = registry
7052            .get_or_spawn(
7053                "127.0.0.1",
7054                port,
7055                1024 * 1024,
7056                10 * 1024 * 1024,
7057                16,
7058                rt.clone(),
7059                "test-route-1".into(),
7060                None,
7061            )
7062            .await
7063            .unwrap();
7064        let _r2 = registry
7065            .get_or_spawn(
7066                "127.0.0.1",
7067                port,
7068                1024 * 1024,
7069                10 * 1024 * 1024,
7070                16,
7071                rt,
7072                "test-route-2".into(),
7073                None,
7074            )
7075            .await
7076            .unwrap();
7077
7078        let key = ("127.0.0.1".to_string(), port);
7079        let cell = {
7080            let guard = registry.inner.lock().expect("lock");
7081            guard.entries.get(&key).expect("entry should exist").clone()
7082        };
7083
7084        // Unregister first route -> monitor still alive (count = 1).
7085        registry.unregister("127.0.0.1", port).await;
7086        {
7087            let handle = cell
7088                .get()
7089                .expect("handle should still exist after first unregister");
7090            assert!(
7091                !handle.monitor_task.is_finished(),
7092                "monitor task should still be alive after first unregister"
7093            );
7094        }
7095
7096        // Unregister second route -> server stays alive (process-lifetime).
7097        registry.unregister("127.0.0.1", port).await;
7098        tokio::time::sleep(Duration::from_millis(20)).await;
7099        {
7100            let handle = cell
7101                .get()
7102                .expect("handle should still exist after last unregister");
7103            assert!(
7104                !handle.monitor_task.is_finished(),
7105                "monitor task should still be alive — server is process-lifetime"
7106            );
7107        }
7108
7109        // Entry stays in registry for potential restart.
7110        {
7111            let guard = registry.inner.lock().expect("lock");
7112            assert!(
7113                guard.entries.contains_key(&key),
7114                "entry should remain in registry — server kept alive for restart"
7115            );
7116        }
7117    }
7118
7119    // -----------------------------------------------------------------------
7120    // Staged listeners (itest-bound-ports Task 1)
7121    // -----------------------------------------------------------------------
7122
7123    /// CLONE-FIXTURE: bind a std listener on `127.0.0.1:0`, retain a blocking
7124    /// std clone (`probe`) so the port stays reserved, and hand the original
7125    /// socket to tokio as a non-blocking listener. `tokio::net::TcpListener`
7126    /// has no `try_clone`, so clones come from the std handle.
7127    async fn clone_fixture_listener() -> (
7128        tokio::net::TcpListener,
7129        std::net::TcpListener,
7130        std::net::SocketAddr,
7131    ) {
7132        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7133        let probe = l.try_clone().expect("clone probe");
7134        l.set_nonblocking(true).expect("set_nonblocking");
7135        let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7136        let addr = listener.local_addr().expect("local_addr");
7137        (listener, probe, addr)
7138    }
7139
7140    /// Default-limit constants the existing registry tests in this file use.
7141    fn staged_limits() -> (usize, usize, usize) {
7142        (1024 * 1024, 10 * 1024 * 1024, 1024)
7143    }
7144
7145    #[allow(clippy::await_holding_lock)]
7146    #[tokio::test]
7147    async fn staged_listener_first_spawn_serves_without_second_bind() {
7148        let _guard = lock_registry_test_mutex();
7149        ServerRegistry::reset();
7150        let registry = ServerRegistry::global();
7151        let (listener, _probe, addr) = clone_fixture_listener().await;
7152        let port = addr.port();
7153        registry
7154            .stage_listener(listener)
7155            .await
7156            .expect("stage listener");
7157
7158        let (max_req, max_res, max_inflight) = staged_limits();
7159        let routes = registry
7160            .get_or_spawn(
7161                "127.0.0.1",
7162                port,
7163                max_req,
7164                max_res,
7165                max_inflight,
7166                test_rt(),
7167                "staged-first-spawn".into(),
7168                None,
7169            )
7170            .await
7171            .expect("spawn from staged listener must succeed");
7172
7173        assert_eq!(
7174            registry.bound_addr("127.0.0.1", port),
7175            Some(addr),
7176            "served socket must be the staged listener's addr"
7177        );
7178        // The probe clone shares the socket, so service is proven by an HTTP
7179        // response, not by accepting on the probe.
7180        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7181            .await
7182            .expect("http request against staged listener must connect");
7183        assert!(
7184            resp.status().as_u16() >= 200,
7185            "any status proves the staged socket serves"
7186        );
7187        drop(routes);
7188    }
7189
7190    #[allow(clippy::await_holding_lock)]
7191    #[tokio::test]
7192    async fn staged_entry_reused_by_second_caller() {
7193        let _guard = lock_registry_test_mutex();
7194        ServerRegistry::reset();
7195        let registry = ServerRegistry::global();
7196        let (listener, _probe, addr) = clone_fixture_listener().await;
7197        let port = addr.port();
7198        registry
7199            .stage_listener(listener)
7200            .await
7201            .expect("stage listener");
7202
7203        let (max_req, max_res, max_inflight) = staged_limits();
7204        let first = registry
7205            .get_or_spawn(
7206                "127.0.0.1",
7207                port,
7208                max_req,
7209                max_res,
7210                max_inflight,
7211                test_rt(),
7212                "staged-reuse-1".into(),
7213                None,
7214            )
7215            .await
7216            .expect("first spawn from staged listener");
7217        let second = registry
7218            .get_or_spawn(
7219                "127.0.0.1",
7220                port,
7221                max_req,
7222                max_res,
7223                max_inflight,
7224                test_rt(),
7225                "staged-reuse-2".into(),
7226                None,
7227            )
7228            .await
7229            .expect("second caller must reuse the entry");
7230        assert_eq!(
7231            registry.bound_addr("127.0.0.1", port),
7232            Some(addr),
7233            "entry reused — bound addr unchanged, no second bind"
7234        );
7235        drop(first);
7236        drop(second);
7237    }
7238
7239    #[allow(clippy::await_holding_lock)]
7240    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7241    async fn staged_race_two_callers_single_resolver() {
7242        let _guard = lock_registry_test_mutex();
7243        ServerRegistry::reset();
7244        let registry = ServerRegistry::global();
7245        let (listener, _probe, addr) = clone_fixture_listener().await;
7246        let port = addr.port();
7247        registry
7248            .stage_listener(listener)
7249            .await
7250            .expect("stage listener");
7251
7252        // Two racing callers for the exact staged key: the staged listener
7253        // must be consumed by the single cell-init winner and served to
7254        // both — never leave the winner binding a port the loser still
7255        // holds (EADDRINUSE).
7256        let (max_req, max_res, max_inflight) = staged_limits();
7257        let (first, second) = tokio::join!(
7258            registry.get_or_spawn(
7259                "127.0.0.1",
7260                port,
7261                max_req,
7262                max_res,
7263                max_inflight,
7264                test_rt(),
7265                "staged-race-1".into(),
7266                None,
7267            ),
7268            registry.get_or_spawn(
7269                "127.0.0.1",
7270                port,
7271                max_req,
7272                max_res,
7273                max_inflight,
7274                test_rt(),
7275                "staged-race-2".into(),
7276                None,
7277            ),
7278        );
7279        let first = first.expect("first racing caller must succeed");
7280        let second = second.expect("second racing caller must succeed");
7281        assert_eq!(
7282            registry.bound_addr("127.0.0.1", port),
7283            Some(addr),
7284            "single entry must be served from the staged socket — no EADDRINUSE path"
7285        );
7286        drop(first);
7287        drop(second);
7288    }
7289
7290    #[allow(clippy::await_holding_lock)]
7291    #[tokio::test]
7292    async fn unstaged_spawn_binds_legacy() {
7293        let _guard = lock_registry_test_mutex();
7294        ServerRegistry::reset();
7295        let registry = ServerRegistry::global();
7296        // Fresh port P2: reserve then release — the legacy path rebinds.
7297        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
7298        let port = probe.local_addr().expect("local addr").port();
7299        drop(probe);
7300
7301        let (max_req, max_res, max_inflight) = staged_limits();
7302        registry
7303            .get_or_spawn(
7304                "127.0.0.1",
7305                port,
7306                max_req,
7307                max_res,
7308                max_inflight,
7309                test_rt(),
7310                "legacy-bind".into(),
7311                None,
7312            )
7313            .await
7314            .expect("legacy bind spawn");
7315        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
7316            .await
7317            .expect("connect to freshly bound port must succeed");
7318        assert!(resp.status().as_u16() >= 200);
7319        assert_eq!(
7320            registry.bound_addr("127.0.0.1", port),
7321            Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
7322            "bound addr must be the legacy bound (host, port)"
7323        );
7324    }
7325
7326    #[allow(clippy::await_holding_lock)]
7327    #[tokio::test]
7328    async fn wrong_host_staged_port_fails_deterministically() {
7329        let _guard = lock_registry_test_mutex();
7330        ServerRegistry::reset();
7331        let registry = ServerRegistry::global();
7332        let (listener, _probe, addr) = clone_fixture_listener().await;
7333        let port = addr.port();
7334        registry
7335            .stage_listener(listener)
7336            .await
7337            .expect("stage listener under 127.0.0.1");
7338
7339        let (max_req, max_res, max_inflight) = staged_limits();
7340        let err = registry
7341            .get_or_spawn(
7342                "localhost",
7343                port,
7344                max_req,
7345                max_res,
7346                max_inflight,
7347                test_rt(),
7348                "conflict-probe".into(),
7349                None,
7350            )
7351            .await
7352            .expect_err("wrong host on staged port must fail deterministically");
7353        assert!(
7354            err.to_string().contains("staged listener conflict on port"),
7355            "unexpected error: {err}"
7356        );
7357
7358        // Slot untouched by the failed call: the correct host now consumes it.
7359        registry
7360            .get_or_spawn(
7361                "127.0.0.1",
7362                port,
7363                max_req,
7364                max_res,
7365                max_inflight,
7366                test_rt(),
7367                "conflict-after".into(),
7368                None,
7369            )
7370            .await
7371            .expect("correct host must serve the staged listener");
7372        assert_eq!(
7373            registry.bound_addr("127.0.0.1", port),
7374            Some(addr),
7375            "staged slot must be untouched by the conflicting call"
7376        );
7377    }
7378
7379    #[allow(clippy::await_holding_lock)]
7380    #[tokio::test]
7381    async fn duplicate_stage_same_key_rejected() {
7382        let _guard = lock_registry_test_mutex();
7383        ServerRegistry::reset();
7384        let registry = ServerRegistry::global();
7385        let (listener, probe, addr) = clone_fixture_listener().await;
7386        registry
7387            .stage_listener(listener)
7388            .await
7389            .expect("stage listener A");
7390
7391        // Second tokio handle to the SAME socket: clone the std probe handle.
7392        let dup = probe.try_clone().expect("clone2");
7393        dup.set_nonblocking(true).expect("set_nonblocking2");
7394        let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
7395
7396        let err = registry
7397            .stage_listener(b)
7398            .await
7399            .expect_err("duplicate stage must be rejected");
7400        assert!(
7401            err.to_string().contains("listener already staged"),
7402            "unexpected error: {err}"
7403        );
7404
7405        let (max_req, max_res, max_inflight) = staged_limits();
7406        registry
7407            .get_or_spawn(
7408                "127.0.0.1",
7409                addr.port(),
7410                max_req,
7411                max_res,
7412                max_inflight,
7413                test_rt(),
7414                "dup-stage-after".into(),
7415                None,
7416            )
7417            .await
7418            .expect("spawn from first staged listener");
7419        assert_eq!(
7420            registry.bound_addr("127.0.0.1", addr.port()),
7421            Some(addr),
7422            "first staged listener retained"
7423        );
7424    }
7425
7426    #[allow(clippy::await_holding_lock)]
7427    #[tokio::test]
7428    async fn distinct_keys_stage_independently() {
7429        let _guard = lock_registry_test_mutex();
7430        ServerRegistry::reset();
7431        let registry = ServerRegistry::global();
7432        let (l1, _p1, addr1) = clone_fixture_listener().await;
7433        let (l2, _p2, addr2) = clone_fixture_listener().await;
7434        registry.stage_listener(l1).await.expect("stage P1");
7435        registry.stage_listener(l2).await.expect("stage P2");
7436
7437        let (max_req, max_res, max_inflight) = staged_limits();
7438        registry
7439            .get_or_spawn(
7440                "127.0.0.1",
7441                addr1.port(),
7442                max_req,
7443                max_res,
7444                max_inflight,
7445                test_rt(),
7446                "distinct-1".into(),
7447                None,
7448            )
7449            .await
7450            .expect("spawn P1");
7451        registry
7452            .get_or_spawn(
7453                "127.0.0.1",
7454                addr2.port(),
7455                max_req,
7456                max_res,
7457                max_inflight,
7458                test_rt(),
7459                "distinct-2".into(),
7460                None,
7461            )
7462            .await
7463            .expect("spawn P2");
7464        assert_eq!(
7465            registry.bound_addr("127.0.0.1", addr1.port()),
7466            Some(addr1),
7467            "P1 bound addr must be its own listener"
7468        );
7469        assert_eq!(
7470            registry.bound_addr("127.0.0.1", addr2.port()),
7471            Some(addr2),
7472            "P2 bound addr must be its own listener"
7473        );
7474        let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
7475            .await
7476            .expect("connect P1");
7477        assert!(r1.status().as_u16() >= 200);
7478        let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
7479            .await
7480            .expect("connect P2");
7481        assert!(r2.status().as_u16() >= 200);
7482    }
7483
7484    #[allow(clippy::await_holding_lock)]
7485    #[tokio::test]
7486    async fn tls_prebound_listener_served() {
7487        use camel_component_api::test_support::tls;
7488
7489        // Install rustls crypto provider (aws-lc-rs — matches the existing
7490        // TLS registry tests).
7491        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7492
7493        let _guard = lock_registry_test_mutex();
7494        ServerRegistry::reset();
7495        let registry = ServerRegistry::global();
7496        let (listener, _probe, addr) = clone_fixture_listener().await;
7497        let port = addr.port();
7498
7499        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7500        let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
7501        let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
7502        let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
7503
7504        let (max_req, max_res, max_inflight) = staged_limits();
7505        let routes = registry
7506            .get_or_spawn_with_listener(
7507                listener,
7508                max_req,
7509                max_res,
7510                max_inflight,
7511                test_rt(),
7512                "staged-tls".into(),
7513                Some(crate::config::ServerTlsConfig {
7514                    cert_path: cert_path.to_string_lossy().into_owned(),
7515                    key_path: key_path.to_string_lossy().into_owned(),
7516                }),
7517            )
7518            .await
7519            .expect("spawn TLS server from pre-bound listener");
7520
7521        // Client with CA cert — REAL verification (no danger_accept_invalid),
7522        // same helper pattern as the existing TLS registry tests.
7523        let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
7524        let client = reqwest::Client::builder()
7525            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
7526            .build()
7527            .expect("build tls client");
7528
7529        let resp = client
7530            .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
7531            .send()
7532            .await
7533            .expect("TLS handshake + request must succeed");
7534        assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
7535        assert_eq!(
7536            registry.bound_addr("127.0.0.1", port),
7537            Some(addr),
7538            "bound addr equals the pre-bound listener addr"
7539        );
7540        drop(routes);
7541    }
7542
7543    #[allow(clippy::await_holding_lock)]
7544    #[tokio::test]
7545    async fn with_listener_direct_spawn_keyed_by_actual_addr() {
7546        let _guard = lock_registry_test_mutex();
7547        ServerRegistry::reset();
7548        let registry = ServerRegistry::global();
7549        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7550            .await
7551            .expect("bind un-staged listener");
7552        let addr = listener.local_addr().expect("local addr");
7553        let port = addr.port();
7554
7555        let (max_req, max_res, max_inflight) = staged_limits();
7556        registry
7557            .get_or_spawn_with_listener(
7558                listener,
7559                max_req,
7560                max_res,
7561                max_inflight,
7562                test_rt(),
7563                "with-listener".into(),
7564                None,
7565            )
7566            .await
7567            .expect("direct spawn from un-staged listener");
7568        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
7569            .await
7570            .expect("connect on actual port");
7571        assert!(resp.status().as_u16() >= 200);
7572        assert_eq!(
7573            registry.bound_addr("127.0.0.1", port),
7574            Some(addr),
7575            "registry key is the listener's actual port"
7576        );
7577
7578        registry
7579            .get_or_spawn(
7580                "127.0.0.1",
7581                port,
7582                max_req,
7583                max_res,
7584                max_inflight,
7585                test_rt(),
7586                "with-listener-reuse".into(),
7587                None,
7588            )
7589            .await
7590            .expect("legacy caller must reuse the entry");
7591        assert_eq!(
7592            registry.bound_addr("127.0.0.1", port),
7593            Some(addr),
7594            "entry reused — no second bind"
7595        );
7596    }
7597
7598    // -----------------------------------------------------------------------
7599    // Axum dispatch handler tests
7600    // -----------------------------------------------------------------------
7601
7602    #[tokio::test]
7603    async fn test_dispatch_handler_returns_404_for_unknown_path() {
7604        let registry = HttpRouteRegistry::new();
7605        // Nothing registered in route registry
7606        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7607        let port = listener.local_addr().unwrap().port();
7608        tokio::spawn(run_axum_server(
7609            listener,
7610            registry,
7611            2 * 1024 * 1024,
7612            10 * 1024 * 1024,
7613            Arc::new(tokio::sync::Semaphore::new(1024)),
7614            test_rt(),
7615            "test-route".into(),
7616        ));
7617
7618        // Wait for server to start
7619        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7620
7621        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
7622            .await
7623            .unwrap();
7624        assert_eq!(resp.status().as_u16(), 404);
7625    }
7626
7627    // -----------------------------------------------------------------------
7628    // HttpConsumer tests
7629    // -----------------------------------------------------------------------
7630
7631    #[tokio::test]
7632    async fn test_http_consumer_start_registers_path() {
7633        use camel_component_api::ConsumerContext;
7634
7635        // Get an OS-assigned free port
7636        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7637        let port = listener.local_addr().unwrap().port();
7638        drop(listener); // Release port — ServerRegistry will rebind it
7639
7640        let consumer_cfg = HttpServerConfig {
7641            scheme: "http".to_string(),
7642            host: "127.0.0.1".to_string(),
7643            port,
7644            path: "/ping".to_string(),
7645            max_request_body: 2 * 1024 * 1024,
7646            max_response_body: 10 * 1024 * 1024,
7647            max_inflight_requests: 1024,
7648            method: None,
7649            tls_config: None,
7650        };
7651        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7652
7653        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7654        let token = tokio_util::sync::CancellationToken::new();
7655        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7656
7657        tokio::spawn(async move {
7658            consumer.start(ctx).await.unwrap();
7659        });
7660
7661        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7662
7663        let client = reqwest::Client::new();
7664        let resp_future = client
7665            .post(format!("http://127.0.0.1:{port}/ping"))
7666            .body("hello world")
7667            .send();
7668
7669        let (http_result, _) = tokio::join!(resp_future, async {
7670            if let Some(mut envelope) = rx.recv().await {
7671                // Set a custom status code
7672                envelope.exchange.input.set_header(
7673                    "CamelHttpResponseCode",
7674                    serde_json::Value::Number(201.into()),
7675                );
7676                if let Some(reply_tx) = envelope.reply_tx {
7677                    let _ = reply_tx.send(Ok(envelope.exchange));
7678                }
7679            }
7680        });
7681
7682        let resp = http_result.unwrap();
7683        assert_eq!(resp.status().as_u16(), 201);
7684
7685        token.cancel();
7686    }
7687
7688    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
7689    /// dispatcher's inflight semaphore so the semaphore stays the single
7690    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
7691    #[test]
7692    fn test_envelope_channel_capacity_follows_max_inflight() {
7693        assert_eq!(envelope_channel_capacity(0), 1);
7694        assert_eq!(envelope_channel_capacity(1), 1);
7695        assert_eq!(envelope_channel_capacity(7), 7);
7696        assert_eq!(envelope_channel_capacity(64), 64);
7697        assert_eq!(envelope_channel_capacity(1024), 1024);
7698    }
7699
7700    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
7701    /// configuration. Consumer start must not panic on it (the channel guard)
7702    /// and every request must get 503 from the empty semaphore.
7703    #[tokio::test]
7704    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
7705        use camel_component_api::ConsumerContext;
7706
7707        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7708        let port = listener.local_addr().unwrap().port();
7709        drop(listener);
7710
7711        let consumer_cfg = HttpServerConfig {
7712            scheme: "http".to_string(),
7713            host: "127.0.0.1".to_string(),
7714            port,
7715            path: "/ping".to_string(),
7716            max_request_body: 2 * 1024 * 1024,
7717            max_response_body: 10 * 1024 * 1024,
7718            max_inflight_requests: 0,
7719            method: None,
7720            tls_config: None,
7721        };
7722        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7723
7724        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7725        let token = tokio_util::sync::CancellationToken::new();
7726        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7727
7728        let start_handle = tokio::spawn(async move {
7729            consumer.start(ctx).await.unwrap();
7730        });
7731
7732        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7733
7734        let client = reqwest::Client::new();
7735        let resp = client
7736            .post(format!("http://127.0.0.1:{port}/ping"))
7737            .body("hello world")
7738            .send()
7739            .await
7740            .unwrap();
7741        assert_eq!(resp.status().as_u16(), 503);
7742
7743        token.cancel();
7744        let _ = start_handle.await;
7745    }
7746
7747    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
7748    /// waits for the listener bind before publishing RouteStarted.
7749    #[test]
7750    fn test_http_consumer_startup_mode_is_explicit() {
7751        use camel_component_api::ConsumerStartupMode;
7752        let consumer_cfg = HttpServerConfig {
7753            scheme: "http".to_string(),
7754            host: "127.0.0.1".to_string(),
7755            port: 0,
7756            path: "/x".to_string(),
7757            max_request_body: 2 * 1024 * 1024,
7758            max_response_body: 10 * 1024 * 1024,
7759            max_inflight_requests: 1024,
7760            method: None,
7761            tls_config: None,
7762        };
7763        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
7764        assert_eq!(
7765            consumer.startup_mode(),
7766            ConsumerStartupMode::Explicit,
7767            "HttpConsumer must opt into Explicit startup"
7768        );
7769    }
7770
7771    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
7772    /// + route registration. The StartupSignal resolves Ok only when that
7773    /// happens. Verified here by injecting our own signal pair into the
7774    /// ConsumerContext and asserting the receiver resolves within a bounded
7775    /// window even before any HTTP request is made.
7776    #[allow(clippy::await_holding_lock)]
7777    #[tokio::test]
7778    async fn test_http_consumer_emits_mark_ready_after_bind() {
7779        use camel_component_api::{ConsumerContext, StartupSignal};
7780
7781        let _guard = lock_registry_test_mutex();
7782
7783        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7784        let port = listener.local_addr().unwrap().port();
7785        drop(listener);
7786
7787        let consumer_cfg = HttpServerConfig {
7788            scheme: "http".to_string(),
7789            host: "127.0.0.1".to_string(),
7790            port,
7791            path: "/ready-probe".to_string(),
7792            max_request_body: 2 * 1024 * 1024,
7793            max_response_body: 10 * 1024 * 1024,
7794            max_inflight_requests: 1024,
7795            method: None,
7796            tls_config: None,
7797        };
7798        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7799
7800        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7801        let token = tokio_util::sync::CancellationToken::new();
7802        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
7803
7804        // Inject our own startup signal so we can observe mark_ready.
7805        let (signal, startup_rx) = StartupSignal::pair();
7806        let ctx = ctx.with_startup(signal);
7807
7808        // Spawn start() — it MUST call mark_ready once the listener is bound
7809        // and the path is registered.
7810        tokio::spawn(async move {
7811            let _ = consumer.start(ctx).await;
7812        });
7813
7814        // The receiver MUST resolve Ok within a bounded window — proving
7815        // mark_ready was called by start(). A short timeout catches the
7816        // regression where mark_ready is never called (the old behaviour
7817        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
7818        let result =
7819            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
7820                .await
7821                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
7822        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
7823
7824        // Cancellation tears down the spawned start() loop.
7825        token.cancel();
7826    }
7827
7828    // -----------------------------------------------------------------------
7829    // Shared-server death supervision (rc-szmob / ADR-0007)
7830    // -----------------------------------------------------------------------
7831
7832    /// RuntimeObservability stub that records every `increment_errors`
7833    /// `(route_id, label)` pair so tests can assert error counters.
7834    #[derive(Default, Clone)]
7835    struct ErrorRecordingRuntime {
7836        errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
7837    }
7838
7839    impl camel_api::MetricsCollector for ErrorRecordingRuntime {
7840        fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
7841        fn increment_errors(&self, route_id: &str, error_type: &str) {
7842            self.errors
7843                .lock()
7844                .expect("error recorder lock")
7845                .push((route_id.to_string(), error_type.to_string()));
7846        }
7847        fn increment_exchanges(&self, _route_id: &str) {}
7848        fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
7849        fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
7850    }
7851
7852    impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
7853        fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
7854    }
7855
7856    impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
7857        fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
7858            std::sync::Arc::new(self.clone())
7859        }
7860        fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
7861            std::sync::Arc::new(self.clone())
7862        }
7863    }
7864
7865    /// rc-szmob (ADR-0007 parity): when the shared Axum server task for a
7866    /// host:port dies, EVERY HttpConsumer hosted on that port must fail its
7867    /// `start()` with an Err — that Err is the signal camel-core's consumer
7868    /// watcher turns into a per-route CrashNotification → FailRoute →
7869    /// supervision backoff restart. Before the fix the consumers hung in
7870    /// `Running` forever (zombie routes): neither `ctx.cancelled()` nor
7871    /// `env_rx.recv()` fires when the server task dies, because the envelope
7872    /// senders live in the (still-alive) registry, not in the dead task.
7873    ///
7874    /// Deterministic by construction: readiness is awaited via the injected
7875    /// StartupSignal (no sleeps), the server is killed via its AbortHandle
7876    /// (real JoinError → monitor's unexpected-exit branch), and consumer
7877    /// resolution is bounded by a timeout — on unmodified behavior the
7878    /// timeout trips, which is exactly the zombie this test pins down.
7879    #[allow(clippy::await_holding_lock)]
7880    #[tokio::test]
7881    async fn shared_server_death_fails_every_hosted_consumer() {
7882        use camel_component_api::{ConsumerContext, StartupSignal};
7883
7884        let _guard = lock_registry_test_mutex();
7885        ServerRegistry::reset();
7886
7887        // Reserve a port, release it, let get_or_spawn bind it.
7888        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7889        let port = listener.local_addr().unwrap().port();
7890        drop(listener);
7891
7892        let rt = ErrorRecordingRuntime::default();
7893
7894        let make_consumer = |path: &str| {
7895            HttpConsumer::new(
7896                HttpServerConfig {
7897                    scheme: "http".to_string(),
7898                    host: "127.0.0.1".to_string(),
7899                    port,
7900                    path: path.to_string(),
7901                    max_request_body: 2 * 1024 * 1024,
7902                    max_response_body: 10 * 1024 * 1024,
7903                    max_inflight_requests: 16,
7904                    method: None,
7905                    tls_config: None,
7906                },
7907                std::sync::Arc::new(rt.clone()),
7908            )
7909        };
7910
7911        let spawn_consumer = |path: &str, route_id: &str| {
7912            let mut consumer = make_consumer(path);
7913            let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7914            let token = tokio_util::sync::CancellationToken::new();
7915            let ctx = ConsumerContext::new(tx, token, route_id.to_string());
7916            let (signal, startup_rx) = StartupSignal::pair();
7917            let ctx = ctx.with_startup(signal);
7918            let task = tokio::spawn(async move { consumer.start(ctx).await });
7919            (task, startup_rx)
7920        };
7921
7922        // Two routes hosted on the SAME shared server (same host:port).
7923        let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
7924        let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
7925
7926        // Both consumers registered and the server is up (bounded, no sleeps).
7927        for (name, ready) in [("a", ready_a), ("b", ready_b)] {
7928            let result =
7929                tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
7930                    .await
7931                    .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
7932            assert!(
7933                result.is_ok(),
7934                "consumer {name} readiness must resolve Ok (bind + registration complete)"
7935            );
7936        }
7937
7938        // Kill the shared server task: abort → JoinError → the monitor's
7939        // unexpected-exit branch. This is the real crash path (no mock).
7940        {
7941            let registry = ServerRegistry::global();
7942            let guard = registry.inner.lock().expect("ServerRegistry lock");
7943            let cell = guard
7944                .entries
7945                .get(&("127.0.0.1".to_string(), port))
7946                .expect("shared server entry must exist");
7947            let handle = cell.get().expect("server handle must be initialized");
7948            handle.server_abort.abort();
7949        }
7950
7951        // THE assertion: both hosted consumers must fail (bounded). On the
7952        // zombie bug they never resolve and this timeout trips.
7953        let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
7954            .await
7955            .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
7956        let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
7957            .await
7958            .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
7959
7960        let err_a = outcome_a
7961            .expect("consumer-a task must join")
7962            .expect_err("consumer-a start() must return Err when the shared server dies");
7963        let err_b = outcome_b
7964            .expect("consumer-b task must join")
7965            .expect_err("consumer-b start() must return Err when the shared server dies");
7966
7967        // The error must identify the dead shared transport (it flows into the
7968        // CrashNotification message camel-core records against the route).
7969        for (name, err) in [("a", &err_a), ("b", &err_b)] {
7970            assert!(
7971                err.to_string().contains("127.0.0.1")
7972                    && err.to_string().contains(&port.to_string()),
7973                "consumer-{name} error must name the dead shared server, got: {err}"
7974            );
7975        }
7976
7977        // Error counter regression guard: the monitor still records
7978        // `e:http:server-task-exited` for the route that spawned the server.
7979        let recorded = rt.errors.lock().expect("error recorder lock").clone();
7980        assert!(
7981            recorded
7982                .iter()
7983                .any(|(route, label)| label == "e:http:server-task-exited"
7984                    && route == "zombie-route-a"),
7985            "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
7986        );
7987    }
7988
7989    #[tokio::test]
7990    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
7991        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7992
7993        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7994        let port = listener.local_addr().unwrap().port();
7995        drop(listener);
7996
7997        let consumer_cfg = HttpServerConfig {
7998            scheme: "http".to_string(),
7999            host: "127.0.0.1".to_string(),
8000            port,
8001            path: "/saturation".to_string(),
8002            max_request_body: 2 * 1024 * 1024,
8003            max_response_body: 10 * 1024 * 1024,
8004            max_inflight_requests: 1,
8005            method: None,
8006            tls_config: None,
8007        };
8008        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8009
8010        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8011        let token = tokio_util::sync::CancellationToken::new();
8012        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8013        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8014        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8015
8016        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
8017        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
8018
8019        tokio::spawn(async move {
8020            let mut first_seen_tx = Some(first_seen_tx);
8021            let mut unblock_first_rx = Some(unblock_first_rx);
8022
8023            while let Some(envelope) = rx.recv().await {
8024                if let Some(tx) = first_seen_tx.take() {
8025                    let _ = tx.send(());
8026                    if let Some(rx_unblock) = unblock_first_rx.take() {
8027                        let _ = rx_unblock.await;
8028                    }
8029                }
8030
8031                if let Some(reply_tx) = envelope.reply_tx {
8032                    let _ = reply_tx.send(Ok(envelope.exchange));
8033                }
8034            }
8035        });
8036
8037        let client = reqwest::Client::new();
8038        let first_req = {
8039            let client = client.clone();
8040            async move {
8041                client
8042                    .get(format!("http://127.0.0.1:{port}/saturation"))
8043                    .send()
8044                    .await
8045                    .unwrap()
8046            }
8047        };
8048
8049        let first_handle = tokio::spawn(first_req);
8050        first_seen_rx.await.unwrap();
8051
8052        let second_resp = client
8053            .get(format!("http://127.0.0.1:{port}/saturation"))
8054            .send()
8055            .await
8056            .unwrap();
8057
8058        assert_eq!(second_resp.status().as_u16(), 503);
8059
8060        let _ = unblock_first_tx.send(());
8061        let first_resp = first_handle.await.unwrap();
8062        assert_eq!(first_resp.status().as_u16(), 200);
8063
8064        token.cancel();
8065    }
8066
8067    /// Audit 2026-08-31, F2-1: a chunked (no Content-Length) request body must
8068    /// still be capped — the byte limit travels with the stream, so any
8069    /// downstream materialization fails closed past `max_request_body`.
8070    #[tokio::test]
8071    async fn test_http_consumer_chunked_body_is_capped() {
8072        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8073
8074        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8075        let port = listener.local_addr().unwrap().port();
8076        drop(listener);
8077
8078        let consumer_cfg = HttpServerConfig {
8079            scheme: "http".to_string(),
8080            host: "127.0.0.1".to_string(),
8081            port,
8082            path: "/chunked-cap".to_string(),
8083            max_request_body: 1024, // tiny cap for the test
8084            max_response_body: 10 * 1024 * 1024,
8085            max_inflight_requests: 16,
8086            method: None,
8087            tls_config: None,
8088        };
8089        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8090
8091        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8092        let token = tokio_util::sync::CancellationToken::new();
8093        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8094        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8095        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8096
8097        // Chunked body: reqwest streams it without Content-Length.
8098        let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
8099            .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
8100            .collect();
8101        let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
8102
8103        let client = reqwest::Client::new();
8104        let send_fut = client
8105            .post(format!("http://127.0.0.1:{port}/chunked-cap"))
8106            .body(stream_body)
8107            .send();
8108
8109        let (http_result, _) = tokio::join!(send_fut, async {
8110            if let Some(mut envelope) = rx.recv().await {
8111                // The route materializes the body — the cap must fire.
8112                let materialized = envelope
8113                    .exchange
8114                    .input
8115                    .body
8116                    .clone()
8117                    .into_bytes(64 * 1024)
8118                    .await;
8119                assert!(
8120                    materialized.is_err(),
8121                    "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
8122                );
8123                let err = materialized.unwrap_err().to_string();
8124                assert!(
8125                    err.contains("limit") || err.contains("exceeds"),
8126                    "error should mention the limit: {err}"
8127                );
8128                if let Some(reply_tx) = envelope.reply_tx {
8129                    envelope.exchange.input.body =
8130                        camel_component_api::Body::Text("handled".to_string());
8131                    let _ = reply_tx.send(Ok(envelope.exchange));
8132                }
8133            }
8134        });
8135
8136        let resp = http_result.unwrap();
8137        assert_eq!(resp.status().as_u16(), 200);
8138
8139        token.cancel();
8140    }
8141
8142    #[tokio::test]
8143    #[allow(clippy::await_holding_lock)]
8144    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
8145        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8146
8147        let _guard = lock_registry_test_mutex();
8148
8149        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8150        let port = listener.local_addr().unwrap().port();
8151        drop(listener);
8152
8153        let consumer_cfg = HttpServerConfig {
8154            scheme: "http".to_string(),
8155            host: "127.0.0.1".to_string(),
8156            port,
8157            path: "/limit-bytes".to_string(),
8158            max_request_body: 2 * 1024 * 1024,
8159            max_response_body: 16,
8160            max_inflight_requests: 1024,
8161            method: None,
8162            tls_config: None,
8163        };
8164        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8165
8166        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8167        let token = tokio_util::sync::CancellationToken::new();
8168        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8169        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8170        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8171
8172        let client = reqwest::Client::new();
8173        let send_fut = client
8174            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
8175            .send();
8176
8177        let (http_result, _) = tokio::join!(send_fut, async {
8178            if let Some(mut envelope) = rx.recv().await {
8179                envelope.exchange.input.body =
8180                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
8181                if let Some(reply_tx) = envelope.reply_tx {
8182                    let _ = reply_tx.send(Ok(envelope.exchange));
8183                }
8184            }
8185        });
8186
8187        let resp = http_result.unwrap();
8188        assert_eq!(resp.status().as_u16(), 500);
8189        let body = resp.text().await.unwrap();
8190        assert_eq!(body, "Response body exceeds configured limit");
8191        token.cancel();
8192    }
8193
8194    #[tokio::test]
8195    #[allow(clippy::await_holding_lock)]
8196    async fn test_http_consumer_enforces_max_response_body_for_json() {
8197        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8198
8199        let _guard = lock_registry_test_mutex();
8200
8201        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8202        let port = listener.local_addr().unwrap().port();
8203        drop(listener);
8204
8205        let consumer_cfg = HttpServerConfig {
8206            scheme: "http".to_string(),
8207            host: "127.0.0.1".to_string(),
8208            port,
8209            path: "/limit-json".to_string(),
8210            max_request_body: 2 * 1024 * 1024,
8211            max_response_body: 16,
8212            max_inflight_requests: 1024,
8213            method: None,
8214            tls_config: None,
8215        };
8216        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8217
8218        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8219        let token = tokio_util::sync::CancellationToken::new();
8220        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8221        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8222        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8223
8224        let client = reqwest::Client::new();
8225        let send_fut = client
8226            .get(format!("http://127.0.0.1:{port}/limit-json"))
8227            .send();
8228
8229        let (http_result, _) = tokio::join!(send_fut, async {
8230            if let Some(mut envelope) = rx.recv().await {
8231                envelope.exchange.input.body = camel_component_api::Body::Json(
8232                    serde_json::json!({"message":"this response is bigger than sixteen"}),
8233                );
8234                if let Some(reply_tx) = envelope.reply_tx {
8235                    let _ = reply_tx.send(Ok(envelope.exchange));
8236                }
8237            }
8238        });
8239
8240        let resp = http_result.unwrap();
8241        assert_eq!(resp.status().as_u16(), 500);
8242        let body = resp.text().await.unwrap();
8243        assert_eq!(body, "Response body exceeds configured limit");
8244        token.cancel();
8245    }
8246
8247    #[tokio::test]
8248    #[allow(clippy::await_holding_lock)]
8249    async fn test_http_consumer_enforces_max_response_body_for_xml() {
8250        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8251
8252        let _guard = lock_registry_test_mutex();
8253
8254        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8255        let port = listener.local_addr().unwrap().port();
8256        drop(listener);
8257
8258        let consumer_cfg = HttpServerConfig {
8259            scheme: "http".to_string(),
8260            host: "127.0.0.1".to_string(),
8261            port,
8262            path: "/limit-xml".to_string(),
8263            max_request_body: 2 * 1024 * 1024,
8264            max_response_body: 16,
8265            max_inflight_requests: 1024,
8266            method: None,
8267            tls_config: None,
8268        };
8269        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8270
8271        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8272        let token = tokio_util::sync::CancellationToken::new();
8273        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8274        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8275        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8276
8277        let client = reqwest::Client::new();
8278        let send_fut = client
8279            .get(format!("http://127.0.0.1:{port}/limit-xml"))
8280            .send();
8281
8282        let (http_result, _) = tokio::join!(send_fut, async {
8283            if let Some(mut envelope) = rx.recv().await {
8284                envelope.exchange.input.body = camel_component_api::Body::Xml(
8285                    "<root><value>way-too-large</value></root>".into(),
8286                );
8287                if let Some(reply_tx) = envelope.reply_tx {
8288                    let _ = reply_tx.send(Ok(envelope.exchange));
8289                }
8290            }
8291        });
8292
8293        let resp = http_result.unwrap();
8294        assert_eq!(resp.status().as_u16(), 500);
8295        let body = resp.text().await.unwrap();
8296        assert_eq!(body, "Response body exceeds configured limit");
8297        token.cancel();
8298    }
8299
8300    #[tokio::test]
8301    #[allow(clippy::await_holding_lock)]
8302    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
8303        use camel_component_api::{
8304            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
8305        };
8306        use futures::stream;
8307
8308        let _guard = lock_registry_test_mutex();
8309
8310        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
8311        let port = listener.local_addr().unwrap().port();
8312        drop(listener);
8313
8314        let consumer_cfg = HttpServerConfig {
8315            scheme: "http".to_string(),
8316            host: "0.0.0.0".to_string(),
8317            port,
8318            path: "/limit-stream".to_string(),
8319            max_request_body: 2 * 1024 * 1024,
8320            max_response_body: 16,
8321            max_inflight_requests: 1024,
8322            method: None,
8323            tls_config: None,
8324        };
8325        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8326
8327        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8328        let token = tokio_util::sync::CancellationToken::new();
8329        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8330        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8331        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8332
8333        let client = reqwest::Client::new();
8334        let send_fut = client
8335            .get(format!("http://127.0.0.1:{port}/limit-stream"))
8336            .send();
8337
8338        let (http_result, _) = tokio::join!(send_fut, async {
8339            if let Some(mut envelope) = rx.recv().await {
8340                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
8341                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
8342                let stream = Box::pin(stream::iter(chunks));
8343                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
8344                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
8345                    metadata: StreamMetadata {
8346                        size_hint: Some(32),
8347                        content_type: Some("application/octet-stream".into()),
8348                        origin: None,
8349                    },
8350                });
8351                if let Some(reply_tx) = envelope.reply_tx {
8352                    let _ = reply_tx.send(Ok(envelope.exchange));
8353                }
8354            }
8355        });
8356
8357        let resp = http_result.unwrap();
8358        assert_eq!(resp.status().as_u16(), 200);
8359        let body = resp.bytes().await.unwrap();
8360        assert_eq!(body.len(), 32);
8361        token.cancel();
8362    }
8363
8364    // -----------------------------------------------------------------------
8365    // Integration tests
8366    // -----------------------------------------------------------------------
8367
8368    #[tokio::test]
8369    #[allow(clippy::await_holding_lock)]
8370    async fn test_integration_single_consumer_round_trip() {
8371        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8372
8373        // Spawns an HTTP consumer on the global ServerRegistry
8374        // (HttpConsumer::start → get_or_spawn). Serialize against the other
8375        // registry tests so parallel runs do not race on shared global state.
8376        let _guard = lock_registry_test_mutex();
8377
8378        // Get an OS-assigned free port (ephemeral)
8379        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8380        let port = listener.local_addr().unwrap().port();
8381        drop(listener); // Release — ServerRegistry will rebind
8382
8383        let component = HttpComponent::new();
8384        let endpoint_ctx = NoOpComponentContext;
8385        let endpoint = component
8386            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
8387            .unwrap();
8388        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8389
8390        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8391        let token = tokio_util::sync::CancellationToken::new();
8392        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8393
8394        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8395        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8396
8397        let client = reqwest::Client::new();
8398        let send_fut = client
8399            .post(format!("http://127.0.0.1:{port}/echo"))
8400            .header("Content-Type", "text/plain")
8401            .body("ping")
8402            .send();
8403
8404        let (http_result, _) = tokio::join!(send_fut, async {
8405            if let Some(mut envelope) = rx.recv().await {
8406                assert_eq!(
8407                    envelope.exchange.input.header("CamelHttpMethod"),
8408                    Some(&serde_json::Value::String("POST".into()))
8409                );
8410                assert_eq!(
8411                    envelope.exchange.input.header("CamelHttpPath"),
8412                    Some(&serde_json::Value::String("/echo".into()))
8413                );
8414                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
8415                if let Some(reply_tx) = envelope.reply_tx {
8416                    let _ = reply_tx.send(Ok(envelope.exchange));
8417                }
8418            }
8419        });
8420
8421        let resp = http_result.unwrap();
8422        assert_eq!(resp.status().as_u16(), 200);
8423        let body = resp.text().await.unwrap();
8424        assert_eq!(body, "pong");
8425
8426        token.cancel();
8427    }
8428
8429    #[tokio::test]
8430    #[allow(clippy::await_holding_lock)]
8431    async fn test_integration_two_consumers_shared_port() {
8432        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8433
8434        let _guard = lock_registry_test_mutex();
8435
8436        // Get an OS-assigned free port (ephemeral)
8437        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8438        let port = listener.local_addr().unwrap().port();
8439        drop(listener);
8440
8441        let component = HttpComponent::new();
8442        let endpoint_ctx = NoOpComponentContext;
8443
8444        // Consumer A: /hello
8445        let endpoint_a = component
8446            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
8447            .unwrap();
8448        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
8449
8450        // Consumer B: /world
8451        let endpoint_b = component
8452            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
8453            .unwrap();
8454        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
8455
8456        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8457        let token_a = tokio_util::sync::CancellationToken::new();
8458        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
8459
8460        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8461        let token_b = tokio_util::sync::CancellationToken::new();
8462        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
8463
8464        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
8465        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
8466        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8467
8468        let client = reqwest::Client::new();
8469
8470        // Request to /hello
8471        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
8472        let (resp_hello, _) = tokio::join!(fut_hello, async {
8473            if let Some(mut envelope) = rx_a.recv().await {
8474                envelope.exchange.input.body =
8475                    camel_component_api::Body::Text("hello-response".to_string());
8476                if let Some(reply_tx) = envelope.reply_tx {
8477                    let _ = reply_tx.send(Ok(envelope.exchange));
8478                }
8479            }
8480        });
8481
8482        // Request to /world
8483        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
8484        let (resp_world, _) = tokio::join!(fut_world, async {
8485            if let Some(mut envelope) = rx_b.recv().await {
8486                envelope.exchange.input.body =
8487                    camel_component_api::Body::Text("world-response".to_string());
8488                if let Some(reply_tx) = envelope.reply_tx {
8489                    let _ = reply_tx.send(Ok(envelope.exchange));
8490                }
8491            }
8492        });
8493
8494        let body_a = resp_hello.unwrap().text().await.unwrap();
8495        let body_b = resp_world.unwrap().text().await.unwrap();
8496
8497        assert_eq!(body_a, "hello-response");
8498        assert_eq!(body_b, "world-response");
8499
8500        token_a.cancel();
8501        token_b.cancel();
8502    }
8503
8504    #[tokio::test]
8505    #[allow(clippy::await_holding_lock)]
8506    async fn test_integration_unregistered_path_returns_404() {
8507        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8508
8509        let _guard = lock_registry_test_mutex();
8510
8511        // Get an OS-assigned free port (ephemeral)
8512        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8513        let port = listener.local_addr().unwrap().port();
8514        drop(listener);
8515
8516        let component = HttpComponent::new();
8517        let endpoint_ctx = NoOpComponentContext;
8518        let endpoint = component
8519            .create_endpoint(
8520                &format!("http://127.0.0.1:{port}/registered"),
8521                &endpoint_ctx,
8522            )
8523            .unwrap();
8524        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8525
8526        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8527        let token = tokio_util::sync::CancellationToken::new();
8528        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8529
8530        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8531
8532        // Wait until the server is actually accepting connections (CI runners can be slow).
8533        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8534        loop {
8535            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
8536                .await
8537                .is_ok()
8538            {
8539                break;
8540            }
8541            if std::time::Instant::now() >= deadline {
8542                panic!("HTTP server did not start within 5s on port {port}");
8543            }
8544            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8545        }
8546
8547        let client = reqwest::Client::new();
8548        let resp = client
8549            .get(format!("http://127.0.0.1:{port}/not-there"))
8550            .send()
8551            .await
8552            .unwrap();
8553        assert_eq!(resp.status().as_u16(), 404);
8554
8555        token.cancel();
8556    }
8557
8558    #[test]
8559    fn test_http_consumer_declares_concurrent() {
8560        use camel_component_api::ConcurrencyModel;
8561
8562        let config = HttpServerConfig {
8563            scheme: "http".to_string(),
8564            host: "127.0.0.1".to_string(),
8565            port: 19999,
8566            path: "/test".to_string(),
8567            max_request_body: 2 * 1024 * 1024,
8568            max_response_body: 10 * 1024 * 1024,
8569            max_inflight_requests: 1024,
8570            method: None,
8571            tls_config: None,
8572        };
8573        let consumer = HttpConsumer::new(config, test_rt());
8574        assert_eq!(
8575            consumer.concurrency_model(),
8576            ConcurrencyModel::Concurrent { max: None }
8577        );
8578    }
8579
8580    #[test]
8581    fn server_config_parses_tls_cert_and_key() {
8582        let cfg = HttpServerConfig::from_uri(
8583            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
8584        )
8585        .unwrap();
8586        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
8587        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
8588    }
8589
8590    #[test]
8591    fn server_config_no_tls_when_params_absent() {
8592        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
8593        assert!(cfg.tls_config.is_none());
8594    }
8595
8596    // -----------------------------------------------------------------------
8597    // HttpReplyBody streaming tests
8598    // -----------------------------------------------------------------------
8599
8600    #[tokio::test]
8601    async fn test_http_reply_body_stream_variant_exists() {
8602        use bytes::Bytes;
8603        use camel_component_api::CamelError;
8604        use futures::stream;
8605
8606        let chunks: Vec<Result<Bytes, CamelError>> =
8607            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
8608        let stream = Box::pin(stream::iter(chunks));
8609        let reply_body = HttpReplyBody::Stream(stream);
8610        // Si compila y el match funciona, el test pasa
8611        match reply_body {
8612            HttpReplyBody::Stream(_) => {}
8613            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
8614        }
8615    }
8616
8617    // -----------------------------------------------------------------------
8618    // OpenTelemetry propagation tests (only compiled with "otel" feature)
8619    // -----------------------------------------------------------------------
8620
8621    #[cfg(feature = "otel")]
8622    mod otel_tests {
8623        use super::*;
8624        use camel_component_api::Message;
8625        use tower::ServiceExt;
8626
8627        #[tokio::test]
8628        async fn test_producer_injects_traceparent_header() {
8629            let (url, _handle) = start_test_server_with_header_capture().await;
8630            let ctx = test_producer_ctx();
8631
8632            let component = HttpComponent::new();
8633            let endpoint_ctx = NoOpComponentContext;
8634            let endpoint = component
8635                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8636                .unwrap();
8637            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8638
8639            // Create exchange with an OTel context by extracting from a traceparent header
8640            let mut exchange = Exchange::new(Message::default());
8641            let mut headers = std::collections::HashMap::new();
8642            headers.insert(
8643                "traceparent".to_string(),
8644                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
8645            );
8646            camel_otel::extract_into_exchange(&mut exchange, &headers);
8647
8648            let result = producer.oneshot(exchange).await.unwrap();
8649
8650            // Verify request succeeded
8651            let status = result
8652                .input
8653                .header("CamelHttpResponseCode")
8654                .and_then(|v| v.as_u64())
8655                .unwrap();
8656            assert_eq!(status, 200);
8657
8658            // The test server echoes back the received traceparent header
8659            let traceparent = result.input.header("X-Received-Traceparent");
8660            assert!(
8661                traceparent.is_some(),
8662                "traceparent header should have been sent"
8663            );
8664
8665            let traceparent_str = traceparent.unwrap().as_str().unwrap();
8666            // Verify format: version-traceid-spanid-flags
8667            let parts: Vec<&str> = traceparent_str.split('-').collect();
8668            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8669            assert_eq!(parts[0], "00", "version should be 00");
8670            assert_eq!(
8671                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8672                "trace-id should match"
8673            );
8674            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
8675            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
8676        }
8677
8678        #[tokio::test]
8679        async fn test_consumer_extracts_traceparent_header() {
8680            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8681
8682            // Get an OS-assigned free port
8683            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8684            let port = listener.local_addr().unwrap().port();
8685            drop(listener);
8686
8687            let component = HttpComponent::new();
8688            let endpoint_ctx = NoOpComponentContext;
8689            let endpoint = component
8690                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8691                .unwrap();
8692            let mut consumer = endpoint.create_consumer(rt()).unwrap();
8693
8694            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8695            let token = tokio_util::sync::CancellationToken::new();
8696            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8697
8698            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8699            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8700
8701            // Send request with traceparent header
8702            let client = reqwest::Client::new();
8703            let send_fut = client
8704                .post(format!("http://127.0.0.1:{port}/trace"))
8705                .header(
8706                    "traceparent",
8707                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8708                )
8709                .body("test")
8710                .send();
8711
8712            let (http_result, _) = tokio::join!(send_fut, async {
8713                if let Some(envelope) = rx.recv().await {
8714                    // Verify the exchange has a valid OTel context by re-injecting it
8715                    // and checking the traceparent matches
8716                    let mut injected_headers = std::collections::HashMap::new();
8717                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8718
8719                    assert!(
8720                        injected_headers.contains_key("traceparent"),
8721                        "Exchange should have traceparent after extraction"
8722                    );
8723
8724                    let traceparent = injected_headers.get("traceparent").unwrap();
8725                    let parts: Vec<&str> = traceparent.split('-').collect();
8726                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8727                    assert_eq!(
8728                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8729                        "Trace ID should match the original traceparent header"
8730                    );
8731
8732                    if let Some(reply_tx) = envelope.reply_tx {
8733                        let _ = reply_tx.send(Ok(envelope.exchange));
8734                    }
8735                }
8736            });
8737
8738            let resp = http_result.unwrap();
8739            assert_eq!(resp.status().as_u16(), 200);
8740
8741            token.cancel();
8742        }
8743
8744        #[tokio::test]
8745        async fn test_consumer_extracts_mixed_case_traceparent_header() {
8746            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8747
8748            // Get an OS-assigned free port
8749            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8750            let port = listener.local_addr().unwrap().port();
8751            drop(listener);
8752
8753            let component = HttpComponent::new();
8754            let endpoint_ctx = NoOpComponentContext;
8755            let endpoint = component
8756                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8757                .unwrap();
8758            let mut consumer = endpoint.create_consumer(rt()).unwrap();
8759
8760            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8761            let token = tokio_util::sync::CancellationToken::new();
8762            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8763
8764            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8765            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8766
8767            // Send request with MIXED-CASE TraceParent header (not lowercase)
8768            let client = reqwest::Client::new();
8769            let send_fut = client
8770                .post(format!("http://127.0.0.1:{port}/trace"))
8771                .header(
8772                    "TraceParent",
8773                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8774                )
8775                .body("test")
8776                .send();
8777
8778            let (http_result, _) = tokio::join!(send_fut, async {
8779                if let Some(envelope) = rx.recv().await {
8780                    // Verify the exchange has a valid OTel context by re-injecting it
8781                    // and checking the traceparent matches
8782                    let mut injected_headers = HashMap::new();
8783                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8784
8785                    assert!(
8786                        injected_headers.contains_key("traceparent"),
8787                        "Exchange should have traceparent after extraction from mixed-case header"
8788                    );
8789
8790                    let traceparent = injected_headers.get("traceparent").unwrap();
8791                    let parts: Vec<&str> = traceparent.split('-').collect();
8792                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8793                    assert_eq!(
8794                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8795                        "Trace ID should match the original mixed-case TraceParent header"
8796                    );
8797
8798                    if let Some(reply_tx) = envelope.reply_tx {
8799                        let _ = reply_tx.send(Ok(envelope.exchange));
8800                    }
8801                }
8802            });
8803
8804            let resp = http_result.unwrap();
8805            assert_eq!(resp.status().as_u16(), 200);
8806
8807            token.cancel();
8808        }
8809
8810        #[tokio::test]
8811        async fn test_producer_no_trace_context_no_crash() {
8812            let (url, _handle) = start_test_server().await;
8813            let ctx = test_producer_ctx();
8814
8815            let component = HttpComponent::new();
8816            let endpoint_ctx = NoOpComponentContext;
8817            let endpoint = component
8818                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8819                .unwrap();
8820            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8821
8822            // Create exchange with default (empty) otel_context - no trace context
8823            let exchange = Exchange::new(Message::default());
8824
8825            // Should succeed without panic
8826            let result = producer.oneshot(exchange).await.unwrap();
8827
8828            // Verify request succeeded
8829            let status = result
8830                .input
8831                .header("CamelHttpResponseCode")
8832                .and_then(|v| v.as_u64())
8833                .unwrap();
8834            assert_eq!(status, 200);
8835        }
8836
8837        /// Test server that captures and echoes back the traceparent header
8838        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
8839            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8840            let addr = listener.local_addr().unwrap();
8841            let url = format!("http://127.0.0.1:{}", addr.port());
8842
8843            let handle = tokio::spawn(async move {
8844                loop {
8845                    if let Ok((mut stream, _)) = listener.accept().await {
8846                        tokio::spawn(async move {
8847                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
8848                            let mut buf = vec![0u8; 8192];
8849                            let n = stream.read(&mut buf).await.unwrap_or(0);
8850                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
8851
8852                            // Extract traceparent header from request
8853                            let traceparent = request
8854                                .lines()
8855                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
8856                                .map(|line| {
8857                                    line.split(':')
8858                                        .nth(1)
8859                                        .map(|s| s.trim().to_string())
8860                                        .unwrap_or_default()
8861                                })
8862                                .unwrap_or_default();
8863
8864                            let body =
8865                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
8866                            let response = format!(
8867                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
8868                                body.len(),
8869                                traceparent,
8870                                body
8871                            );
8872                            let _ = stream.write_all(response.as_bytes()).await;
8873                        });
8874                    }
8875                }
8876            });
8877
8878            (url, handle)
8879        }
8880    }
8881
8882    // -----------------------------------------------------------------------
8883    // Response streaming tests (Eje A - Task 2)
8884    // -----------------------------------------------------------------------
8885
8886    // -----------------------------------------------------------------------
8887    // Request streaming tests (Eje B - Task 3)
8888    // -----------------------------------------------------------------------
8889
8890    #[tokio::test]
8891    async fn test_request_body_arrives_as_stream() {
8892        use camel_component_api::Body;
8893        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8894
8895        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8896        let port = listener.local_addr().unwrap().port();
8897        drop(listener);
8898
8899        let component = HttpComponent::new();
8900        let endpoint_ctx = NoOpComponentContext;
8901        let endpoint = component
8902            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
8903            .unwrap();
8904        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8905
8906        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8907        let token = tokio_util::sync::CancellationToken::new();
8908        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8909
8910        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8911        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8912
8913        let client = reqwest::Client::new();
8914        let send_fut = client
8915            .post(format!("http://127.0.0.1:{port}/upload"))
8916            .body("hello streaming world")
8917            .send();
8918
8919        let (http_result, _) = tokio::join!(send_fut, async {
8920            if let Some(mut envelope) = rx.recv().await {
8921                // Body must be Body::Stream, not Body::Text or Body::Bytes
8922                assert!(
8923                    matches!(envelope.exchange.input.body, Body::Stream(_)),
8924                    "expected Body::Stream, got discriminant {:?}",
8925                    std::mem::discriminant(&envelope.exchange.input.body)
8926                );
8927                // Materialize to verify content
8928                let bytes = envelope
8929                    .exchange
8930                    .input
8931                    .body
8932                    .into_bytes(1024 * 1024)
8933                    .await
8934                    .unwrap();
8935                assert_eq!(&bytes[..], b"hello streaming world");
8936
8937                envelope.exchange.input.body = camel_component_api::Body::Empty;
8938                if let Some(reply_tx) = envelope.reply_tx {
8939                    let _ = reply_tx.send(Ok(envelope.exchange));
8940                }
8941            }
8942        });
8943
8944        let resp = http_result.unwrap();
8945        assert_eq!(resp.status().as_u16(), 200);
8946
8947        token.cancel();
8948    }
8949
8950    // -----------------------------------------------------------------------
8951    // Response streaming tests (Eje A - Task 2)
8952    // -----------------------------------------------------------------------
8953
8954    #[tokio::test]
8955    async fn test_streaming_response_chunked() {
8956        use bytes::Bytes;
8957        use camel_component_api::Body;
8958        use camel_component_api::CamelError;
8959        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8960        use camel_component_api::{StreamBody, StreamMetadata};
8961        use futures::stream;
8962        use std::sync::Arc;
8963        use tokio::sync::Mutex;
8964
8965        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8966        let port = listener.local_addr().unwrap().port();
8967        drop(listener);
8968
8969        let component = HttpComponent::new();
8970        let endpoint_ctx = NoOpComponentContext;
8971        let endpoint = component
8972            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
8973            .unwrap();
8974        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8975
8976        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8977        let token = tokio_util::sync::CancellationToken::new();
8978        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8979
8980        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8981        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8982
8983        let client = reqwest::Client::new();
8984        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
8985
8986        let (http_result, _) = tokio::join!(send_fut, async {
8987            if let Some(mut envelope) = rx.recv().await {
8988                // Respond with Body::Stream
8989                let chunks: Vec<Result<Bytes, CamelError>> =
8990                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
8991                let stream = Box::pin(stream::iter(chunks));
8992                envelope.exchange.input.body = Body::Stream(StreamBody {
8993                    stream: Arc::new(Mutex::new(Some(stream))),
8994                    metadata: StreamMetadata::default(),
8995                });
8996                if let Some(reply_tx) = envelope.reply_tx {
8997                    let _ = reply_tx.send(Ok(envelope.exchange));
8998                }
8999            }
9000        });
9001
9002        let resp = http_result.unwrap();
9003        assert_eq!(resp.status().as_u16(), 200);
9004        let body = resp.text().await.unwrap();
9005        assert_eq!(body, "chunk1chunk2");
9006
9007        token.cancel();
9008    }
9009
9010    // -----------------------------------------------------------------------
9011    // 413 Content-Length limit test (Task 4)
9012    // -----------------------------------------------------------------------
9013
9014    #[tokio::test]
9015    async fn test_413_when_content_length_exceeds_limit() {
9016        use camel_component_api::ConsumerContext;
9017
9018        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9019        let port = listener.local_addr().unwrap().port();
9020        drop(listener);
9021
9022        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
9023        let component = HttpComponent::new();
9024        let endpoint_ctx = NoOpComponentContext;
9025        let endpoint = component
9026            .create_endpoint(
9027                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
9028                &endpoint_ctx,
9029            )
9030            .unwrap();
9031        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9032
9033        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9034        let token = tokio_util::sync::CancellationToken::new();
9035        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9036
9037        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9038        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9039
9040        let client = reqwest::Client::new();
9041        let resp = client
9042            .post(format!("http://127.0.0.1:{port}/upload"))
9043            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
9044            .body("x".repeat(1000))
9045            .send()
9046            .await
9047            .unwrap();
9048
9049        assert_eq!(resp.status().as_u16(), 413);
9050
9051        token.cancel();
9052    }
9053
9054    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
9055    /// The spec says: "If there is no Content-Length, the limit does not apply at the
9056    /// consumer level — the route is responsible."
9057    #[tokio::test]
9058    async fn test_chunked_upload_without_content_length_bypasses_limit() {
9059        use bytes::Bytes;
9060        use camel_component_api::Body;
9061        use camel_component_api::ConsumerContext;
9062        use futures::stream;
9063
9064        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9065        let port = listener.local_addr().unwrap().port();
9066        drop(listener);
9067
9068        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
9069        let component = HttpComponent::new();
9070        let endpoint_ctx = NoOpComponentContext;
9071        let endpoint = component
9072            .create_endpoint(
9073                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
9074                &endpoint_ctx,
9075            )
9076            .unwrap();
9077        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9078
9079        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9080        let token = tokio_util::sync::CancellationToken::new();
9081        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9082
9083        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9084        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9085
9086        let client = reqwest::Client::new();
9087
9088        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
9089        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
9090        // but since there's no Content-Length the 413 check must NOT fire.
9091        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
9092            Ok(Bytes::from("y".repeat(50))),
9093            Ok(Bytes::from("y".repeat(50))),
9094        ];
9095        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
9096        let send_fut = client
9097            .post(format!("http://127.0.0.1:{port}/upload"))
9098            .body(stream_body)
9099            .send();
9100
9101        let consumer_fut = async {
9102            // Use timeout to avoid deadlock if the handler rejects before enqueueing
9103            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
9104                Ok(Some(mut envelope)) => {
9105                    assert!(
9106                        matches!(envelope.exchange.input.body, Body::Stream(_)),
9107                        "expected Body::Stream"
9108                    );
9109                    envelope.exchange.input.body = camel_component_api::Body::Empty;
9110                    if let Some(reply_tx) = envelope.reply_tx {
9111                        let _ = reply_tx.send(Ok(envelope.exchange));
9112                    }
9113                }
9114                Ok(None) => panic!("consumer channel closed unexpectedly"),
9115                Err(_) => {
9116                    // Timeout: the request was rejected before reaching the consumer.
9117                    // The HTTP response will carry the real status code (we check below).
9118                }
9119            }
9120        };
9121
9122        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
9123
9124        let resp = http_result.unwrap();
9125        // Audit 2026-08-31 (F2-1): the request is no longer rejected at the door
9126        // (no Content-Length to pre-check), but the byte cap now travels with the
9127        // stream: ANY materialization past maxRequestBody fails closed. This test
9128        // does not consume the body, so the request still completes with 200 —
9129        // enforcement happens at consumption time (see
9130        // test_http_consumer_chunked_body_is_capped).
9131        assert_ne!(
9132            resp.status().as_u16(),
9133            413,
9134            "chunked upload has no Content-Length to pre-check"
9135        );
9136        assert_eq!(resp.status().as_u16(), 200);
9137
9138        token.cancel();
9139    }
9140
9141    #[test]
9142    fn test_is_private_ip_ranges() {
9143        use camel_api::is_ssrf_blocked_ip;
9144        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
9145        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
9146        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
9147        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
9148        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
9149        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
9150
9151        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
9152        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
9153        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
9154        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
9155        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
9156        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
9157        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
9158        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
9159
9160        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
9161        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
9162        assert!(!is_ssrf_blocked_ip(
9163            &"2001:4860:4860::8888".parse().unwrap()
9164        )); // allow-unwrap
9165    }
9166
9167    #[test]
9168    fn test_title_case_header() {
9169        assert_eq!(title_case_header("content-type"), "Content-Type");
9170        assert_eq!(title_case_header("authorization"), "Authorization");
9171        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
9172        assert_eq!(title_case_header("host"), "Host");
9173        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
9174        assert_eq!(title_case_header("single"), "Single");
9175        assert_eq!(title_case_header(""), "");
9176    }
9177
9178    #[test]
9179    fn test_resolve_url_combines_path_and_query_sources() {
9180        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
9181        let mut exchange = Exchange::new(Message::default());
9182        exchange.input.set_header(
9183            "CamelHttpPath",
9184            serde_json::Value::String("next".to_string()),
9185        );
9186        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9187        assert!(url.starts_with("http://example.com/base/next?"));
9188        assert!(url.contains("foo=bar"));
9189
9190        exchange.input.set_header(
9191            "CamelHttpUri",
9192            serde_json::Value::String("http://other.test/root".to_string()),
9193        );
9194        exchange.input.set_header(
9195            "CamelHttpQuery",
9196            serde_json::Value::String("a=1&b=2".to_string()),
9197        );
9198
9199        let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9200        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
9201    }
9202
9203    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
9204        let mut exchange = Exchange::new(Message::default());
9205        exchange
9206            .input
9207            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
9208        exchange.input.set_header(
9209            "CamelHttpQuery",
9210            serde_json::Value::String(query.to_string()),
9211        );
9212        exchange
9213    }
9214
9215    #[test]
9216    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
9217        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9218        cfg.bridge_endpoint = true;
9219        cfg.query_params
9220            .push(("token".to_string(), "secret".to_string()));
9221        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9222        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9223        // Verbatim assembly: the old round-trip normalized the empty base
9224        // path to `/` (`http://x/?token=secret`); authored bytes end-to-end
9225        // no longer insert it.
9226        assert_eq!(url, "http://x?token=secret");
9227        assert!(!url.contains("/foo"));
9228        assert!(!url.contains("dropme"));
9229    }
9230
9231    #[test]
9232    fn resolve_url_bridge_endpoint_false_merges_path() {
9233        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9234        cfg.bridge_endpoint = false;
9235        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9236        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9237        assert!(url.contains("/foo"), "url should contain /foo: {url}");
9238        assert!(
9239            url.contains("dropme=1"),
9240            "url should contain dropme=1: {url}"
9241        );
9242    }
9243
9244    #[test]
9245    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
9246        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9247        cfg.bridge_endpoint = true;
9248        let mut exchange = Exchange::new(Message::default());
9249        exchange.input.set_header(
9250            "CamelHttpPath",
9251            serde_json::Value::String("/foo".to_string()),
9252        );
9253        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9254        assert_eq!(url, "http://x");
9255        assert!(!url.contains("/foo"));
9256    }
9257
9258    #[test]
9259    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
9260        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9261        cfg.bridge_endpoint = true;
9262        // query_params stays empty ([])
9263        let mut exchange = Exchange::new(Message::default());
9264        exchange.input.set_header(
9265            "CamelHttpUri",
9266            serde_json::Value::String("http://dest/explicit".to_string()),
9267        );
9268        exchange.input.set_header(
9269            "CamelHttpPath",
9270            serde_json::Value::String("/foo".to_string()),
9271        );
9272        exchange.input.set_header(
9273            "CamelHttpQuery",
9274            serde_json::Value::String("x=1".to_string()),
9275        );
9276        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9277        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
9278        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
9279        // wins verbatim.
9280        assert_eq!(url, "http://x");
9281    }
9282
9283    #[test]
9284    fn bridge_programmatic_params_use_percent20() {
9285        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9286        cfg.bridge_endpoint = true;
9287        cfg.query_params = vec![("b".to_string(), "x y".to_string())];
9288        let exchange = Exchange::new(Message::default());
9289
9290        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9291
9292        // `%20 never +` is global for programmatic values — the bridge arm
9293        // uses the same encoder as the non-bridge path. Bridging
9294        // semantics (what gets bridged, precedence) are unchanged.
9295        assert_eq!(url, "http://x?b=x%20y");
9296        assert!(!url.contains('+'));
9297    }
9298
9299    #[test]
9300    fn bridge_arm_carries_authored_raw_query() {
9301        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9302        // bridgeEndpoint is consumed as an endpoint option; a=1 is the
9303        // authored leftover riding raw_query.
9304        let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
9305
9306        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9307
9308        // Authored leftovers ride under bridging (Apache Camel semantics):
9309        // query is a=1 in authored bytes; exchange path/query stay ignored.
9310        assert_eq!(url, "http://h/p?a=1");
9311        assert!(!url.contains("dropme"), "exchange query leaked: {url}");
9312        assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
9313    }
9314
9315    // -----------------------------------------------------------------------
9316    // Bridge arm verbatim assembly (Papal Direction A): the bridged base is
9317    // never round-tripped through `url::Url` normalization — authored bytes
9318    // end-to-end, identical assembly to every other resolve_url arm.
9319    // -----------------------------------------------------------------------
9320
9321    #[test]
9322    fn resolve_url_bridge_preserves_dot_segments() {
9323        let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
9324        cfg.bridge_endpoint = true;
9325        cfg.query_params.push(("k".to_string(), "1".to_string()));
9326        let exchange = Exchange::new(Message::default());
9327
9328        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9329
9330        // Dot segments are authored bytes; the old round-trip collapsed
9331        // them (`/a/../b` → `/b`). Verbatim keeps them.
9332        assert_eq!(url, "http://h/a/../b?k=1");
9333    }
9334
9335    #[test]
9336    fn resolve_url_bridge_preserves_default_port() {
9337        let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
9338        cfg.bridge_endpoint = true;
9339        cfg.query_params.push(("k".to_string(), "1".to_string()));
9340        let exchange = Exchange::new(Message::default());
9341
9342        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9343
9344        // The old round-trip stripped the default port `:80`. Verbatim
9345        // keeps it.
9346        assert_eq!(url, "http://h:80/p?k=1");
9347    }
9348
9349    #[test]
9350    fn resolve_url_bridge_preserves_scheme_and_host_case() {
9351        let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
9352        cfg.bridge_endpoint = true;
9353        cfg.query_params.push(("k".to_string(), "1".to_string()));
9354        // `from_uri`'s scheme validation is case-sensitive, so the scheme
9355        // case is applied on the stored base directly — the resolve path
9356        // must carry whatever bytes the operator authored.
9357        cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
9358        let exchange = Exchange::new(Message::default());
9359
9360        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9361
9362        // The old round-trip lowercased scheme and host. Verbatim keeps
9363        // both authored.
9364        assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
9365    }
9366
9367    #[test]
9368    fn resolve_url_bridge_no_query_emits_base_verbatim() {
9369        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9370        cfg.bridge_endpoint = true;
9371        let exchange = Exchange::new(Message::default());
9372
9373        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9374
9375        // No resolved query: exactly the authored base — no synthetic `/`,
9376        // no dangling `?`.
9377        assert_eq!(url, "http://h/p");
9378    }
9379
9380    #[test]
9381    fn resolve_url_bridge_and_non_bridge_byte_identical() {
9382        // (a) Bridged arm: the effective query comes from programmatic
9383        // query_params.
9384        let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9385        bridged.bridge_endpoint = true;
9386        bridged
9387            .query_params
9388            .push(("k".to_string(), "1".to_string()));
9389        let bridge_url =
9390            HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
9391
9392        // (b) Non-bridge CamelHttpQuery composition path: same effective
9393        // query riding the exchange header.
9394        let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9395        let mut exchange = Exchange::new(Message::default());
9396        exchange.input.set_header(
9397            "CamelHttpQuery",
9398            serde_json::Value::String("k=1".to_string()),
9399        );
9400        let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
9401
9402        assert_eq!(bridge_url, plain_url);
9403        assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
9404    }
9405
9406    #[test]
9407    fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
9408        let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
9409        cfg.bridge_endpoint = true;
9410        cfg.query_params.push(("k".to_string(), "1".to_string()));
9411        let exchange = Exchange::new(Message::default());
9412
9413        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9414
9415        assert_eq!(url, "http://[::1]:8080/p?k=1");
9416    }
9417
9418    #[test]
9419    fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
9420        let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
9421        let exchange = Exchange::new(Message::default());
9422
9423        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9424
9425        // Authored query on an empty base path: the old round-trip
9426        // inserted a synthetic `/` (`http://h/?x=1`); verbatim does not.
9427        assert_eq!(url, "http://h?x=1");
9428    }
9429
9430    // -----------------------------------------------------------------------
9431    // Raw-preserving outbound query serialization (http-query-wire-fidelity)
9432    // -----------------------------------------------------------------------
9433
9434    #[test]
9435    fn resolve_url_preserves_authored_query_order_and_bytes() {
9436        let config =
9437            HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
9438        let exchange = Exchange::new(Message::default());
9439
9440        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9441
9442        // Authored order, authored separators, no %2C/%3A re-encoding,
9443        // consumed option (connectTimeout) removed.
9444        assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
9445    }
9446
9447    #[test]
9448    fn resolve_url_consumes_encoded_option_key() {
9449        let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
9450        let exchange = Exchange::new(Message::default());
9451
9452        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9453
9454        // The raw filter matches the decoded key, not the encoded bytes.
9455        assert_eq!(url, "http://h/p?a=1");
9456    }
9457
9458    #[test]
9459    fn resolve_url_all_options_consumed_drops_query() {
9460        let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
9461        let exchange = Exchange::new(Message::default());
9462
9463        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9464
9465        // A non-empty query whose every pair was consumed drops the query
9466        // component entirely — no dangling `?`.
9467        assert_eq!(url, "http://h/p");
9468        assert!(!url.contains('?'));
9469    }
9470
9471    #[test]
9472    fn resolve_url_preserves_empty_query_marker() {
9473        let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
9474        let exchange = Exchange::new(Message::default());
9475
9476        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9477
9478        // A bare `?` marker is preserved distinctly, never conflated with
9479        // an all-consumed query.
9480        assert_eq!(url, "http://h/p?");
9481    }
9482
9483    #[test]
9484    fn resolve_url_raw_wrapper_not_re_encoded() {
9485        let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
9486        let exchange = Exchange::new(Message::default());
9487
9488        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9489
9490        // RAW(...) wrapper bytes survive exactly as authored (rc-g4isv).
9491        assert_eq!(url, "http://h/p?token=RAW(abc)");
9492        assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
9493    }
9494
9495    #[test]
9496    fn resolve_url_camel_http_query_composes_verbatim_span() {
9497        let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
9498        let mut exchange = Exchange::new(Message::default());
9499        exchange.input.set_header(
9500            "CamelHttpQuery",
9501            serde_json::Value::String("userFilter=a%2Cb".to_string()),
9502        );
9503
9504        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9505
9506        // Policy change (ADR-0071): the header no longer replaces the
9507        // endpoint query — it composes, the endpoint winning collisions.
9508        // The header span bytes still ride verbatim: `a%2Cb` is carried
9509        // as-authored, never re-encoded (no %252C).
9510        assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
9511        assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
9512    }
9513
9514    // -----------------------------------------------------------------------
9515    // Outbound query composition (http-contract-surface, ADR-0071)
9516    // -----------------------------------------------------------------------
9517
9518    #[test]
9519    fn header_composes_with_endpoint_query() {
9520        let config =
9521            HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
9522        let mut exchange = Exchange::new(Message::default());
9523        exchange.input.set_header(
9524            "CamelHttpQuery",
9525            serde_json::Value::String("lang=es&page=2".to_string()),
9526        );
9527
9528        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9529
9530        // Higher precedence (endpoint) wins collisions: `lang` stays `en`;
9531        // the header appends only its absent keys.
9532        assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
9533    }
9534
9535    #[test]
9536    fn header_alone_still_rides() {
9537        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9538        let mut exchange = Exchange::new(Message::default());
9539        exchange.input.set_header(
9540            "CamelHttpQuery",
9541            serde_json::Value::String("page=2".to_string()),
9542        );
9543
9544        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9545
9546        // No endpoint query: the header pairs are the whole query.
9547        assert_eq!(url, "http://upstream/api?page=2");
9548    }
9549
9550    #[test]
9551    fn empty_reflected_query_leaves_endpoint_query_intact() {
9552        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9553        let mut exchange = Exchange::new(Message::default());
9554        // The consumer installs an empty CamelHttpQuery on requests that
9555        // arrived without a query string.
9556        exchange
9557            .input
9558            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9559
9560        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9561
9562        // No second `?` marker, no dropped endpoint pair.
9563        assert_eq!(url, "http://upstream/api?apiKey=secret");
9564        assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
9565    }
9566
9567    #[test]
9568    fn forbidden_byte_in_header_query_errors() {
9569        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9570        let mut exchange = Exchange::new(Message::default());
9571        exchange.input.set_header(
9572            "CamelHttpQuery",
9573            serde_json::Value::String("q=ab<cd".to_string()),
9574        );
9575
9576        let err = HttpProducer::resolve_url(&exchange, &config)
9577            .unwrap_err()
9578            .to_string();
9579
9580        // Fail loud naming the forbidden byte (`<` = 0x3C); a resolve
9581        // error means no URL is emitted, never a re-encoded one.
9582        assert!(err.contains("0x3C"), "error must name the byte: {err}");
9583    }
9584
9585    #[test]
9586    fn override_uri_with_query_plus_header_query() {
9587        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9588        let mut exchange = Exchange::new(Message::default());
9589        exchange.input.set_header(
9590            "CamelHttpUri",
9591            serde_json::Value::String("http://host/api?a=1".to_string()),
9592        );
9593        exchange.input.set_header(
9594            "CamelHttpQuery",
9595            serde_json::Value::String("a=2&b=3".to_string()),
9596        );
9597
9598        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9599
9600        // Pair-level merge with a single `?`: the override's `a=1` wins
9601        // the collision, the header appends `b=3` — no `?a=1?a=2` concat.
9602        assert_eq!(url, "http://host/api?a=1&b=3");
9603    }
9604
9605    #[test]
9606    fn path_applies_before_query_composition() {
9607        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9608        let mut exchange = Exchange::new(Message::default());
9609        exchange.input.set_header(
9610            "CamelHttpUri",
9611            serde_json::Value::String("http://host/api?a=1".to_string()),
9612        );
9613        exchange.input.set_header(
9614            "CamelHttpPath",
9615            serde_json::Value::String("/extra".to_string()),
9616        );
9617        exchange.input.set_header(
9618            "CamelHttpQuery",
9619            serde_json::Value::String("b=2".to_string()),
9620        );
9621
9622        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9623
9624        // CamelHttpPath applies to the override base without its query,
9625        // then the query composes.
9626        assert_eq!(url, "http://host/api/extra?a=1&b=2");
9627    }
9628
9629    #[test]
9630    fn plain_proxy_reflection_composes() {
9631        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9632        // Headers as the consumer installs them from the wire.
9633        let exchange = exchange_with_path_and_query("/in/extra", "page=2");
9634
9635        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9636
9637        // Reflection rides by default and composes: the operator pair is
9638        // not replaced (rc-k3pir parity).
9639        assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
9640    }
9641
9642    #[test]
9643    fn bridge_endpoint_ignores_url_headers() {
9644        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9645        let mut exchange = Exchange::new(Message::default());
9646        exchange.input.set_header(
9647            "CamelHttpUri",
9648            serde_json::Value::String("http://evil.test/x".to_string()),
9649        );
9650        exchange.input.set_header(
9651            "CamelHttpPath",
9652            serde_json::Value::String("/foo".to_string()),
9653        );
9654        exchange.input.set_header(
9655            "CamelHttpQuery",
9656            serde_json::Value::String("z=9".to_string()),
9657        );
9658
9659        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9660
9661        // All three URL headers ignored; the endpoint base plus its own
9662        // (consumed-option-filtered) query is sent, exactly as before.
9663        assert_eq!(url, "http://h/p?a=1");
9664        assert!(!url.contains("evil"), "override leaked: {url}");
9665        assert!(!url.contains("z=9"), "header query leaked: {url}");
9666        assert!(!url.contains("/foo"), "header path leaked: {url}");
9667    }
9668
9669    #[test]
9670    fn resolve_url_programmatic_params_use_percent20_deterministic() {
9671        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9672        config.query_params = vec![
9673            ("b".to_string(), "x y".to_string()),
9674            ("a".to_string(), "1".to_string()),
9675        ];
9676        let exchange = Exchange::new(Message::default());
9677
9678        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9679
9680        // Declaration order (not lexical), minimal RFC-3986 encoding,
9681        // `%20` — never `+` — for spaces.
9682        assert_eq!(url, "http://h/p?b=x%20y&a=1");
9683        assert!(!url.contains('+'));
9684    }
9685
9686    #[test]
9687    fn resolve_url_authored_and_programmatic_merge() {
9688        let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
9689        config.query_params = vec![
9690            ("b".to_string(), "2".to_string()),
9691            ("a".to_string(), "9".to_string()),
9692        ];
9693        let exchange = Exchange::new(Message::default());
9694
9695        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9696
9697        // Programmatic `b` appended (absent from raw); programmatic `a=9`
9698        // ignored (authored key wins); no duplication.
9699        assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
9700    }
9701
9702    #[test]
9703    fn from_uri_no_longer_fills_query_params_from_uri() {
9704        let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
9705
9706        // Authored pairs live in raw_query ONLY (provenance pin).
9707        assert!(
9708            config.query_params.is_empty(),
9709            "query_params is programmatic-only: {:?}",
9710            config.query_params
9711        );
9712        assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
9713    }
9714
9715    #[test]
9716    fn resolve_url_forbidden_raw_byte_errors() {
9717        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9718        config.raw_query = Some("a=x y".to_string());
9719        let exchange = Exchange::new(Message::default());
9720
9721        let err = HttpProducer::resolve_url(&exchange, &config)
9722            .expect_err("literal space in raw query must error");
9723
9724        // The error names the forbidden byte; no output string is produced.
9725        assert!(
9726            err.to_string().contains("0x20"),
9727            "error must name the forbidden byte: {err}"
9728        );
9729    }
9730
9731    /// rc-m4xk1: the override URI's own query is span-validated at resolve
9732    /// time — a forbidden byte in the override arm errors naming the byte,
9733    /// instead of riding verbatim to a reqwest send error.
9734    #[test]
9735    fn resolve_url_override_query_forbidden_byte_errors() {
9736        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9737        let mut exchange = Exchange::new(Message::default());
9738        exchange.input.set_header(
9739            "CamelHttpUri",
9740            serde_json::Value::String("http://h2/p?a=x y".to_string()),
9741        );
9742
9743        let err = HttpProducer::resolve_url(&exchange, &config)
9744            .expect_err("literal space in the override URI's query must error");
9745
9746        assert!(
9747            err.to_string().contains("0x20"),
9748            "error must name the forbidden byte from the override query: {err}"
9749        );
9750    }
9751
9752    /// rc-m4xk1 pin: decoded-key collision — a header pair whose key decodes
9753    /// to a key already present in the higher-precedence query (here
9754    /// `%61=2`, decoding to `a`) is dropped by the shared decoded-key
9755    /// matching; the higher-precedence authored span rides verbatim.
9756    #[test]
9757    fn merge_header_query_decoded_key_collision_drops_header_pair() {
9758        let merged = merge_header_query(Some("a=1"), "%61=2")
9759            .expect("decoded-key collision must not be a parse error");
9760        assert_eq!(
9761            merged.as_deref(),
9762            Some("a=1"),
9763            "the higher-precedence span wins and the colliding header pair is dropped"
9764        );
9765    }
9766
9767    /// rc-m4xk1 pin: duplicate keys within ONE header query are not
9768    /// deduplicated — both spans ride verbatim in authored order.
9769    #[test]
9770    fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
9771        let merged = merge_header_query(None, "k=1&k=2")
9772            .expect("duplicate header keys must not be a parse error");
9773        assert_eq!(
9774            merged.as_deref(),
9775            Some("k=1&k=2"),
9776            "intra-header duplicate keys ride verbatim"
9777        );
9778    }
9779
9780    /// rc-dhkeo: the Debug surface masks userinfo-style bytes in
9781    /// `base_url` and leaves a userinfo-free base untouched, byte-for-byte.
9782    /// rc-yvjp3 (ADR-0076 strictest-wins): `base_url` routes through the
9783    /// canonical `camel_api::redact::redact_url` — query and fragment bytes
9784    /// now drop behind their sentinels and later `//user:pass@` windows
9785    /// mask too, dimensions the former byte-preserving local variant kept.
9786    #[test]
9787    fn endpoint_config_debug_masks_base_url_userinfo() {
9788        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9789        config.base_url = "http://user:pass@h.example/p".to_string();
9790        let rendered = format!("{config:?}");
9791        assert!(
9792            rendered.contains("***@h.example"),
9793            "userinfo must render masked: {rendered}"
9794        );
9795        assert!(
9796            !rendered.contains("user:pass"),
9797            "no credentials in Debug output: {rendered}"
9798        );
9799
9800        let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9801        let rendered_plain = format!("{plain:?}");
9802        assert!(
9803            rendered_plain.contains("http://h.example/p"),
9804            "a base without userinfo renders unchanged: {rendered_plain}"
9805        );
9806    }
9807
9808    /// rc-yvjp3 convergence: an authored query and fragment on `base_url`
9809    /// render as sentinels, never as raw bytes (strictest-wins over the
9810    /// former byte-preserving variant), and the rendered value is
9811    /// byte-identical to the canonical helper.
9812    #[test]
9813    fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
9814        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9815
9816        config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
9817        let rendered = format!("{config:?}");
9818        assert!(
9819            rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
9820            "query and fragment must render as composed sentinels: {rendered}"
9821        );
9822        assert!(
9823            !rendered.contains("token=secret") && !rendered.contains("access_token"),
9824            "query/fragment credential bytes must not render: {rendered}"
9825        );
9826
9827        config.base_url = "http://h.example//u2:p2@evil/".to_string();
9828        let rendered = format!("{config:?}");
9829        assert!(
9830            rendered.contains("base_url: \"http://h.example//***@evil/\""),
9831            "later //window userinfo must mask (canonical window rule): {rendered}"
9832        );
9833        assert!(
9834            !rendered.contains("u2:p2"),
9835            "later-window credentials must not render: {rendered}"
9836        );
9837
9838        // Cross-surface identity: the Debug field is byte-identical to the
9839        // canonical helper output for the same input.
9840        config.base_url = "http://user:pass@h.example/p?token=x".to_string();
9841        let canonical = camel_api::redact::redact_url(&config.base_url);
9842        assert_eq!(canonical, "http://***@h.example/p?[redacted]");
9843        let rendered = format!("{config:?}");
9844        assert!(
9845            rendered.contains(&format!("base_url: \"{canonical}\"")),
9846            "Debug base_url must equal canonical redact_url output: {rendered}"
9847        );
9848    }
9849
9850    /// rc-nmupb: authored apostrophe (0x27) is RFC 3986 pchar-legal, but
9851    /// reqwest's WHATWG parser re-encodes it as `%27` in every http/https
9852    /// query — the raw byte can never ride the wire verbatim. Resolve
9853    /// rejects it naming the byte; the authored `%27` escape is the
9854    /// wire-faithful form and rides verbatim.
9855    #[test]
9856    fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
9857        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9858
9859        config.raw_query = Some("q=it's".to_string());
9860        let exchange = Exchange::new(Message::default());
9861        let err = HttpProducer::resolve_url(&exchange, &config)
9862            .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
9863        assert!(
9864            err.to_string().contains("0x27"),
9865            "error must name the apostrophe byte: {err}"
9866        );
9867
9868        config.raw_query = Some("q=it%27s".to_string());
9869        let url = HttpProducer::resolve_url(&exchange, &config)
9870            .expect("authored %27 escape is wire-legal");
9871        assert!(
9872            url.contains("q=it%27s"),
9873            "the authored escape must ride byte-for-byte: {url}"
9874        );
9875
9876        // The rest of reqwest's WHATWG special-query set shares the same
9877        // rationale and is rejected alongside (`"` and backtick are not
9878        // RFC 3986 query-legal bytes; `<`/`>` likewise).
9879        for &byte in b"\"`<>" {
9880            config.raw_query = Some(format!("k={}x", byte as char));
9881            let err = HttpProducer::resolve_url(&exchange, &config)
9882                .expect_err("WHATWG special-query byte must be rejected");
9883            assert!(
9884                err.to_string().contains(&format!("0x{byte:02X}")),
9885                "error must name byte 0x{byte:02X}: {err}"
9886            );
9887        }
9888    }
9889
9890    #[test]
9891    fn armed_fence_rejects_unknown_host_redacted() {
9892        let cfg = HttpEndpointConfig::from_uri(
9893            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9894        )
9895        .unwrap();
9896        let mut exchange = Exchange::new(Message::default());
9897        exchange.input.set_header(
9898            "CamelHttpUri",
9899            serde_json::Value::String(
9900                "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
9901            ),
9902        );
9903
9904        let err = HttpProducer::resolve_url(&exchange, &cfg)
9905            .expect_err("override host outside the fence must fail resolution");
9906
9907        let message = err.to_string();
9908        assert!(!message.contains("pass"), "userinfo leaked: {message}");
9909        assert!(!message.contains("s3cret"), "query leaked: {message}");
9910    }
9911
9912    #[test]
9913    fn armed_fence_rejects_unparseable_override_redacted() {
9914        let cfg = HttpEndpointConfig::from_uri(
9915            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9916        )
9917        .unwrap();
9918        let mut exchange = Exchange::new(Message::default());
9919        exchange.input.set_header(
9920            "CamelHttpUri",
9921            serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
9922        );
9923
9924        let err = HttpProducer::resolve_url(&exchange, &cfg)
9925            .expect_err("unparseable override outside the fence must fail resolution");
9926
9927        let message = err.to_string();
9928        assert!(
9929            message.contains("allowedUriHosts fence"),
9930            "fence must be named: {message}"
9931        );
9932        assert!(
9933            message.contains("[redacted]"),
9934            "suppression sentinel missing: {message}"
9935        );
9936        assert!(
9937            !message.contains("evil.example.com"),
9938            "host leaked: fail-closed arm must render only the sentinel: {message}"
9939        );
9940        assert!(
9941            !message.contains("fencesecret"),
9942            "password leaked: {message}"
9943        );
9944        assert!(!message.contains("u:"), "userinfo leaked: {message}");
9945    }
9946
9947    #[test]
9948    fn armed_fence_rejects_password_only_userinfo_redacted() {
9949        let cfg = HttpEndpointConfig::from_uri(
9950            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9951        )
9952        .unwrap();
9953        let mut exchange = Exchange::new(Message::default());
9954        exchange.input.set_header(
9955            "CamelHttpUri",
9956            serde_json::Value::String(
9957                "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
9958            ),
9959        );
9960
9961        let err = HttpProducer::resolve_url(&exchange, &cfg)
9962            .expect_err("password-only override outside the fence must fail resolution");
9963
9964        let message = err.to_string();
9965        assert!(
9966            !message.contains("passwordonly"),
9967            "password-only userinfo leaked: {message}"
9968        );
9969        assert!(!message.contains("querysecret"), "query leaked: {message}");
9970        assert!(
9971            message.contains("http://***@evil.example.com/x?[redacted]"),
9972            "masked shape missing: {message}"
9973        );
9974    }
9975
9976    #[test]
9977    fn armed_fence_allows_listed_host() {
9978        let cfg = HttpEndpointConfig::from_uri(
9979            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9980        )
9981        .unwrap();
9982        let mut exchange = Exchange::new(Message::default());
9983        exchange.input.set_header(
9984            "CamelHttpUri",
9985            serde_json::Value::String("http://cdn.example.com/x".to_string()),
9986        );
9987
9988        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9989        assert_eq!(url, "http://cdn.example.com/x");
9990    }
9991
9992    #[test]
9993    fn host_only_entry_permits_any_port() {
9994        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
9995        let mut exchange = Exchange::new(Message::default());
9996        exchange.input.set_header(
9997            "CamelHttpUri",
9998            serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
9999        );
10000
10001        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10002        assert_eq!(url, "http://cdn.example.com:9443/x");
10003    }
10004
10005    #[test]
10006    fn unarmed_endpoint_unchanged() {
10007        let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10008        let mut exchange = Exchange::new(Message::default());
10009        exchange.input.set_header(
10010            "CamelHttpUri",
10011            serde_json::Value::String("http://any.example.com/path".to_string()),
10012        );
10013
10014        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10015        assert_eq!(url, "http://any.example.com/path");
10016    }
10017
10018    #[test]
10019    fn empty_allowlist_fails_endpoint_creation() {
10020        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
10021    }
10022
10023    #[test]
10024    fn malformed_entry_fails_endpoint_creation() {
10025        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
10026    }
10027
10028    #[test]
10029    fn fence_entry_with_path_fails_creation() {
10030        // A trailing path is a typo'd entry: silently narrowing it to the
10031        // hostname would widen or skew the fence. Reject loudly.
10032        assert!(
10033            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
10034        );
10035    }
10036
10037    #[test]
10038    fn fence_entry_with_userinfo_fails_creation() {
10039        assert!(
10040            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
10041        );
10042    }
10043
10044    #[test]
10045    fn ipv6_fence_entry_allows_bracketed_host() {
10046        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
10047        // The textual host forms differ; both parse to the same bracketed
10048        // canonical host (`[::1]`) that the entry stores, so both ride.
10049        for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
10050            let mut exchange = Exchange::new(Message::default());
10051            exchange
10052                .input
10053                .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
10054            let url = HttpProducer::resolve_url(&exchange, &cfg)
10055                .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
10056            assert_eq!(url, uri, "bracketed IPv6 override not honored");
10057        }
10058    }
10059
10060    #[test]
10061    fn dns_case_insensitive_fence_match() {
10062        // The entry is stored ASCII-lowercased, so the mixed-case option
10063        // matches the lowercase override host.
10064        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
10065        let mut exchange = Exchange::new(Message::default());
10066        exchange.input.set_header(
10067            "CamelHttpUri",
10068            serde_json::Value::String("http://cdn.example.com/x".to_string()),
10069        );
10070        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10071        assert_eq!(url, "http://cdn.example.com/x");
10072    }
10073
10074    #[test]
10075    fn fence_allowed_override_query_merges_with_header() {
10076        // Fence pass plus full composition: the override URI query is the
10077        // higher-precedence source, the header pair appends.
10078        let cfg =
10079            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
10080        let mut exchange = Exchange::new(Message::default());
10081        exchange.input.set_header(
10082            "CamelHttpUri",
10083            serde_json::Value::String("http://host.example/api?a=1".to_string()),
10084        );
10085        exchange.input.set_header(
10086            "CamelHttpQuery",
10087            serde_json::Value::String("b=2".to_string()),
10088        );
10089
10090        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10091        assert_eq!(url, "http://host.example/api?a=1&b=2");
10092    }
10093
10094    #[test]
10095    fn empty_header_with_armed_fence_leaves_no_query() {
10096        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
10097        let mut exchange = Exchange::new(Message::default());
10098        exchange.input.set_header(
10099            "CamelHttpUri",
10100            serde_json::Value::String("http://host.example/api".to_string()),
10101        );
10102        exchange
10103            .input
10104            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10105
10106        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10107        assert_eq!(url, "http://host.example/api");
10108        assert!(!url.contains('?'), "query marker leaked: {url}");
10109    }
10110
10111    #[test]
10112    fn fence_option_is_consumed() {
10113        // A raw query on the base URI plus the fence option; no override
10114        // header. The option is consumed at parse time and must never
10115        // appear in the outbound query.
10116        let cfg =
10117            HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
10118        let exchange = Exchange::new(Message::default());
10119
10120        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10121        assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
10122        assert!(url.contains("x=1"), "authored query lost: {url}");
10123    }
10124
10125    #[tokio::test]
10126    async fn resolve_url_malformed_base_url_errors_no_panic() {
10127        use tower::ServiceExt;
10128
10129        let (url, _handle) = start_test_server().await;
10130        let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
10131        config.allow_internal = true; // test server binds 127.0.0.1
10132        let producer = HttpProducer {
10133            config: Arc::new(config),
10134            client: build_client(&HttpConfig::default(), None),
10135            pinned_cache: Arc::new(PinnedClientCache::new(
10136                PINNED_CLIENT_TTL,
10137                PINNED_CLIENT_MAX_ENTRIES,
10138            )),
10139            http_config: Arc::new(HttpConfig::default()),
10140            runtime: rt(),
10141        };
10142
10143        // First call: malformed base URL propagates as an error through the
10144        // real producer path — no panic, no poisoned state (rc-ph7z2).
10145        let first = producer
10146            .clone()
10147            .oneshot(Exchange::new(Message::default()))
10148            .await;
10149        let err = first.expect_err("malformed base URL must error, not panic");
10150        assert!(
10151            err.to_string().to_lowercase().contains("url"),
10152            "error must name the malformed URL: {err}"
10153        );
10154
10155        // Second call through the SAME producer succeeds — the failure
10156        // left no poisoned state.
10157        let mut exchange = Exchange::new(Message::default());
10158        exchange.input.set_header(
10159            "CamelHttpUri",
10160            serde_json::Value::String(format!("{url}/api")),
10161        );
10162        let response = producer
10163            .oneshot(exchange)
10164            .await
10165            .expect("valid request through same producer must succeed");
10166        let status = response
10167            .input
10168            .header("CamelHttpResponseCode")
10169            .and_then(|v| v.as_u64())
10170            .unwrap();
10171        assert_eq!(status, 200);
10172    }
10173
10174    #[test]
10175    fn resolve_url_bridge_malformed_base_errors_no_panic() {
10176        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10177        cfg.bridge_endpoint = true;
10178        cfg.query_params.push(("k".to_string(), "1".to_string()));
10179        // `from_uri` rejects the malformed authority, so the base is set on
10180        // the stored config directly (same build shape as the scheme-case
10181        // test). The bridge arm's validation-only parse (rc-ph7z2) must
10182        // surface it as an error — no panic.
10183        cfg.base_url = "http://[::1:bad".to_string();
10184        let exchange = Exchange::new(Message::default());
10185
10186        let err = HttpProducer::resolve_url(&exchange, &cfg)
10187            .expect_err("malformed bridge base URL must error");
10188        assert!(
10189            err.to_string().contains("invalid base URL"),
10190            "error must name the invalid base URL: {err}"
10191        );
10192    }
10193
10194    #[test]
10195    fn test_http_producer_helpers_status_and_size_boundaries() {
10196        assert!(HttpProducer::is_ok_status(200, (200, 299)));
10197        assert!(HttpProducer::is_ok_status(299, (200, 299)));
10198        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
10199        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
10200
10201        assert!(!exceeds_max_response_body(10, 10));
10202        assert!(exceeds_max_response_body(11, 10));
10203    }
10204
10205    // -----------------------------------------------------------------------
10206    // Content-Type inference tests
10207    // -----------------------------------------------------------------------
10208
10209    #[allow(clippy::await_holding_lock)]
10210    async fn setup_consumer_on_free_port(
10211        path: &str,
10212    ) -> (
10213        u16,
10214        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
10215        tokio_util::sync::CancellationToken,
10216    ) {
10217        use camel_component_api::ConsumerContext;
10218
10219        // ADR-0070 staged-listener law: bind, KEEP the socket, and stage it
10220        // in the ServerRegistry; the consumer's `get_or_spawn` consumes the
10221        // staged listener, so the port never returns to the ephemeral pool
10222        // between probe and serve (no bind-read-drop race).
10223        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10224        let port = listener.local_addr().unwrap().port();
10225
10226        // Hold the registry test mutex across the whole stage→spawn→ready
10227        // window so a concurrent `ServerRegistry::reset()` cannot evict the
10228        // staged listener between staging and readiness. The guard covers
10229        // stage_listener, the consumer spawn, the readiness poll and the
10230        // tail-yield loop; it releases when this helper returns.
10231        // Poison-recovering acquire: a failed sibling test must not
10232        // cascade — the mutex guards test serialization only, no
10233        // structural invariant, so recovery via into_inner is safe.
10234        let _registry_guard = lock_registry_test_mutex();
10235
10236        ServerRegistry::global()
10237            .stage_listener(listener)
10238            .await
10239            .expect("stage consumer test listener");
10240
10241        let consumer_cfg = HttpServerConfig {
10242            scheme: "http".to_string(),
10243            host: "127.0.0.1".to_string(),
10244            port,
10245            path: path.to_string(),
10246            max_request_body: 2 * 1024 * 1024,
10247            max_response_body: 10 * 1024 * 1024,
10248            max_inflight_requests: 1024,
10249            method: None,
10250            tls_config: None,
10251        };
10252        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
10253
10254        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10255        let token = tokio_util::sync::CancellationToken::new();
10256        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10257
10258        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10259
10260        // Readiness without a fixed wall-clock sleep: poll the registry
10261        // entry live (1ms doubling backoff, 10s deadline), then yield so
10262        // the spawned `start()` completes route registration (that tail
10263        // path has no pending timers — only the registry lock — so
10264        // scheduler yields order it deterministically behind this loop).
10265        wait_for_registry_ready("127.0.0.1", port).await;
10266        for _ in 0..8 {
10267            tokio::task::yield_now().await;
10268        }
10269
10270        (port, rx, token)
10271    }
10272
10273    /// Poll `ServerRegistry::bound_addr(host, port)` until the entry
10274    /// appears: 1ms backoff doubling per iteration, capped at 64ms, with
10275    /// a 10s deadline. Panics with a hint naming the likely causes when
10276    /// the deadline fires.
10277    async fn wait_for_registry_ready(host: &str, port: u16) {
10278        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
10279        let mut backoff = std::time::Duration::from_millis(1);
10280        while ServerRegistry::global().bound_addr(host, port).is_none() {
10281            assert!(
10282                tokio::time::Instant::now() < deadline,
10283                "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
10284            );
10285            tokio::time::sleep(backoff).await;
10286            backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
10287        }
10288    }
10289
10290    #[tokio::test]
10291    #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
10292    async fn readiness_deadline_fires_loud_with_hint() {
10293        // Poll a key no writer can produce. Registry keys come from
10294        // either the listener's resolved IP string (staged path) or the
10295        // caller-provided host verbatim (legacy get_or_spawn path), so a
10296        // synthetic host literal that no test passes is unreachable on
10297        // BOTH paths. Binding and HOLDING the listener (never dropped,
10298        // never staged) additionally keeps its port out of the ephemeral
10299        // pool, so no concurrent test can register that port either.
10300        // (Earlier drafts polled 127.0.0.2 — rejected: macOS exposes only
10301        // 127.0.0.1 and the bind fails there, rc-dwmd; and "localhost" —
10302        // rejected: the legacy host-verbatim path could produce it.)
10303        let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10304        let port = held.local_addr().unwrap().port();
10305        wait_for_registry_ready("httpflake-unreachable-host", port).await;
10306    }
10307
10308    // -----------------------------------------------------------------------
10309    // Readiness vs concurrent registry reset (httpflake, regression RED)
10310    // -----------------------------------------------------------------------
10311
10312    #[tokio::test]
10313    async fn readiness_survives_concurrent_registry_reset() {
10314        let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10315        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
10316
10317        // Hammer thread: loop legal resets, counting a contention whenever
10318        // its try-lock on the registry test mutex blocks (someone else held
10319        // it). The guard is dropped at each iteration end.
10320        let contended_hammer = std::sync::Arc::clone(&contended);
10321        let stop_hammer = std::sync::Arc::clone(&stop);
10322        let handle = std::thread::spawn(move || {
10323            while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
10324                let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
10325                    Err(_) => {
10326                        contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10327                        lock_registry_test_mutex()
10328                    }
10329                    Ok(guard) => guard,
10330                };
10331                ServerRegistry::reset();
10332            }
10333        });
10334
10335        // Drop guard: even if a setup panics, stop the hammer and join it so
10336        // the thread never outlives the test.
10337        struct StopHammerOnDrop {
10338            handle: Option<std::thread::JoinHandle<()>>,
10339            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
10340        }
10341        impl Drop for StopHammerOnDrop {
10342            fn drop(&mut self) {
10343                self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
10344                if let Some(handle) = self.handle.take() {
10345                    let _ = handle.join();
10346                }
10347            }
10348        }
10349        let _hammer_guard = StopHammerOnDrop {
10350            handle: Some(handle),
10351            stop,
10352        };
10353
10354        // Always at least 25 setups on fresh ephemeral ports; continue past
10355        // 25 only until one contended reset is observed; hard cap 50.
10356        let mut setups = 0;
10357        loop {
10358            setups += 1;
10359            let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
10360            drop(rx);
10361            token.cancel();
10362            if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
10363                || setups >= 50
10364            {
10365                break;
10366            }
10367        }
10368
10369        let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
10370        assert!(
10371            contended_hits >= 1,
10372            "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
10373        );
10374    }
10375
10376    #[tokio::test]
10377    async fn test_content_type_inferred_for_json_body() {
10378        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
10379
10380        let client = reqwest::Client::new();
10381        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
10382
10383        let (http_result, _) = tokio::join!(send_fut, async {
10384            if let Some(mut envelope) = rx.recv().await {
10385                envelope.exchange.input.body =
10386                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
10387                if let Some(reply_tx) = envelope.reply_tx {
10388                    let _ = reply_tx.send(Ok(envelope.exchange));
10389                }
10390            }
10391        });
10392
10393        let resp = http_result.unwrap();
10394        assert_eq!(resp.status().as_u16(), 200);
10395        let ct = resp
10396            .headers()
10397            .get("content-type")
10398            .expect("Content-Type header should be present");
10399        assert_eq!(ct, "application/json");
10400        let body = resp.text().await.unwrap();
10401        assert_eq!(body, r#"{"message":"hello"}"#);
10402
10403        token.cancel();
10404    }
10405
10406    #[tokio::test]
10407    async fn test_content_type_inferred_for_text_body() {
10408        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
10409
10410        let client = reqwest::Client::new();
10411        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
10412
10413        let (http_result, _) = tokio::join!(send_fut, async {
10414            if let Some(mut envelope) = rx.recv().await {
10415                envelope.exchange.input.body =
10416                    camel_component_api::Body::Text("plain text response".to_string());
10417                if let Some(reply_tx) = envelope.reply_tx {
10418                    let _ = reply_tx.send(Ok(envelope.exchange));
10419                }
10420            }
10421        });
10422
10423        let resp = http_result.unwrap();
10424        assert_eq!(resp.status().as_u16(), 200);
10425        let ct = resp
10426            .headers()
10427            .get("content-type")
10428            .expect("Content-Type header should be present");
10429        assert_eq!(ct, "text/plain; charset=utf-8");
10430        let body = resp.text().await.unwrap();
10431        assert_eq!(body, "plain text response");
10432
10433        token.cancel();
10434    }
10435
10436    #[tokio::test]
10437    async fn test_content_type_inferred_for_xml_body() {
10438        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
10439
10440        let client = reqwest::Client::new();
10441        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
10442
10443        let (http_result, _) = tokio::join!(send_fut, async {
10444            if let Some(mut envelope) = rx.recv().await {
10445                envelope.exchange.input.body =
10446                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
10447                if let Some(reply_tx) = envelope.reply_tx {
10448                    let _ = reply_tx.send(Ok(envelope.exchange));
10449                }
10450            }
10451        });
10452
10453        let resp = http_result.unwrap();
10454        assert_eq!(resp.status().as_u16(), 200);
10455        let ct = resp
10456            .headers()
10457            .get("content-type")
10458            .expect("Content-Type header should be present");
10459        assert_eq!(ct, "application/xml");
10460        let body = resp.text().await.unwrap();
10461        assert_eq!(body, "<root><item>value</item></root>");
10462
10463        token.cancel();
10464    }
10465
10466    #[tokio::test]
10467    async fn test_no_content_type_for_empty_body() {
10468        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
10469
10470        let client = reqwest::Client::new();
10471        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
10472
10473        let (http_result, _) = tokio::join!(send_fut, async {
10474            if let Some(mut envelope) = rx.recv().await {
10475                envelope.exchange.input.body = camel_component_api::Body::Empty;
10476                if let Some(reply_tx) = envelope.reply_tx {
10477                    let _ = reply_tx.send(Ok(envelope.exchange));
10478                }
10479            }
10480        });
10481
10482        let resp = http_result.unwrap();
10483        assert_eq!(resp.status().as_u16(), 200);
10484        assert!(
10485            resp.headers().get("content-type").is_none(),
10486            "Empty body should not set Content-Type"
10487        );
10488
10489        token.cancel();
10490    }
10491
10492    #[tokio::test]
10493    async fn test_no_content_type_for_raw_bytes_body() {
10494        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
10495
10496        let client = reqwest::Client::new();
10497        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
10498
10499        let (http_result, _) = tokio::join!(send_fut, async {
10500            if let Some(mut envelope) = rx.recv().await {
10501                envelope.exchange.input.body =
10502                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
10503                if let Some(reply_tx) = envelope.reply_tx {
10504                    let _ = reply_tx.send(Ok(envelope.exchange));
10505                }
10506            }
10507        });
10508
10509        let resp = http_result.unwrap();
10510        assert_eq!(resp.status().as_u16(), 200);
10511        assert!(
10512            resp.headers().get("content-type").is_none(),
10513            "Raw Bytes body should not set Content-Type"
10514        );
10515
10516        token.cancel();
10517    }
10518
10519    #[tokio::test]
10520    async fn test_content_type_from_stream_metadata() {
10521        use camel_component_api::{StreamBody, StreamMetadata};
10522        use futures::stream;
10523
10524        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
10525
10526        let client = reqwest::Client::new();
10527        let send_fut = client
10528            .get(format!("http://127.0.0.1:{port}/stream-ct"))
10529            .send();
10530
10531        let (http_result, _) = tokio::join!(send_fut, async {
10532            if let Some(mut envelope) = rx.recv().await {
10533                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
10534                    vec![Ok(bytes::Bytes::from("audio data"))];
10535                let stream = Box::pin(stream::iter(chunks));
10536                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
10537                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
10538                    metadata: StreamMetadata {
10539                        size_hint: None,
10540                        content_type: Some("audio/mpeg".to_string()),
10541                        origin: None,
10542                    },
10543                });
10544                if let Some(reply_tx) = envelope.reply_tx {
10545                    let _ = reply_tx.send(Ok(envelope.exchange));
10546                }
10547            }
10548        });
10549
10550        let resp = http_result.unwrap();
10551        assert_eq!(resp.status().as_u16(), 200);
10552        let ct = resp
10553            .headers()
10554            .get("content-type")
10555            .expect("Content-Type header should be present");
10556        assert_eq!(ct, "audio/mpeg");
10557        let body = resp.text().await.unwrap();
10558        assert_eq!(body, "audio data");
10559
10560        token.cancel();
10561    }
10562
10563    #[tokio::test]
10564    async fn test_user_content_type_overrides_inferred() {
10565        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
10566
10567        let client = reqwest::Client::new();
10568        let send_fut = client
10569            .get(format!("http://127.0.0.1:{port}/override-ct"))
10570            .send();
10571
10572        let (http_result, _) = tokio::join!(send_fut, async {
10573            if let Some(mut envelope) = rx.recv().await {
10574                envelope.exchange.input.body =
10575                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
10576                envelope.exchange.input.set_header(
10577                    "Content-Type",
10578                    serde_json::Value::String("text/html".to_string()),
10579                );
10580                if let Some(reply_tx) = envelope.reply_tx {
10581                    let _ = reply_tx.send(Ok(envelope.exchange));
10582                }
10583            }
10584        });
10585
10586        let resp = http_result.unwrap();
10587        assert_eq!(resp.status().as_u16(), 200);
10588        let ct = resp
10589            .headers()
10590            .get("content-type")
10591            .expect("Content-Type header should be present");
10592        assert_eq!(
10593            ct, "text/html",
10594            "User-set Content-Type should take precedence over inferred type"
10595        );
10596
10597        token.cancel();
10598    }
10599
10600    #[tokio::test]
10601    async fn test_user_content_type_with_bytes_body() {
10602        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
10603
10604        let client = reqwest::Client::new();
10605        let send_fut = client
10606            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
10607            .send();
10608
10609        let (http_result, _) = tokio::join!(send_fut, async {
10610            if let Some(mut envelope) = rx.recv().await {
10611                envelope.exchange.input.body =
10612                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
10613                envelope.exchange.input.set_header(
10614                    "Content-Type",
10615                    serde_json::Value::String("application/json".to_string()),
10616                );
10617                if let Some(reply_tx) = envelope.reply_tx {
10618                    let _ = reply_tx.send(Ok(envelope.exchange));
10619                }
10620            }
10621        });
10622
10623        let resp = http_result.unwrap();
10624        assert_eq!(resp.status().as_u16(), 200);
10625        let ct = resp
10626            .headers()
10627            .get("content-type")
10628            .expect("Content-Type header should be present for Bytes body with user header");
10629        assert_eq!(
10630            ct, "application/json",
10631            "User Content-Type should be sent for Bytes body"
10632        );
10633
10634        token.cancel();
10635    }
10636
10637    // -----------------------------------------------------------------------
10638    // Server monitor tests (GRL-005)
10639    // -----------------------------------------------------------------------
10640
10641    #[tokio::test]
10642    async fn monitor_task_silent_on_clean_exit() {
10643        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
10644        let server_exited = tokio_util::sync::CancellationToken::new();
10645        // Clean exit should complete without panicking or logging errors
10646        monitor_axum_task(
10647            handle,
10648            "127.0.0.1:0".to_string(),
10649            noop_rt(),
10650            "test-monitor".into(),
10651            server_exited.clone(),
10652        )
10653        .await;
10654        // rc-szmob: a clean exit must NOT fail hosted consumers — route
10655        // stops own their termination (no CrashNotification storm on
10656        // graceful process shutdown).
10657        assert!(
10658            !server_exited.is_cancelled(),
10659            "clean server exit must not cancel server_exited"
10660        );
10661    }
10662
10663    #[tokio::test]
10664    async fn monitor_task_handles_panicked_task() {
10665        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
10666            panic!("simulated server crash");
10667        });
10668        let server_exited = tokio_util::sync::CancellationToken::new();
10669        // Should complete without panicking even though the inner task panicked
10670        monitor_axum_task(
10671            handle,
10672            "127.0.0.1:9999".to_string(),
10673            noop_rt(),
10674            "test-monitor".into(),
10675            server_exited.clone(),
10676        )
10677        .await;
10678        // rc-szmob: unexpected exit must cancel the token so every hosted
10679        // consumer fails and supervision engages (ADR-0007).
10680        assert!(
10681            server_exited.is_cancelled(),
10682            "crashed server must cancel server_exited"
10683        );
10684    }
10685
10686    // -----------------------------------------------------------------------
10687    // Credential redaction tests
10688    // -----------------------------------------------------------------------
10689
10690    #[test]
10691    fn http_auth_basic_debug_redacts_password() {
10692        let auth = HttpAuth::Basic {
10693            username: "admin".to_string(),
10694            password: "hunter2".to_string(),
10695        };
10696        let debug = format!("{:?}", auth);
10697        assert!(
10698            !debug.contains("hunter2"),
10699            "password must be redacted: {debug}"
10700        );
10701        assert!(debug.contains("admin"), "username should appear: {debug}");
10702    }
10703
10704    #[test]
10705    fn http_auth_bearer_debug_redacts_token() {
10706        let auth = HttpAuth::Bearer {
10707            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
10708        };
10709        let debug = format!("{:?}", auth);
10710        assert!(
10711            !debug.contains("eyJhbGci"),
10712            "token must be redacted: {debug}"
10713        );
10714    }
10715
10716    #[test]
10717    fn http_auth_none_debug_shows_variant() {
10718        let debug = format!("{:?}", HttpAuth::None);
10719        assert!(
10720            debug.contains("None"),
10721            "None variant should appear: {debug}"
10722        );
10723    }
10724
10725    #[test]
10726    fn http_endpoint_config_debug_redacts_auth_credentials() {
10727        let config = HttpEndpointConfig::from_uri(
10728            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
10729        )
10730        .unwrap();
10731        let debug = format!("{:?}", config);
10732        assert!(
10733            !debug.contains("secret123"),
10734            "password must be redacted in HttpEndpointConfig debug: {debug}"
10735        );
10736    }
10737
10738    #[test]
10739    fn debug_lists_all_public_fields() {
10740        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10741        let debug = format!("{:?}", config);
10742        for field in [
10743            "base_url",
10744            "http_method",
10745            "throw_exception_on_failure",
10746            "ok_status_code_range",
10747            "response_timeout",
10748            "query_params",
10749            "raw_query",
10750            "allow_internal",
10751            "allow_cleartext",
10752            "blocked_hosts",
10753            "max_body_size",
10754            "read_timeout_ms",
10755            "max_response_bytes",
10756            "auth",
10757            "token_provider",
10758            "user_agent",
10759            "bridge_endpoint",
10760            "connection_close",
10761            "skip_request_headers",
10762            "skip_response_headers",
10763            "follow_redirects",
10764            "max_redirects",
10765        ] {
10766            assert!(
10767                debug.contains(field),
10768                "Debug output missing field '{field}': {debug}"
10769            );
10770        }
10771    }
10772
10773    // -----------------------------------------------------------------------
10774    // Static file serving tests (Task 5)
10775    // -----------------------------------------------------------------------
10776
10777    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
10778    use tower_http::services::ServeDir;
10779
10780    fn make_test_registry() -> HttpRouteRegistry {
10781        HttpRouteRegistry::new()
10782    }
10783
10784    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
10785        AppState {
10786            registry,
10787            max_request_body: 2 * 1024 * 1024,
10788            max_response_body: 10 * 1024 * 1024,
10789            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
10790        }
10791    }
10792
10793    #[allow(clippy::await_holding_lock)]
10794    #[tokio::test]
10795    async fn test_static_file_serving_serves_file_contents() {
10796        let _guard = lock_registry_test_mutex();
10797        ServerRegistry::reset();
10798
10799        // Create temp dir with test files
10800        let temp_dir =
10801            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
10802        std::fs::create_dir_all(&temp_dir).unwrap();
10803        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
10804        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
10805
10806        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10807
10808        let registry = make_test_registry();
10809        let serve_dir = ServeDir::new(&canonical_dir)
10810            .precompressed_gzip()
10811            .precompressed_br()
10812            .append_index_html_on_directories(true);
10813
10814        let mount = StaticMount {
10815            mount_path: "/".to_string(),
10816            mode: MountMode::Static,
10817            dir: canonical_dir.clone(),
10818            cache_control: "public, max-age=3600".to_string(),
10819            error_pages: std::collections::HashMap::new(),
10820            serve_dir,
10821        };
10822        registry.register_static_mount(mount).await.unwrap();
10823
10824        let state = make_test_state(registry);
10825
10826        // Test serving hello.txt
10827        let req = Request::builder()
10828            .uri("/hello.txt")
10829            .body(AxumBody::empty())
10830            .unwrap();
10831        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
10832        assert_eq!(resp.status(), StatusCode::OK);
10833        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10834            .await
10835            .unwrap();
10836        assert_eq!(&body[..], b"Hello, static world!");
10837
10838        // Test serving style.css
10839        let req = Request::builder()
10840            .uri("/style.css")
10841            .body(AxumBody::empty())
10842            .unwrap();
10843        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10844        assert_eq!(resp.status(), StatusCode::OK);
10845        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10846            .await
10847            .unwrap();
10848        assert_eq!(&body[..], b"body { color: red; }");
10849
10850        // Test 404 for non-existent file
10851        let req = Request::builder()
10852            .uri("/missing.txt")
10853            .body(AxumBody::empty())
10854            .unwrap();
10855        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
10856        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10857
10858        // Cleanup
10859        std::fs::remove_dir_all(&temp_dir).ok();
10860    }
10861
10862    #[allow(clippy::await_holding_lock)]
10863    #[tokio::test]
10864    async fn test_spa_fallback_serves_index_for_unknown_paths() {
10865        let _guard = lock_registry_test_mutex();
10866        ServerRegistry::reset();
10867
10868        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
10869        std::fs::create_dir_all(&temp_dir).unwrap();
10870        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
10871        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
10872
10873        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10874
10875        let registry = make_test_registry();
10876        let serve_dir = ServeDir::new(&canonical_dir)
10877            .precompressed_gzip()
10878            .precompressed_br()
10879            .append_index_html_on_directories(true);
10880
10881        let mount = StaticMount {
10882            mount_path: "/".to_string(),
10883            mode: MountMode::Spa,
10884            dir: canonical_dir.clone(),
10885            cache_control: "public, max-age=0".to_string(),
10886            error_pages: std::collections::HashMap::new(),
10887            serve_dir,
10888        };
10889        // Register as SPA mount
10890        registry.register_static_mount(mount).await.unwrap();
10891
10892        let state = make_test_state(registry);
10893
10894        // SPA fallback: GET /dashboard with Accept: text/html → index.html
10895        let req = Request::builder()
10896            .method("GET")
10897            .uri("/dashboard")
10898            .header("Accept", "text/html")
10899            .body(AxumBody::empty())
10900            .unwrap();
10901        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
10902        assert_eq!(resp.status(), StatusCode::OK);
10903        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10904            .await
10905            .unwrap();
10906        assert_eq!(&body[..], b"<h1>SPA App</h1>");
10907
10908        // Static file still works: GET /app.js
10909        let req = Request::builder()
10910            .method("GET")
10911            .uri("/app.js")
10912            .body(AxumBody::empty())
10913            .unwrap();
10914        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
10915        assert_eq!(resp.status(), StatusCode::OK);
10916        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10917            .await
10918            .unwrap();
10919        assert_eq!(&body[..], b"console.log('app')");
10920
10921        // No SPA fallback for JSON accept → 404
10922        let req = Request::builder()
10923            .method("GET")
10924            .uri("/api/data")
10925            .header("Accept", "application/json")
10926            .body(AxumBody::empty())
10927            .unwrap();
10928        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
10929        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10930
10931        // No SPA fallback for file extensions → 404
10932        let req = Request::builder()
10933            .method("GET")
10934            .uri("/style.css")
10935            .header("Accept", "text/html")
10936            .body(AxumBody::empty())
10937            .unwrap();
10938        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10939        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10940
10941        // Cleanup
10942        std::fs::remove_dir_all(&temp_dir).ok();
10943    }
10944
10945    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
10946    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
10947    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
10948    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
10949    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
10950    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
10951    #[allow(clippy::await_holding_lock)]
10952    async fn run_conditional_get_returns_304(mode: MountMode) {
10953        let _guard = lock_registry_test_mutex();
10954        ServerRegistry::reset();
10955
10956        let temp_dir = std::env::temp_dir().join(format!(
10957            "http_cond_get_{}_{}",
10958            if mode == MountMode::Spa {
10959                "spa"
10960            } else {
10961                "static"
10962            },
10963            std::process::id()
10964        ));
10965        std::fs::create_dir_all(&temp_dir).unwrap();
10966        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
10967
10968        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10969
10970        let registry = make_test_registry();
10971        let serve_dir = ServeDir::new(&canonical_dir)
10972            .precompressed_gzip()
10973            .precompressed_br()
10974            .append_index_html_on_directories(true);
10975
10976        let mount = StaticMount {
10977            mount_path: "/".to_string(),
10978            mode,
10979            dir: canonical_dir.clone(),
10980            cache_control: "public, max-age=3600".to_string(),
10981            error_pages: std::collections::HashMap::new(),
10982            serve_dir,
10983        };
10984        registry.register_static_mount(mount).await.unwrap();
10985
10986        let state = make_test_state(registry);
10987
10988        // 1st request: normal GET → 200, capture validators.
10989        let req = Request::builder()
10990            .method("GET")
10991            .uri("/index.html")
10992            .body(AxumBody::empty())
10993            .unwrap();
10994        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10995        assert_eq!(
10996            resp.status(),
10997            StatusCode::OK,
10998            "first GET should return 200, got {}",
10999            resp.status()
11000        );
11001        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
11002        assert!(
11003            resp.headers().contains_key(http::header::CACHE_CONTROL),
11004            "200 response missing Cache-Control"
11005        );
11006        let etag = resp
11007            .headers()
11008            .get(http::header::ETAG)
11009            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
11010            .clone();
11011        let last_modified = resp
11012            .headers()
11013            .get(http::header::LAST_MODIFIED)
11014            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
11015            .clone();
11016        // Consume the body so the response is fully drained.
11017        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
11018            .await
11019            .unwrap();
11020
11021        // 2nd request: If-None-Match with the captured ETag → 304.
11022        // Unconditional: ETag presence is required (asserted above) so this
11023        // sub-test cannot silently skip on a ServeDir etag_method change.
11024        let req = Request::builder()
11025            .method("GET")
11026            .uri("/index.html")
11027            .header(http::header::IF_NONE_MATCH, etag.clone())
11028            .body(AxumBody::empty())
11029            .unwrap();
11030        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11031        assert_eq!(
11032            resp.status(),
11033            StatusCode::NOT_MODIFIED,
11034            "If-None-Match with matching ETag should return 304, got {}",
11035            resp.status()
11036        );
11037        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
11038        assert!(
11039            resp.headers().contains_key(http::header::CACHE_CONTROL),
11040            "304 (If-None-Match) missing Cache-Control"
11041        );
11042        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
11043        // response parts rebuild in serve_via_serve_dir preserves them.
11044        assert_eq!(
11045            resp.headers().get(http::header::ETAG),
11046            Some(&etag),
11047            "304 (If-None-Match) must echo the ETag validator"
11048        );
11049        assert_eq!(
11050            resp.headers().get(http::header::LAST_MODIFIED),
11051            Some(&last_modified),
11052            "304 (If-None-Match) must carry Last-Modified"
11053        );
11054
11055        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
11056        let req = Request::builder()
11057            .method("GET")
11058            .uri("/index.html")
11059            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
11060            .body(AxumBody::empty())
11061            .unwrap();
11062        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11063        assert_eq!(
11064            resp.status(),
11065            StatusCode::NOT_MODIFIED,
11066            "If-Modified-Since with matching timestamp should return 304, got {}",
11067            resp.status()
11068        );
11069        assert!(
11070            resp.headers().contains_key(http::header::CACHE_CONTROL),
11071            "304 (If-Modified-Since) missing Cache-Control"
11072        );
11073        assert_eq!(
11074            resp.headers().get(http::header::ETAG),
11075            Some(&etag),
11076            "304 (If-Modified-Since) must carry the ETag validator"
11077        );
11078        assert_eq!(
11079            resp.headers().get(http::header::LAST_MODIFIED),
11080            Some(&last_modified),
11081            "304 (If-Modified-Since) must echo Last-Modified"
11082        );
11083
11084        // Negative control: a PAST If-Modified-Since (before the file's mtime)
11085        // MUST return 200 — proving the 304 path is validator-aware, not a
11086        // blanket "always 304" regression. A future date would correctly yield
11087        // 304 since the file's mtime precedes it; that is RFC-correct 304
11088        // behaviour, not a negative control.
11089        let req = Request::builder()
11090            .method("GET")
11091            .uri("/index.html")
11092            .header(
11093                http::header::IF_MODIFIED_SINCE,
11094                "Wed, 21 Oct 2000 07:28:00 GMT",
11095            )
11096            .body(AxumBody::empty())
11097            .unwrap();
11098        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11099        assert_eq!(
11100            resp.status(),
11101            StatusCode::OK,
11102            "past If-Modified-Since should return 200 (file modified after it), got {}",
11103            resp.status()
11104        );
11105
11106        // Cleanup
11107        std::fs::remove_dir_all(&temp_dir).ok();
11108    }
11109
11110    #[tokio::test]
11111    async fn test_conditional_get_returns_304_static_mode() {
11112        run_conditional_get_returns_304(MountMode::Static).await;
11113    }
11114
11115    #[tokio::test]
11116    async fn test_conditional_get_returns_304_spa_mode() {
11117        run_conditional_get_returns_304(MountMode::Spa).await;
11118    }
11119
11120    #[allow(clippy::await_holding_lock)]
11121    #[tokio::test]
11122    async fn test_error_page_mapping_serves_custom_404() {
11123        let _guard = lock_registry_test_mutex();
11124        ServerRegistry::reset();
11125
11126        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
11127        let errors_dir = temp_dir.join("errors");
11128        std::fs::create_dir_all(&errors_dir).unwrap();
11129        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11130        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
11131
11132        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11133        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
11134
11135        let registry = make_test_registry();
11136        let serve_dir = ServeDir::new(&canonical_dir)
11137            .precompressed_gzip()
11138            .precompressed_br()
11139            .append_index_html_on_directories(true);
11140
11141        let mut error_pages = std::collections::HashMap::new();
11142        error_pages.insert(404, canonical_404);
11143
11144        let mount = StaticMount {
11145            mount_path: "/".to_string(),
11146            mode: MountMode::Static,
11147            dir: canonical_dir.clone(),
11148            cache_control: "public, max-age=0".to_string(),
11149            error_pages,
11150            serve_dir,
11151        };
11152        registry.register_static_mount(mount).await.unwrap();
11153
11154        let state = make_test_state(registry);
11155
11156        // Request non-existent file → custom 404 page
11157        let req = Request::builder()
11158            .method("GET")
11159            .uri("/missing.html")
11160            .body(AxumBody::empty())
11161            .unwrap();
11162        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
11163        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11164        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11165            .await
11166            .unwrap();
11167        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
11168
11169        // Existing file still works
11170        let req = Request::builder()
11171            .method("GET")
11172            .uri("/index.html")
11173            .body(AxumBody::empty())
11174            .unwrap();
11175        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11176        assert_eq!(resp.status(), StatusCode::OK);
11177        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11178            .await
11179            .unwrap();
11180        assert_eq!(&body[..], b"<h1>Home</h1>");
11181
11182        // Cleanup
11183        std::fs::remove_dir_all(&temp_dir).ok();
11184    }
11185
11186    #[tokio::test]
11187    async fn http_consumer_returns_body_and_code_on_stop() {
11188        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
11189        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11190        use tower::ServiceExt;
11191
11192        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
11193        let set_body_step = CompiledStep::Process {
11194            kind_hint: camel_api::SpanKindHint::Internal,
11195            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11196                ex.input.body = Body::Text("nope".into());
11197                Box::pin(async move { Ok(ex) })
11198            }),
11199            body_contract: None,
11200            lifecycle: None,
11201            label: None,
11202            to_uri: None,
11203        };
11204        let set_status_step = CompiledStep::Process {
11205            kind_hint: camel_api::SpanKindHint::Internal,
11206            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11207                ex.input.set_header(
11208                    "CamelHttpResponseCode",
11209                    serde_json::Value::Number(409.into()),
11210                );
11211                Box::pin(async move { Ok(ex) })
11212            }),
11213            body_contract: None,
11214            lifecycle: None,
11215            label: None,
11216            to_uri: None,
11217        };
11218        let pipeline = compose_pipeline_with_handler(
11219            vec![set_body_step, set_status_step, CompiledStep::Stop],
11220            None,
11221            PipelineRuntimeCtx::compile_time(),
11222        );
11223
11224        let ex = Exchange::new(Message::default());
11225        let result = pipeline.oneshot(ex).await;
11226        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
11227        let returned = result.unwrap();
11228        assert_eq!(returned.input.body.as_text(), Some("nope"));
11229        assert_eq!(
11230            returned
11231                .input
11232                .header("CamelHttpResponseCode")
11233                .and_then(|v| v.as_u64()),
11234            Some(409)
11235        );
11236    }
11237
11238    #[tokio::test]
11239    async fn http_consumer_returns_200_when_body_empty_on_stop() {
11240        // After ADR-0024: Stop with no body + no status header produces 200 (same as
11241        // a normal completion with no body). The 204 default is gone — users who
11242        // want 204 set CamelHttpResponseCode=204 explicitly.
11243        //
11244        // This test stays at the pipeline level (consistent with the test above).
11245        // E2E coverage of the full HTTP dispatch path is in
11246        // crates/camel-test/tests/integration_test.rs.
11247        use camel_api::{Exchange, Message};
11248        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11249        use tower::ServiceExt;
11250
11251        let pipeline = compose_pipeline_with_handler(
11252            vec![CompiledStep::Stop],
11253            None,
11254            PipelineRuntimeCtx::compile_time(),
11255        );
11256        let ex = Exchange::new(Message::default());
11257        let result = pipeline.oneshot(ex).await;
11258        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
11259        // Body is default (empty); no CamelHttpResponseCode header was set.
11260        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
11261    }
11262
11263    // -----------------------------------------------------------------------
11264    // Task 5: Method-aware REST dispatch tests
11265    // -----------------------------------------------------------------------
11266
11267    /// Spins up an axum server on a free port with a fresh registry.
11268    /// Returns the port plus the registry so the caller can register
11269    /// REST endpoints directly.
11270    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
11271        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11272        let port = listener.local_addr().unwrap().port();
11273        let registry = HttpRouteRegistry::new();
11274        tokio::spawn(run_axum_server(
11275            listener,
11276            registry.clone(),
11277            2 * 1024 * 1024,
11278            10 * 1024 * 1024,
11279            Arc::new(tokio::sync::Semaphore::new(1024)),
11280            test_rt(),
11281            "test-route".into(),
11282        ));
11283        // Give the server a moment to start accepting.
11284        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
11285        (port, registry)
11286    }
11287
11288    /// Helper for REST integration tests: spawns a responder task that
11289    /// reads from `rx`, writes a fixed `(status, body)` back via the
11290    /// envelope's reply channel, and returns once the test request is
11291    /// satisfied.
11292    fn spawn_responder(
11293        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
11294        status: u16,
11295        body: String,
11296    ) -> tokio::task::JoinHandle<()> {
11297        tokio::spawn(async move {
11298            if let Some(envelope) = rx.recv().await {
11299                let _ = envelope.reply_tx.send(HttpReply {
11300                    status,
11301                    headers: vec![],
11302                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
11303                });
11304            }
11305        })
11306    }
11307
11308    #[tokio::test]
11309    async fn method_aware_dispatch_same_path_different_verbs() {
11310        let (port, registry) = spawn_test_server().await;
11311
11312        // Register two REST endpoints on the same path with different
11313        // methods. This is the core scenario REST DSL needs to support:
11314        // GET /users (list) and POST /users (create) must not overwrite
11315        // each other.
11316        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11317        registry
11318            .register_rest_endpoint(
11319                "GET".into(),
11320                vec![PathSegment::Literal("users".into())],
11321                get_tx,
11322            )
11323            .await;
11324
11325        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11326        registry
11327            .register_rest_endpoint(
11328                "POST".into(),
11329                vec![PathSegment::Literal("users".into())],
11330                post_tx,
11331            )
11332            .await;
11333
11334        let get_handle = spawn_responder(get_rx, 200, "list".into());
11335        let post_handle = spawn_responder(post_rx, 201, "create".into());
11336
11337        let client = reqwest::Client::new();
11338
11339        // GET /users → list route
11340        let resp = client
11341            .get(format!("http://127.0.0.1:{port}/users"))
11342            .send()
11343            .await
11344            .unwrap();
11345        assert_eq!(resp.status().as_u16(), 200);
11346        let body = resp.text().await.unwrap();
11347        assert_eq!(body, "list");
11348
11349        // POST /users → create route
11350        let resp = client
11351            .post(format!("http://127.0.0.1:{port}/users"))
11352            .send()
11353            .await
11354            .unwrap();
11355        assert_eq!(resp.status().as_u16(), 201);
11356        let body = resp.text().await.unwrap();
11357        assert_eq!(body, "create");
11358
11359        let _ = tokio::join!(get_handle, post_handle);
11360    }
11361
11362    #[tokio::test]
11363    async fn method_aware_dispatch_templated_path_extracts_params() {
11364        let (port, registry) = spawn_test_server().await;
11365
11366        // Register GET /users/{id} as a templated endpoint. The
11367        // dispatcher should match `/users/42` against the template and
11368        // attach `id=42` to the envelope's path_params.
11369        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11370        registry
11371            .register_rest_endpoint(
11372                "GET".into(),
11373                vec![
11374                    PathSegment::Literal("users".into()),
11375                    PathSegment::Param("id".into()),
11376                ],
11377                tx,
11378            )
11379            .await;
11380
11381        // Spawn a responder that echoes the captured id back in the body
11382        // so the test can verify the param was set.
11383        let handle = tokio::spawn(async move {
11384            if let Some(envelope) = rx.recv().await {
11385                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
11386                let _ = envelope.reply_tx.send(HttpReply {
11387                    status: 200,
11388                    headers: vec![],
11389                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
11390                });
11391            }
11392        });
11393
11394        let client = reqwest::Client::new();
11395        let resp = client
11396            .get(format!("http://127.0.0.1:{port}/users/42"))
11397            .send()
11398            .await
11399            .unwrap();
11400        assert_eq!(resp.status().as_u16(), 200);
11401        let body = resp.text().await.unwrap();
11402        assert_eq!(body, "id=42");
11403
11404        let _ = handle.await;
11405    }
11406
11407    #[tokio::test]
11408    async fn method_aware_dispatch_unmatched_method_falls_through() {
11409        // If no REST endpoint matches the method, dispatch must fall
11410        // through to the legacy api_routes lookup or static mounts. With
11411        // nothing else registered, the request gets 404 from static
11412        // dispatch.
11413        let (port, _registry) = spawn_test_server().await;
11414
11415        // Register only GET /users; a DELETE /users request has no match.
11416        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11417        _registry
11418            .register_rest_endpoint(
11419                "GET".into(),
11420                vec![PathSegment::Literal("users".into())],
11421                get_tx,
11422            )
11423            .await;
11424
11425        // Drain the GET channel in the background so the consumer side
11426        // doesn't block (we don't expect any envelopes here).
11427        let drain = tokio::spawn(async move {
11428            let mut get_rx = get_rx;
11429            while get_rx.recv().await.is_some() {}
11430        });
11431
11432        let client = reqwest::Client::new();
11433        let resp = client
11434            .delete(format!("http://127.0.0.1:{port}/users"))
11435            .send()
11436            .await
11437            .unwrap();
11438        assert_eq!(resp.status().as_u16(), 404);
11439
11440        drop(drain);
11441    }
11442
11443    #[tokio::test]
11444    async fn regression_legacy_exact_api_route_still_works() {
11445        // A `http:` route registered without an `httpMethod=` URI param
11446        // lands in the legacy api_routes registry. The dispatcher must
11447        // still find it via exact path lookup. This guards against
11448        // regressions introduced by the new REST-aware dispatch.
11449        let (port, registry) = spawn_test_server().await;
11450
11451        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11452        registry.register_api_route("/legacy/path".into(), tx).await;
11453
11454        let handle = tokio::spawn(async move {
11455            if let Some(envelope) = rx.recv().await {
11456                let _ = envelope.reply_tx.send(HttpReply {
11457                    status: 200,
11458                    headers: vec![],
11459                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
11460                });
11461            }
11462        });
11463
11464        let client = reqwest::Client::new();
11465        let resp = client
11466            .get(format!("http://127.0.0.1:{port}/legacy/path"))
11467            .send()
11468            .await
11469            .unwrap();
11470        assert_eq!(resp.status().as_u16(), 200);
11471        let body = resp.text().await.unwrap();
11472        assert_eq!(body, "legacy ok");
11473
11474        let _ = handle.await;
11475    }
11476
11477    #[allow(clippy::await_holding_lock)]
11478    #[tokio::test]
11479    async fn regression_static_mount_still_works() {
11480        // Verify that static file serving still works after the
11481        // dispatch refactor. We register a temp-dir mount and request
11482        // a file from it; the static dispatcher should serve it.
11483        let _guard = lock_registry_test_mutex();
11484        ServerRegistry::reset();
11485
11486        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
11487        std::fs::create_dir_all(&temp_dir).unwrap();
11488        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
11489        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11490
11491        let registry = make_test_registry();
11492        let serve_dir = ServeDir::new(&canonical_dir)
11493            .precompressed_gzip()
11494            .precompressed_br()
11495            .append_index_html_on_directories(true);
11496        let mount = StaticMount {
11497            mount_path: "/".to_string(),
11498            mode: MountMode::Static,
11499            dir: canonical_dir.clone(),
11500            cache_control: "public, max-age=3600".to_string(),
11501            error_pages: std::collections::HashMap::new(),
11502            serve_dir,
11503        };
11504        registry.register_static_mount(mount).await.unwrap();
11505
11506        let state = make_test_state(registry);
11507        let req = Request::builder()
11508            .uri("/regress.txt")
11509            .body(AxumBody::empty())
11510            .unwrap();
11511        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
11512        assert_eq!(resp.status(), StatusCode::OK);
11513        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11514            .await
11515            .unwrap();
11516        assert_eq!(&body[..], b"static works");
11517
11518        std::fs::remove_dir_all(&temp_dir).ok();
11519    }
11520
11521    // -----------------------------------------------------------------------
11522    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
11523    // templated from-URI round-trip. These exercise the real axum dispatch
11524    // path (register → HTTP request → reply) so a regression in any of the
11525    // three critical fixes surfaces as a test failure rather than a silent
11526    // production 404/500.
11527    // -----------------------------------------------------------------------
11528
11529    #[tokio::test]
11530    async fn deregister_one_method_keeps_sibling_verbs() {
11531        // Review C1: stopping the GET /users consumer must NOT tear down the
11532        // live POST /users endpoint. Register both, deregister GET only,
11533        // then verify POST still dispatches.
11534        let (port, registry) = spawn_test_server().await;
11535
11536        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11537        registry
11538            .register_rest_endpoint(
11539                "GET".into(),
11540                vec![PathSegment::Literal("users".into())],
11541                get_tx,
11542            )
11543            .await;
11544
11545        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11546        registry
11547            .register_rest_endpoint(
11548                "POST".into(),
11549                vec![PathSegment::Literal("users".into())],
11550                post_tx,
11551            )
11552            .await;
11553
11554        // Drain GET in the background (no requests expected after deregister).
11555        let drain = tokio::spawn(async move {
11556            let mut get_rx = get_rx;
11557            while get_rx.recv().await.is_some() {}
11558        });
11559
11560        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
11561        registry.unregister_rest_endpoint("GET", "/users").await;
11562        drop(drain);
11563
11564        let post_handle = spawn_responder(post_rx, 201, "create".into());
11565
11566        let client = reqwest::Client::new();
11567        // POST /users must still reach its consumer after GET was removed.
11568        let resp = client
11569            .post(format!("http://127.0.0.1:{port}/users"))
11570            .send()
11571            .await
11572            .unwrap();
11573        assert_eq!(resp.status().as_u16(), 201);
11574        assert_eq!(resp.text().await.unwrap(), "create");
11575
11576        let _ = post_handle.await;
11577    }
11578
11579    #[tokio::test]
11580    async fn dispatch_exact_legacy_beats_rest_template() {
11581        // Review C2: an exact legacy API route (`GET /api/users`, no
11582        // httpMethod) must win over a templated REST route
11583        // (`GET /api/{resource}`) for the request `/api/users`, per spec
11584        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
11585        let (port, registry) = spawn_test_server().await;
11586
11587        // Exact legacy route.
11588        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11589        registry
11590            .register_api_route("/api/users".into(), exact_tx)
11591            .await;
11592        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
11593
11594        // Templated REST route that would ALSO match /api/users.
11595        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11596        registry
11597            .register_rest_endpoint(
11598                "GET".into(),
11599                vec![
11600                    PathSegment::Literal("api".into()),
11601                    PathSegment::Param("resource".into()),
11602                ],
11603                tpl_tx,
11604            )
11605            .await;
11606        // The templated handler must NOT receive the /api/users request. If
11607        // it does, it replies "template-leak" so a future assertion could
11608        // catch it. We do NOT await this task: the exact-match branch wins
11609        // and the templated channel never receives, so awaiting would block
11610        // until the test runtime tears down.
11611        let _tpl_drain = tokio::spawn(async move {
11612            let mut tpl_rx = tpl_rx;
11613            if let Some(env) = tpl_rx.recv().await {
11614                let _ = env.reply_tx.send(HttpReply {
11615                    status: 200,
11616                    headers: vec![],
11617                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
11618                });
11619            }
11620        });
11621
11622        let client = reqwest::Client::new();
11623        let resp = client
11624            .get(format!("http://127.0.0.1:{port}/api/users"))
11625            .send()
11626            .await
11627            .unwrap();
11628        assert_eq!(resp.status().as_u16(), 200);
11629        // Exact-match handler answered — not the templated one.
11630        assert_eq!(resp.text().await.unwrap(), "exact");
11631
11632        let _ = exact_handle.await;
11633    }
11634
11635    #[tokio::test]
11636    async fn ambiguous_rest_templates_return_500_not_silent_404() {
11637        // Review C3: two equal-specificity templates that both match one
11638        // request are an ambiguous registration. At runtime this must
11639        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
11640        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
11641        let (port, registry) = spawn_test_server().await;
11642
11643        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11644        registry
11645            .register_rest_endpoint(
11646                "GET".into(),
11647                vec![
11648                    PathSegment::Literal("users".into()),
11649                    PathSegment::Param("id".into()),
11650                ],
11651                a_tx,
11652            )
11653            .await;
11654
11655        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11656        registry
11657            .register_rest_endpoint(
11658                "GET".into(),
11659                vec![
11660                    PathSegment::Literal("users".into()),
11661                    PathSegment::Param("name".into()),
11662                ],
11663                b_tx,
11664            )
11665            .await;
11666
11667        let client = reqwest::Client::new();
11668        let resp = client
11669            .get(format!("http://127.0.0.1:{port}/users/42"))
11670            .send()
11671            .await
11672            .unwrap();
11673        // Ambiguous → 500 (previously a silent 404).
11674        assert_eq!(resp.status().as_u16(), 500);
11675    }
11676
11677    #[test]
11678    fn from_uri_round_trips_templated_path_with_http_method() {
11679        // Review I4: a REST-lowered from-URI like
11680        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
11681        // through HttpServerConfig::from_uri, preserving the templated path
11682        // and the (uppercased) method. This is the binding the DSL lowering
11683        // emits and the consumer reads; it was previously unasserted.
11684        use crate::UriConfig;
11685        let cfg =
11686            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
11687        assert_eq!(cfg.host, "0.0.0.0");
11688        assert_eq!(cfg.port, 8080);
11689        assert_eq!(cfg.path, "/users/{id}");
11690        assert_eq!(cfg.method.as_deref(), Some("GET"));
11691
11692        // Lower-case httpMethod is uppercased (review I5).
11693        let cfg_lc =
11694            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
11695        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
11696        assert_eq!(cfg_lc.path, "/orders");
11697    }
11698
11699    // -----------------------------------------------------------------------
11700    // rc-1dk4: TypeConversionFailed → 400 Bad Request
11701    // -----------------------------------------------------------------------
11702
11703    #[test]
11704    fn type_conversion_failed_maps_to_400() {
11705        let reply = pipeline_error_to_reply(
11706            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
11707            "/api/users",
11708        );
11709        assert_eq!(reply.status, 400);
11710        // Exactly one Content-Type header, application/json
11711        let json_ct = reply
11712            .headers
11713            .iter()
11714            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11715            .count();
11716        assert_eq!(json_ct, 1);
11717        // Body must be structured error JSON with the expected fields
11718        let body = match &reply.body {
11719            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11720            _ => panic!("expected bytes body"),
11721        };
11722        let parsed: serde_json::Value =
11723            serde_json::from_str(&body).expect("body must be valid JSON");
11724        assert_eq!(parsed["error"], "bad_request");
11725        assert_eq!(parsed["message"], "invalid JSON at line 1");
11726    }
11727
11728    #[test]
11729    fn other_error_still_maps_to_500() {
11730        let reply =
11731            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
11732        assert_eq!(reply.status, 500);
11733    }
11734
11735    #[test]
11736    fn unauthenticated_maps_to_401() {
11737        let reply = pipeline_error_to_reply(
11738            CamelError::Unauthenticated("no token".to_string()),
11739            "/api/users",
11740        );
11741        assert_eq!(reply.status, 401);
11742    }
11743
11744    #[test]
11745    fn unauthorized_maps_to_403() {
11746        let reply = pipeline_error_to_reply(
11747            CamelError::Unauthorized("forbidden".to_string()),
11748            "/api/users",
11749        );
11750        assert_eq!(reply.status, 403);
11751    }
11752
11753    #[test]
11754    fn validation_error_maps_to_400() {
11755        let reply = pipeline_error_to_reply(
11756            CamelError::ValidationError("body does not match schema".to_string()),
11757            "/api/users",
11758        );
11759        assert_eq!(reply.status, 400);
11760        let json_ct = reply
11761            .headers
11762            .iter()
11763            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11764            .count();
11765        assert_eq!(json_ct, 1);
11766        let body = match &reply.body {
11767            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11768            _ => panic!("expected bytes body"),
11769        };
11770        let parsed: serde_json::Value =
11771            serde_json::from_str(&body).expect("body must be valid JSON");
11772        assert_eq!(parsed["error"], "validation_error");
11773        assert_eq!(parsed["message"], "body does not match schema");
11774    }
11775
11776    // -----------------------------------------------------------------------
11777    // rc-hlb1q: media negotiation errors → 415 / 406
11778    // -----------------------------------------------------------------------
11779
11780    #[test]
11781    fn finalizer_maps_unsupported_media_type() {
11782        let reply = pipeline_error_to_reply(
11783            CamelError::UnsupportedMediaType {
11784                consumed: "text/plain".to_string(),
11785                declared: "application/json".to_string(),
11786            },
11787            "/x",
11788        );
11789        assert_eq!(reply.status, 415);
11790        let json_ct = reply
11791            .headers
11792            .iter()
11793            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11794            .count();
11795        assert_eq!(json_ct, 1);
11796        let body = match &reply.body {
11797            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11798            _ => panic!("expected bytes body"),
11799        };
11800        let parsed: serde_json::Value =
11801            serde_json::from_str(&body).expect("body must be valid JSON");
11802        assert_eq!(parsed["error"], "unsupported_media_type");
11803        assert_eq!(
11804            parsed["message"],
11805            "consumed text/plain, declared application/json"
11806        );
11807    }
11808
11809    #[test]
11810    fn finalizer_maps_not_acceptable() {
11811        let reply = pipeline_error_to_reply(
11812            CamelError::NotAcceptable {
11813                accept: "application/xml".to_string(),
11814                produced: "application/json".to_string(),
11815            },
11816            "/x",
11817        );
11818        assert_eq!(reply.status, 406);
11819        let json_ct = reply
11820            .headers
11821            .iter()
11822            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11823            .count();
11824        assert_eq!(json_ct, 1);
11825        let body = match &reply.body {
11826            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11827            _ => panic!("expected bytes body"),
11828        };
11829        let parsed: serde_json::Value =
11830            serde_json::from_str(&body).expect("body must be valid JSON");
11831        assert_eq!(parsed["error"], "not_acceptable");
11832        assert_eq!(
11833            parsed["message"],
11834            "accept application/xml, produced application/json"
11835        );
11836    }
11837
11838    #[test]
11839    fn json_error_reply_preserves_empty_message() {
11840        let reply = json_error_reply(400, "bad_request", "".to_string());
11841        assert_eq!(reply.status, 400);
11842        let json_ct = reply
11843            .headers
11844            .iter()
11845            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11846            .count();
11847        assert_eq!(json_ct, 1);
11848        let body = match &reply.body {
11849            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11850            _ => panic!("expected bytes body"),
11851        };
11852        let parsed: serde_json::Value =
11853            serde_json::from_str(&body).expect("body must be valid JSON");
11854        assert_eq!(parsed["error"], "bad_request");
11855        assert_eq!(parsed["message"], "");
11856    }
11857
11858    #[test]
11859    fn https_consumer_without_tls_cert_errors() {
11860        let endpoint = HttpEndpoint {
11861            uri: "https://0.0.0.0:8443/api".to_string(),
11862            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11863            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11864            client: reqwest::Client::new(),
11865            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11866                PINNED_CLIENT_TTL,
11867                PINNED_CLIENT_MAX_ENTRIES,
11868            )),
11869            http_config: HttpConfig::default(),
11870        };
11871        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11872        let result = endpoint.create_consumer(rt);
11873        assert!(result.is_err(), "expected error for https without tls cert");
11874        if let Err(e) = result {
11875            let msg = e.to_string();
11876            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
11877        }
11878    }
11879
11880    #[test]
11881    fn http_consumer_with_tls_config_errors() {
11882        let endpoint = HttpEndpoint {
11883            uri: "http://0.0.0.0:8080/api".to_string(),
11884            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
11885            server_config: HttpServerConfig::from_uri(
11886                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
11887            )
11888            .unwrap(),
11889            client: reqwest::Client::new(),
11890            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11891                PINNED_CLIENT_TTL,
11892                PINNED_CLIENT_MAX_ENTRIES,
11893            )),
11894            http_config: HttpConfig::default(),
11895        };
11896        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11897        let result = endpoint.create_consumer(rt);
11898        assert!(result.is_err(), "expected error for http with tls config");
11899        if let Err(e) = result {
11900            let msg = e.to_string();
11901            assert!(msg.contains("https"), "error must mention https: {msg}");
11902        }
11903    }
11904
11905    #[test]
11906    fn https_consumer_with_partial_tls_cert_only_errors() {
11907        // tlsCert without tlsKey → tls_config is None at parse time
11908        // → create_consumer sees https:// + no TLS → must error
11909        let server_config =
11910            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
11911        assert!(
11912            server_config.tls_config.is_none(),
11913            "partial tlsCert must not create ServerTlsConfig"
11914        );
11915        let endpoint = HttpEndpoint {
11916            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
11917            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
11918                .unwrap(),
11919            server_config,
11920            client: reqwest::Client::new(),
11921            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11922                PINNED_CLIENT_TTL,
11923                PINNED_CLIENT_MAX_ENTRIES,
11924            )),
11925            http_config: HttpConfig::default(),
11926        };
11927        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11928        let result = endpoint.create_consumer(rt);
11929        assert!(
11930            result.is_err(),
11931            "must error: https:// requires both tlsCert and tlsKey"
11932        );
11933    }
11934
11935    #[test]
11936    fn load_tls_config_parses_valid_pem() {
11937        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
11938        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11939        use camel_component_api::test_support::tls;
11940        let (_, cert_pem, key_pem) = tls::gen_server_cert();
11941        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
11942        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
11943
11944        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
11945        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
11946    }
11947
11948    #[tokio::test(flavor = "multi_thread")]
11949    #[allow(clippy::await_holding_lock)]
11950    async fn consumer_tls_handshake_roundtrip() {
11951        use camel_component_api::test_support::tls;
11952        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11953
11954        // Install rustls crypto provider (aws-lc-rs)
11955        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11956
11957        // Serialize against global ServerRegistry singleton
11958        let _guard = lock_registry_test_mutex();
11959
11960        // Generate CA + server cert
11961        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
11962        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
11963        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
11964        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
11965
11966        // Get ephemeral port
11967        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11968        let port = probe.local_addr().unwrap().port();
11969        drop(probe);
11970
11971        ServerRegistry::reset();
11972
11973        // Create real HttpComponent + endpoint with TLS URI
11974        let component = HttpComponent::new();
11975        let endpoint_ctx = NoOpComponentContext;
11976        let uri = format!(
11977            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
11978            cert_path.to_string_lossy(),
11979            key_path.to_string_lossy(),
11980        );
11981        let endpoint = component
11982            .create_endpoint(&uri, &endpoint_ctx)
11983            .expect("create TLS endpoint");
11984        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
11985
11986        // Start consumer — this calls get_or_spawn with tls_config
11987        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11988        let token = tokio_util::sync::CancellationToken::new();
11989        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
11990        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11991
11992        // Give server time to start
11993        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
11994
11995        // Client with CA cert — REAL verification (no danger_accept_invalid)
11996        let ca_bytes = std::fs::read(&ca_path).unwrap();
11997        let client = reqwest::Client::builder()
11998            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
11999            .build()
12000            .unwrap();
12001
12002        let send_fut = client
12003            .post(format!("https://localhost:{port}/test"))
12004            .body("ping")
12005            .send();
12006
12007        // Handler: receive envelope, reply 200 with "pong" body
12008        let (http_result, _) = tokio::join!(send_fut, async {
12009            if let Some(mut envelope) = rx.recv().await {
12010                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
12011                if let Some(reply_tx) = envelope.reply_tx {
12012                    let _ = reply_tx.send(Ok(envelope.exchange));
12013                }
12014            }
12015        });
12016
12017        let resp = http_result.expect("TLS handshake + request must succeed");
12018
12019        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
12020        let body = resp.text().await.unwrap();
12021        assert_eq!(body, "pong");
12022
12023        token.cancel();
12024    }
12025
12026    #[tokio::test(flavor = "multi_thread")]
12027    #[allow(clippy::await_holding_lock)]
12028    async fn consumer_tls_rejects_client_without_ca() {
12029        use camel_component_api::test_support::tls;
12030        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12031
12032        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12033
12034        // Serialize against global ServerRegistry singleton
12035        let _guard = lock_registry_test_mutex();
12036
12037        let (_, cert_pem, key_pem) = tls::gen_server_cert();
12038        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
12039        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
12040
12041        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12042        let port = probe.local_addr().unwrap().port();
12043        drop(probe);
12044
12045        ServerRegistry::reset();
12046
12047        // Spawn TLS server via real HttpComponent path
12048        let component = HttpComponent::new();
12049        let endpoint_ctx = NoOpComponentContext;
12050        let uri = format!(
12051            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12052            cert_path.to_string_lossy(),
12053            key_path.to_string_lossy(),
12054        );
12055        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
12056        let mut consumer = endpoint.create_consumer(rt()).unwrap();
12057        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12058        let token = tokio_util::sync::CancellationToken::new();
12059        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
12060        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12061
12062        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
12063
12064        // Client WITHOUT CA cert — must fail TLS verification
12065        let client = reqwest::Client::builder().build().unwrap();
12066
12067        let result = client
12068            .get(format!("https://localhost:{port}/test"))
12069            .send()
12070            .await;
12071
12072        assert!(
12073            result.is_err(),
12074            "must reject without CA — proves real verification"
12075        );
12076
12077        token.cancel();
12078    }
12079
12080    #[test]
12081    fn server_config_partial_tls_cert_without_key() {
12082        // Parse URI with only tlsCert (no tlsKey)
12083        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12084        // Partial params → tls_config must be None
12085        assert!(cfg.tls_config.is_none());
12086    }
12087
12088    #[test]
12089    fn endpoint_uri_options_count_parity() {
12090        // Mirror struct must stay in sync with bespoke from_components parser.
12091        assert_eq!(
12092            HttpEndpointConfig::uri_options().len(),
12093            23,
12094            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
12095        );
12096    }
12097
12098    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
12099        pairs
12100            .iter()
12101            .map(|(k, v)| {
12102                (
12103                    (*k).to_string(),
12104                    serde_json::Value::String((*v).to_string()),
12105                )
12106            })
12107            .collect()
12108    }
12109
12110    #[test]
12111    fn response_emits_cache_control_via_pragma_warning() {
12112        let headers = make_headers(&[
12113            ("Cache-Control", "public, max-age=3600"),
12114            ("Via", "1.1 myproxy"),
12115            ("Pragma", "no-cache"),
12116            ("Warning", "199 misc"),
12117        ]);
12118        let selected = select_response_headers(&headers, None, None);
12119        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12120        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
12121            assert!(
12122                names.contains(&expected),
12123                "{expected} should pass through to the response"
12124            );
12125        }
12126    }
12127
12128    #[test]
12129    fn response_excludes_request_only_and_server_owned() {
12130        let headers = make_headers(&[
12131            ("User-Agent", "x"),
12132            ("Accept", "*/*"),
12133            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
12134        ]);
12135        let selected = select_response_headers(&headers, None, None);
12136        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12137        for excluded in ["User-Agent", "Accept", "Date"] {
12138            assert!(
12139                !names.contains(&excluded),
12140                "{excluded} should NOT appear in the response"
12141            );
12142        }
12143    }
12144
12145    #[test]
12146    fn response_re_derives_content_type() {
12147        let headers = make_headers(&[("Content-Type", "text/plain")]);
12148        let selected = select_response_headers(&headers, Some("application/json".into()), None);
12149        let ct_entries: Vec<&str> = selected
12150            .iter()
12151            .filter(|(k, _)| k == "Content-Type")
12152            .map(|(_, v)| v.as_str())
12153            .collect();
12154        assert_eq!(
12155            ct_entries,
12156            ["application/json"],
12157            "exactly one Content-Type entry, re-derived from user_content_type"
12158        );
12159    }
12160
12161    #[test]
12162    fn response_excludes_camel_headers() {
12163        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
12164        let selected = select_response_headers(&headers, None, None);
12165        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12166        assert!(
12167            !names.contains(&"CamelHttpPath"),
12168            "Camel-namespace headers must be excluded"
12169        );
12170        assert!(
12171            names.contains(&"Cache-Control"),
12172            "Cache-Control must pass through"
12173        );
12174    }
12175
12176    #[test]
12177    fn response_stringifies_scalar_header_values() {
12178        let mut headers = make_headers(&[("X-Label", "keep")]);
12179        headers.insert("X-Retries".to_string(), serde_json::json!(3));
12180        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12181        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12182        let selected = select_response_headers(&headers, None, None);
12183        let get = |name: &str| -> Option<&str> {
12184            selected
12185                .iter()
12186                .find(|(k, _)| k == name)
12187                .map(|(_, v)| v.as_str())
12188        };
12189        assert_eq!(
12190            get("X-Retries"),
12191            Some("3"),
12192            "integer header must be stringified"
12193        );
12194        assert_eq!(
12195            get("X-Ratio"),
12196            Some("3.5"),
12197            "float header must be stringified"
12198        );
12199        assert_eq!(
12200            get("X-Enabled"),
12201            Some("true"),
12202            "bool header must be stringified"
12203        );
12204        assert_eq!(
12205            get("X-Label"),
12206            Some("keep"),
12207            "string header must pass through"
12208        );
12209    }
12210
12211    #[test]
12212    fn response_drops_null_and_structured_header_values() {
12213        let mut headers = make_headers(&[("X-Keep", "yes")]);
12214        headers.insert("X-Null".to_string(), serde_json::Value::Null);
12215        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12216        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12217        let selected = select_response_headers(&headers, None, None);
12218        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12219        for dropped in ["X-Null", "X-Obj", "X-Arr"] {
12220            assert!(
12221                !names.contains(&dropped),
12222                "{dropped} must not be emitted: no single-value form"
12223            );
12224        }
12225        assert!(names.contains(&"X-Keep"), "scalar headers must survive");
12226    }
12227
12228    #[test]
12229    fn response_stringifies_scalars_despite_excluded_names() {
12230        // Excluded names stay excluded regardless of value type: the policy
12231        // filter runs before stringification, so numeric values cannot smuggle
12232        // content-length or server-owned headers into the reply.
12233        let mut headers = HashMap::new();
12234        headers.insert("Content-Length".to_string(), serde_json::json!(999));
12235        headers.insert("Date".to_string(), serde_json::json!(12345));
12236        let selected = select_response_headers(&headers, None, None);
12237        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12238        assert!(
12239            !names.contains(&"Content-Length"),
12240            "content-length is re-derived by the server"
12241        );
12242        assert!(!names.contains(&"Date"), "date is server-owned");
12243    }
12244
12245    #[test]
12246    fn outbound_stringifies_scalar_header_values() {
12247        let mut headers = make_headers(&[("X-Label", "keep")]);
12248        headers.insert("X-Retries".to_string(), serde_json::json!(3));
12249        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12250        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12251        let outbound = select_outbound_headers(&headers, &[], &[]);
12252        // HeaderName construction lowercases; lookups compare case-blind.
12253        let get = |name: &str| -> Option<String> {
12254            outbound
12255                .accepted
12256                .iter()
12257                .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12258                .map(|(_, v)| v.to_str().unwrap().to_string())
12259        };
12260        assert_eq!(
12261            get("X-Retries").as_deref(),
12262            Some("3"),
12263            "integer header must be stringified"
12264        );
12265        assert_eq!(
12266            get("X-Ratio").as_deref(),
12267            Some("3.5"),
12268            "float header must be stringified"
12269        );
12270        assert_eq!(
12271            get("X-Enabled").as_deref(),
12272            Some("true"),
12273            "bool header must be stringified"
12274        );
12275        assert_eq!(
12276            get("X-Label").as_deref(),
12277            Some("keep"),
12278            "string header must pass through"
12279        );
12280        assert!(outbound.drops.is_empty(), "scalar headers must not drop");
12281    }
12282
12283    #[test]
12284    fn outbound_drops_null_and_structured_header_values() {
12285        let mut headers = make_headers(&[("X-Keep", "yes")]);
12286        headers.insert("X-Null".to_string(), serde_json::Value::Null);
12287        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12288        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12289        let outbound = select_outbound_headers(&headers, &[], &[]);
12290        let has = |name: &str| {
12291            outbound
12292                .accepted
12293                .iter()
12294                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12295        };
12296        assert!(has("X-Keep"), "scalar headers must survive");
12297        for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
12298            let dropped = outbound
12299                .drops
12300                .iter()
12301                .find(|d| d.name == name)
12302                .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
12303            assert_eq!(
12304                dropped.reason, "no scalar string form",
12305                "{name} drop reason must name the value kind absence"
12306            );
12307            assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
12308        }
12309    }
12310
12311    #[test]
12312    fn outbound_stringifies_scalars_despite_excluded_names() {
12313        // Excluded names stay excluded regardless of value type: the policy
12314        // filter runs before stringification, so numeric values cannot smuggle
12315        // hop-by-hop or client-derived headers onto the wire.
12316        let mut headers = HashMap::new();
12317        headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
12318        headers.insert("Host".to_string(), serde_json::json!(12345));
12319        headers.insert("X-Ok".to_string(), serde_json::json!(7));
12320        let outbound = select_outbound_headers(&headers, &[], &[]);
12321        let has = |name: &str| {
12322            outbound
12323                .accepted
12324                .iter()
12325                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12326        };
12327        assert!(
12328            !has("Transfer-Encoding"),
12329            "hop-by-hop header must stay excluded"
12330        );
12331        assert!(!has("Host"), "host is destination-derived");
12332        assert!(has("X-Ok"), "non-excluded scalar must be stringified");
12333        assert!(
12334            outbound
12335                .drops
12336                .iter()
12337                .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
12338            "policy drop must be recorded before coercion"
12339        );
12340    }
12341
12342    #[test]
12343    fn outbound_drops_invalid_names_values_and_skip_config() {
12344        let mut headers = make_headers(&[("X-Good", "fine")]);
12345        headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
12346        headers.insert(
12347            "X-Control-Value".to_string(),
12348            serde_json::json!("line1\nline2"),
12349        );
12350        headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
12351        headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
12352        let skip = vec!["x-secret".to_string()];
12353        let outbound = select_outbound_headers(&headers, &skip, &[]);
12354        let has = |name: &str| {
12355            outbound
12356                .accepted
12357                .iter()
12358                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12359        };
12360        assert!(has("X-Good"), "valid header must survive");
12361        assert!(!has("X Bad Name"), "invalid header name must drop");
12362        assert!(!has("X-Control-Value"), "control-char value must drop");
12363        assert!(!has("X-Secret"), "skipped header must drop");
12364        assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
12365        let reason = |n: &str| {
12366            outbound
12367                .drops
12368                .iter()
12369                .find(|d| d.name == n)
12370                .map(|d| d.reason)
12371        };
12372        assert_eq!(reason("X Bad Name"), Some("invalid header name"));
12373        assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
12374        assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
12375        assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
12376    }
12377
12378    #[test]
12379    fn constructed_header_invalid_value_returns_drop_record() {
12380        let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
12381        let Err(record) = result else {
12382            panic!("invalid value must produce a drop record");
12383        };
12384        assert_eq!(record.reason, "invalid header value");
12385        assert_eq!(record.name, "user-agent");
12386        assert!(record.value_kind.is_none());
12387        let debug = format!("{record:?}");
12388        assert!(
12389            !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
12390            "drop record debug must not leak the value"
12391        );
12392    }
12393
12394    #[test]
12395    fn constructed_header_invalid_name_returns_drop_record() {
12396        let result = constructed_header("bad name", "ok");
12397        let Err(record) = result else {
12398            panic!("invalid name must produce a drop record");
12399        };
12400        assert_eq!(record.reason, "invalid header name");
12401        assert_eq!(record.name, "bad name");
12402        let debug = format!("{record:?}");
12403        assert!(
12404            !debug.contains("ok"),
12405            "drop record debug must not leak the value"
12406        );
12407    }
12408
12409    #[test]
12410    fn constructed_header_valid_pair_roundtrip() {
12411        let result = constructed_header("authorization", "Bearer abc123");
12412        let Ok((name, val)) = result else {
12413            panic!("valid pair must construct");
12414        };
12415        assert_eq!(name.as_str(), "authorization");
12416        let Ok(roundtrip) = val.to_str() else {
12417            panic!("valid value must roundtrip to str");
12418        };
12419        assert_eq!(roundtrip, "Bearer abc123");
12420    }
12421
12422    // -----------------------------------------------------------------------
12423    // Bridge proxy end-to-end integration tests (Task 4.1)
12424    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
12425    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
12426    // -----------------------------------------------------------------------
12427
12428    /// Destination server that captures the outbound request line and the
12429    /// `Host:` header the producer actually sent on the wire. Returns
12430    /// `(host_value, request_line)` so a bridge-proxy test can assert that
12431    /// the producer derived `Host` from the destination (not the exchange)
12432    /// and honoured bridging semantics for the path.
12433    async fn start_host_capturing_destination() -> (
12434        String,
12435        Arc<std::sync::Mutex<Option<(String, String)>>>,
12436        tokio::task::JoinHandle<()>,
12437    ) {
12438        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12439        let port = listener.local_addr().unwrap().port();
12440        let url = format!("http://127.0.0.1:{port}");
12441        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
12442            Arc::new(std::sync::Mutex::new(None));
12443        let captured_clone = Arc::clone(&captured);
12444        let handle = tokio::spawn(async move {
12445            use tokio::io::{AsyncReadExt, AsyncWriteExt};
12446            if let Ok((mut stream, _)) = listener.accept().await {
12447                let mut buf = vec![0u8; 16384];
12448                let n = stream.read(&mut buf).await.unwrap_or(0);
12449                let request = String::from_utf8_lossy(&buf[..n]).to_string();
12450                if request.contains("\r\n\r\n") {
12451                    let request_line = request.lines().next().unwrap_or("").to_string();
12452                    let host_value = request
12453                        .lines()
12454                        .find(|l| l.to_lowercase().starts_with("host:"))
12455                        .and_then(|l| l.split_once(':'))
12456                        .map(|(_, v)| v.trim().to_string())
12457                        .unwrap_or_default();
12458                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
12459                }
12460                let body = r#"{"echo":"ok"}"#;
12461                let resp = format!(
12462                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
12463                    body.len(),
12464                    body
12465                );
12466                let _ = stream.write_all(resp.as_bytes()).await;
12467            }
12468        });
12469        (url, captured, handle)
12470    }
12471
12472    /// A bridging producer must derive `Host` from the destination URL and
12473    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
12474    /// semantics. The wire-level proof is the raw `Host:` header and request
12475    /// line captured at the destination TCP socket.
12476    #[tokio::test]
12477    async fn bridge_proxy_outbound_host_matches_destination() {
12478        use tower::ServiceExt;
12479
12480        let (url, captured, _handle) = start_host_capturing_destination().await;
12481        // The Host header reqwest derives for http://127.0.0.1:{port} is the
12482        // authority, scheme-stripped: "127.0.0.1:{port}".
12483        let expected_host = url.strip_prefix("http://").unwrap();
12484
12485        let ctx = test_producer_ctx();
12486        let component = HttpComponent::new();
12487        let endpoint_ctx = NoOpComponentContext;
12488        let endpoint = component
12489            .create_endpoint(
12490                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
12491                &endpoint_ctx,
12492            )
12493            .unwrap();
12494        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
12495
12496        // Exchange carries a stale Host and a CamelHttpPath that bridging
12497        // must drop.
12498        let mut exchange = Exchange::new(Message::default());
12499        exchange.input.set_header("Host", "localhost");
12500        exchange.input.set_header("CamelHttpPath", "/foo");
12501
12502        let result = producer.oneshot(exchange).await;
12503        assert!(result.is_ok(), "producer call failed: {:?}", result);
12504
12505        tokio::time::sleep(Duration::from_millis(100)).await;
12506        let (host_value, request_line) = captured
12507            .lock()
12508            .unwrap()
12509            .take()
12510            .expect("destination capture mutex empty — producer did not reach the destination");
12511
12512        assert_ne!(
12513            host_value, "localhost",
12514            "bridge producer must not forward the exchange Host: localhost"
12515        );
12516        assert_eq!(
12517            host_value, expected_host,
12518            "Host must be derived from the destination authority (no scheme)"
12519        );
12520        assert!(
12521            !request_line.contains("/foo"),
12522            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
12523        );
12524    }
12525
12526    /// A response header set by the route (`Cache-Control`) must survive to
12527    /// the wire. The assertion is on the reqwest HTTP response — not an
12528    /// in-process HttpReply struct — so it proves the consumer's reply
12529    /// finaliser emitted the header over the socket.
12530    #[tokio::test]
12531    async fn bridge_proxy_route_set_response_header_survives() {
12532        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12533
12534        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12535        let port = listener.local_addr().unwrap().port();
12536        drop(listener);
12537
12538        let component = HttpComponent::new();
12539        let endpoint_ctx = NoOpComponentContext;
12540        let endpoint = component
12541            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
12542            .unwrap();
12543        let mut consumer = endpoint.create_consumer(rt()).unwrap();
12544
12545        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12546        let token = tokio_util::sync::CancellationToken::new();
12547        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
12548
12549        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12550        tokio::time::sleep(Duration::from_millis(50)).await;
12551
12552        let client = reqwest::Client::new();
12553        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
12554
12555        // Route sets Cache-Control on the outbound reply (exchange.input is
12556        // the message the reply finaliser reads — see select_response_headers
12557        // at the dispatch site).
12558        let (http_result, _) = tokio::join!(send_fut, async {
12559            if let Some(mut envelope) = rx.recv().await {
12560                envelope
12561                    .exchange
12562                    .input
12563                    .set_header("Cache-Control", "public, max-age=3600");
12564                if let Some(reply_tx) = envelope.reply_tx {
12565                    let _ = reply_tx.send(Ok(envelope.exchange));
12566                }
12567            }
12568        });
12569
12570        let resp = http_result.unwrap();
12571        assert_eq!(resp.status().as_u16(), 200);
12572
12573        let cache_control = resp.headers().get("cache-control");
12574        assert!(
12575            cache_control.is_some(),
12576            "Cache-Control header must survive to the wire response"
12577        );
12578        assert_eq!(
12579            cache_control.unwrap().to_str().unwrap(),
12580            "public, max-age=3600"
12581        );
12582
12583        token.cancel();
12584    }
12585
12586    // -----------------------------------------------------------------------
12587    // credential-sources task 2.3: credential values stay out of diagnostics
12588    // -----------------------------------------------------------------------
12589    //
12590    // camel-http has no request access log (design.md "Redaction sinks",
12591    // ADR-0051). The only diagnostic sink on the failed-auth path is
12592    // `pipeline_error_to_reply`, which renders the (generic) error message and
12593    // the *configured* route path — never the request URI, query string, or
12594    // extracted credential. These tests pin that redact-by-construction
12595    // contract: a sentinel credential presented in a declared source must not
12596    // appear in the reply body nor in any tracing record emitted while the
12597    // request is handled.
12598    //
12599    // Capture scope: `#[traced_test]` installs a per-crate env filter
12600    // (`camel_component_http=trace`), so records from OTHER targets
12601    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
12602    // redaction contract for those crates is guarded by their own tests.
12603    // Revisit this capture scope if camel-auth ever logs on the auth path.
12604    use camel_api::security_policy::CredentialSource;
12605    use camel_auth::credential_source::extract_token_from_exchange;
12606    use camel_auth::native_auth::NativeCredentialStore;
12607    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
12608
12609    // Sentinel credential values — test fixtures only, not real secrets.
12610    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
12611    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
12612    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
12613
12614    /// Build the exchange the consumer would build for a request envelope:
12615    /// standard Camel HTTP headers plus title-cased forwarded request headers.
12616    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
12617        let mut msg = Message::default();
12618        msg.set_header(
12619            "CamelHttpMethod",
12620            serde_json::Value::String(envelope.method.clone()),
12621        );
12622        msg.set_header(
12623            "CamelHttpPath",
12624            serde_json::Value::String(envelope.path.clone()),
12625        );
12626        msg.set_header(
12627            "CamelHttpQuery",
12628            serde_json::Value::String(envelope.query.clone()),
12629        );
12630        for (k, v) in &envelope.headers {
12631            if let Ok(val_str) = v.to_str() {
12632                msg.set_header(
12633                    title_case_header(k.as_str()),
12634                    serde_json::Value::String(val_str.to_string()),
12635                );
12636            }
12637        }
12638        Exchange::new(msg)
12639    }
12640
12641    /// Register a route whose responder authenticates each request against an
12642    /// empty native store, so every presented credential fails lookup with
12643    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
12644    /// authentication step (extract per `sources` → authenticate → deny) so the
12645    /// credential-extraction redaction contract is exercised on a real
12646    /// authentication failure.
12647    async fn spawn_failing_auth_route(
12648        registry: &HttpRouteRegistry,
12649        path: &str,
12650        sources: Vec<CredentialSource>,
12651    ) {
12652        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
12653            NativeCredentialStore::try_new(vec![]).unwrap(),
12654        ));
12655        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12656        registry.register_api_route(path.to_string(), tx).await;
12657        let path_owned = path.to_string();
12658        tokio::spawn(async move {
12659            while let Some(envelope) = rx.recv().await {
12660                let exchange = envelope_to_exchange(&envelope);
12661                let reply_tx = envelope.reply_tx;
12662                let result: Result<(), CamelError> = async {
12663                    let token = extract_token_from_exchange(&exchange, &sources)
12664                        .map(|extracted| extracted.token)
12665                        .ok_or_else(|| {
12666                            CamelError::Unauthenticated("no credential in any source".into())
12667                        })?;
12668                    authenticator.authenticate_bearer(&token).await?;
12669                    Ok(())
12670                }
12671                .await;
12672                let reply = match result {
12673                    Ok(()) => HttpReply {
12674                        status: 200,
12675                        headers: vec![],
12676                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
12677                    },
12678                    Err(e) => pipeline_error_to_reply(e, &path_owned),
12679                };
12680                let _ = reply_tx.send(reply);
12681            }
12682        });
12683    }
12684
12685    /// Whether any tracing record captured so far (process-wide) contains
12686    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
12687    /// shared buffer, so logs from spawned request-handling tasks are included.
12688    fn captured_logs_contain(needle: &str) -> bool {
12689        let buf = tracing_test::internal::global_buf().lock().unwrap();
12690        String::from_utf8_lossy(&buf).contains(needle)
12691    }
12692
12693    #[tracing_test::traced_test]
12694    #[tokio::test]
12695    async fn error_context_redacts_query_sentinel() {
12696        let (port, registry) = spawn_test_server().await;
12697        spawn_failing_auth_route(
12698            &registry,
12699            "/secure-query",
12700            vec![CredentialSource::QueryParam {
12701                param: "token".to_string(),
12702            }],
12703        )
12704        .await;
12705
12706        let client = reqwest::Client::new();
12707        let resp = client
12708            // allow-secret: `token` is the declared query-source param name, not a credential
12709            .get(format!(
12710                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
12711            ))
12712            .send()
12713            .await
12714            .unwrap();
12715
12716        assert_eq!(resp.status().as_u16(), 401);
12717        let body = resp.text().await.unwrap();
12718        assert_eq!(body, "Unauthorized");
12719        assert!(
12720            !body.contains(SENTINEL_QRY_42),
12721            "reply body must not contain the query credential"
12722        );
12723        assert!(
12724            !captured_logs_contain(SENTINEL_QRY_42),
12725            "no tracing record during request handling may render the query credential"
12726        );
12727        // Permanent positive control: the failed-auth warn! must be captured.
12728        // If the per-crate env filter ever stops matching, this fails loudly
12729        // instead of letting the sentinel assertions pass vacuously.
12730        assert!(
12731            captured_logs_contain("Authentication failed"),
12732            "positive control: the failed-auth warn! must be captured by the test subscriber"
12733        );
12734    }
12735
12736    #[tracing_test::traced_test]
12737    #[tokio::test]
12738    async fn error_context_redacts_cookie_sentinel() {
12739        let (port, registry) = spawn_test_server().await;
12740        spawn_failing_auth_route(
12741            &registry,
12742            "/secure-cookie",
12743            vec![CredentialSource::Cookie {
12744                name: "session".to_string(),
12745            }],
12746        )
12747        .await;
12748
12749        let client = reqwest::Client::new();
12750        let resp = client
12751            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
12752            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
12753            .send()
12754            .await
12755            .unwrap();
12756
12757        assert_eq!(resp.status().as_u16(), 401);
12758        let body = resp.text().await.unwrap();
12759        assert_eq!(body, "Unauthorized");
12760        assert!(
12761            !body.contains(SENTINEL_CKY_7),
12762            "reply body must not contain the cookie credential"
12763        );
12764        assert!(
12765            !captured_logs_contain(SENTINEL_CKY_7),
12766            "no tracing record during request handling may render the cookie credential"
12767        );
12768    }
12769
12770    #[tracing_test::traced_test]
12771    #[tokio::test]
12772    async fn error_reply_no_credential_value() {
12773        let (port, registry) = spawn_test_server().await;
12774        spawn_failing_auth_route(
12775            &registry,
12776            "/secure-bad",
12777            vec![CredentialSource::Cookie {
12778                name: "session".to_string(),
12779            }],
12780        )
12781        .await;
12782
12783        let client = reqwest::Client::new();
12784        let resp = client
12785            .get(format!("http://127.0.0.1:{port}/secure-bad"))
12786            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
12787            .send()
12788            .await
12789            .unwrap();
12790
12791        assert_eq!(resp.status().as_u16(), 401);
12792        let body = resp.text().await.unwrap();
12793        assert_eq!(body, "Unauthorized");
12794        assert!(
12795            !body.contains(SENTINEL_BAD_1),
12796            "reply body must not contain the credential value"
12797        );
12798        assert!(
12799            !captured_logs_contain(SENTINEL_BAD_1),
12800            "error logs must not render the credential value"
12801        );
12802    }
12803
12804    // -----------------------------------------------------------------------
12805    // Pinned-client-cache producer-path behavioral tests
12806    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
12807    // the endpoint cache, hostname requests build one client while the entry
12808    // stays retrievable, IP-literal requests bypass the cache)
12809    // -----------------------------------------------------------------------
12810
12811    /// Local responder that accepts any number of HTTP/1.1 connections on an
12812    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
12813    /// Unlike [`start_host_capturing_destination`], which serves exactly one
12814    /// connection, this loop keeps accepting so cache-reuse tests can drive
12815    /// several requests through one destination. Returns
12816    /// `(base_url, JoinHandle)`.
12817    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12818        use tokio::io::AsyncWriteExt;
12819
12820        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12821            .await
12822            .expect("bind ephemeral 127.0.0.1 listener");
12823        let port = listener.local_addr().expect("local addr").port();
12824        let base_url = format!("http://localhost:{port}");
12825        let handle = tokio::spawn(async move {
12826            while let Ok((mut conn, _)) = listener.accept().await {
12827                let _ = conn
12828                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12829                    .await;
12830                let _ = conn.shutdown().await;
12831            }
12832        });
12833        (base_url, handle)
12834    }
12835
12836    /// rc-0li3: local HTTPS responder — the TLS twin of
12837    /// [`spawn_multi_accept_200`]. Accepts any number of TLS connections on
12838    /// an ephemeral 127.0.0.1 port and answers each with a fixed 200. The
12839    /// certificate comes from `camel_component_api::test_support`
12840    /// (SANs: localhost, 127.0.0.1, ::1); clients run with
12841    /// `tls.insecure = true`.
12842    async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12843        use tokio::io::AsyncWriteExt;
12844
12845        let (_ca_pem, cert_pem, key_pem) =
12846            camel_component_api::test_support::tls::gen_server_cert();
12847        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
12848            .collect::<Result<_, _>>()
12849            .expect("parse server cert pem");
12850        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
12851            .expect("parse server key pem")
12852            .expect("server key present");
12853        // Explicit provider: the process default is ambiguous when multiple
12854        // crates pull rustls feature sets; the graph enables aws-lc-rs.
12855        let provider =
12856            std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
12857        let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
12858            .with_safe_default_protocol_versions()
12859            .expect("safe default protocol versions")
12860            .with_no_client_auth()
12861            .with_single_cert(certs, key)
12862            .expect("build rustls server config");
12863        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
12864
12865        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12866            .await
12867            .expect("bind ephemeral 127.0.0.1 listener");
12868        let port = listener.local_addr().expect("local addr").port();
12869        let base_url = format!("https://localhost:{port}");
12870        let handle = tokio::spawn(async move {
12871            while let Ok((conn, _)) = listener.accept().await {
12872                let acceptor = acceptor.clone();
12873                tokio::spawn(async move {
12874                    if let Ok(mut tls) = acceptor.accept(conn).await {
12875                        let _ = tls
12876                            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12877                            .await;
12878                        let _ = tls.shutdown().await;
12879                    }
12880                });
12881            }
12882        });
12883        (base_url, handle)
12884    }
12885
12886    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
12887    /// target a different authority (the 127.0.0.1 literal) on the same
12888    /// listener.
12889    fn responder_port(base_url: &str) -> u16 {
12890        url::Url::parse(base_url)
12891            .expect("responder base URL parses")
12892            .port()
12893            .expect("responder base URL carries an explicit port")
12894    }
12895
12896    /// Build an endpoint literal whose outbound config points at
12897    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
12898    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
12899    /// build counts stay observable across producers.
12900    fn endpoint_with_shared_cache(
12901        base_url: &str,
12902        pinned_cache: &Arc<PinnedClientCache>,
12903    ) -> HttpEndpoint {
12904        let uri = format!("{base_url}?allowInternal=true");
12905        HttpEndpoint {
12906            uri: uri.clone(),
12907            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
12908            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
12909            client: reqwest::Client::new(),
12910            pinned_cache: Arc::clone(pinned_cache),
12911            http_config: HttpConfig::default(),
12912        }
12913    }
12914
12915    #[tokio::test]
12916    async fn producers_share_endpoint_cache() {
12917        use tower::ServiceExt;
12918
12919        let (base_url, _handle) = spawn_multi_accept_200().await;
12920        let pinned_cache = Arc::new(PinnedClientCache::new(
12921            PINNED_CLIENT_TTL,
12922            PINNED_CLIENT_MAX_ENTRIES,
12923        ));
12924
12925        let ctx = test_producer_ctx();
12926        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
12927        let producer_a = endpoint.create_producer(rt(), &ctx);
12928        let producer_b = endpoint.create_producer(rt(), &ctx);
12929
12930        // Each producer sends one exchange whose resolved URL is the
12931        // endpoint's localhost base URL (a domain name → pinned-client path).
12932        for producer in [producer_a, producer_b] {
12933            let producer = producer.expect("create producer");
12934            let exchange = Exchange::new(Message::default());
12935            let reply = producer.oneshot(exchange).await;
12936            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12937        }
12938
12939        assert_eq!(
12940            pinned_cache.build_count(),
12941            1,
12942            "both producers must hit the same shared cache entry; a second \
12943             build means sharing is broken"
12944        );
12945    }
12946
12947    #[tokio::test]
12948    async fn producer_repeated_hostname_requests_build_one_client() {
12949        use tower::ServiceExt;
12950
12951        let (base_url, _handle) = spawn_multi_accept_200().await;
12952        let pinned_cache = Arc::new(PinnedClientCache::new(
12953            PINNED_CLIENT_TTL,
12954            PINNED_CLIENT_MAX_ENTRIES,
12955        ));
12956        let ctx = test_producer_ctx();
12957        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
12958        let producer = endpoint
12959            .create_producer(rt(), &ctx)
12960            .expect("create producer");
12961
12962        // Two sequential hostname requests — the cached pinned client stays
12963        // retrievable between them, so no second build may happen.
12964        for i in 0..2 {
12965            let exchange = Exchange::new(Message::default());
12966            let reply = producer.clone().oneshot(exchange).await;
12967            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
12968        }
12969
12970        assert_eq!(
12971            pinned_cache.build_count(),
12972            1,
12973            "repeated hostname requests must reuse the one pinned client; \
12974             0 builds means the producer bypassed the cache, more than 1 \
12975             means the entry was dropped"
12976        );
12977    }
12978
12979    #[tokio::test]
12980    async fn ip_literal_request_never_enters_cache() {
12981        use tower::ServiceExt;
12982
12983        let (base_url, _handle) = spawn_multi_accept_200().await;
12984        let pinned_cache = Arc::new(PinnedClientCache::new(
12985            PINNED_CLIENT_TTL,
12986            PINNED_CLIENT_MAX_ENTRIES,
12987        ));
12988
12989        let ctx = test_producer_ctx();
12990        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
12991        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
12992        let producer = endpoint
12993            .create_producer(rt(), &ctx)
12994            .expect("create producer");
12995
12996        let exchange = Exchange::new(Message::default());
12997        let reply = producer.oneshot(exchange).await;
12998        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12999
13000        assert_eq!(
13001            pinned_cache.build_count(),
13002            0,
13003            "an IP-literal URL must use the shared unpinned client and \
13004             never enter the pinned cache"
13005        );
13006    }
13007
13008    #[tokio::test]
13009    async fn test_component_endpoints_share_pinned_cache() {
13010        use tower::ServiceExt;
13011
13012        let component = HttpComponent::new();
13013        let (base_url, _handle) = spawn_multi_accept_200().await;
13014        let baseline = component.pinned_cache.build_count();
13015
13016        let ctx = test_producer_ctx();
13017        let endpoint_ctx = NoOpComponentContext;
13018        for uri in [
13019            format!("{base_url}/a?allowInternal=true&k=a"),
13020            format!("{base_url}/b?allowInternal=true&k=b"),
13021        ] {
13022            let endpoint = component
13023                .create_endpoint(&uri, &endpoint_ctx)
13024                .expect("create endpoint");
13025            let producer = endpoint
13026                .create_producer(rt(), &ctx)
13027                .expect("create producer");
13028            let exchange = Exchange::new(Message::default());
13029            let reply = producer.oneshot(exchange).await;
13030            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13031        }
13032
13033        assert_eq!(
13034            component.pinned_cache.build_count() - baseline,
13035            1,
13036            "endpoints created by one component must share its pinned cache; \
13037             0 builds means the endpoints bypassed it, more than 1 means \
13038             per-endpoint caches came back"
13039        );
13040    }
13041
13042    #[tokio::test]
13043    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
13044        use tower::ServiceExt;
13045
13046        let component = HttpComponent::new();
13047        let (base_url, _handle) = spawn_multi_accept_200().await;
13048        let baseline = component.pinned_cache.build_count();
13049
13050        let ctx = test_producer_ctx();
13051        let endpoint_ctx = NoOpComponentContext;
13052        for i in 0..3 {
13053            let endpoint = component
13054                .create_endpoint(
13055                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
13056                    &endpoint_ctx,
13057                )
13058                .expect("create endpoint");
13059            let producer = endpoint
13060                .create_producer(rt(), &ctx)
13061                .expect("create producer");
13062            let exchange = Exchange::new(Message::default());
13063            let reply = producer.oneshot(exchange).await;
13064            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13065        }
13066
13067        assert_eq!(
13068            component.pinned_cache.build_count() - baseline,
13069            1,
13070            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13071             must reuse the component's one pinned cache entry; 0 builds \
13072             means the endpoints bypassed it, more than 1 means \
13073             per-endpoint caches came back"
13074        );
13075    }
13076
13077    /// rc-0li3: BEHAVIORAL https sharing pin — two https endpoints created
13078    /// through one `HttpsComponent` drive real TLS requests through the
13079    /// component's single pinned cache. A regression that reintroduces
13080    /// per-endpoint `PinnedClientCache::new` inside
13081    /// `HttpsComponent::create_endpoint` leaves the component cache at
13082    /// delta 0 and fails this test (the structural ptr_eq test cannot see
13083    /// that).
13084    #[tokio::test]
13085    async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
13086        use tower::ServiceExt;
13087
13088        let http_config = HttpConfig {
13089            tls: Some(crate::config::TlsConfig {
13090                enabled: true,
13091                insecure: true,
13092                ..Default::default()
13093            }),
13094            ..Default::default()
13095        };
13096        let component = HttpsComponent::with_config(http_config);
13097        let (base_url, _handle) = spawn_tls_multi_accept_200().await;
13098        let baseline = component.pinned_cache.build_count();
13099
13100        let ctx = test_producer_ctx();
13101        let endpoint_ctx = NoOpComponentContext;
13102        for uri in [
13103            format!("{base_url}/a?allowInternal=true&k=a"),
13104            format!("{base_url}/b?allowInternal=true&k=b"),
13105        ] {
13106            let endpoint = component
13107                .create_endpoint(&uri, &endpoint_ctx)
13108                .expect("create https endpoint");
13109            let producer = endpoint
13110                .create_producer(rt(), &ctx)
13111                .expect("create producer");
13112            let exchange = Exchange::new(Message::default());
13113            let reply = producer.oneshot(exchange).await;
13114            assert!(reply.is_ok(), "https request failed: {reply:?}");
13115        }
13116
13117        assert_eq!(
13118            component.pinned_cache.build_count() - baseline,
13119            1,
13120            "endpoints of one HttpsComponent must share its pinned cache over \
13121             real https requests; 0 builds means the endpoints bypassed it \
13122             (per-endpoint cache regression), more than 1 means \
13123             per-endpoint caches came back"
13124        );
13125    }
13126
13127    #[test]
13128    fn test_https_component_owns_distinct_cache() {
13129        let http = HttpComponent::new();
13130        let https = HttpsComponent::new();
13131
13132        assert!(
13133            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
13134            "http and https components must each own their own pinned cache"
13135        );
13136
13137        let endpoint_ctx = NoOpComponentContext;
13138        let _ = http
13139            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
13140            .expect("http endpoint");
13141        let _ = https
13142            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
13143            .expect("https endpoint");
13144
13145        assert_eq!(
13146            http.pinned_cache.build_count(),
13147            0,
13148            "endpoint creation must not build a pinned client"
13149        );
13150        assert_eq!(
13151            https.pinned_cache.build_count(),
13152            0,
13153            "endpoint creation must not build a pinned client"
13154        );
13155    }
13156
13157    #[test]
13158    fn test_component_constructor_builds_one_unpinned_client() {
13159        let baseline = build_client_call_count();
13160
13161        let _http = HttpComponent::new();
13162        assert_eq!(
13163            build_client_call_count() - baseline,
13164            1,
13165            "HttpComponent::new() must build exactly one shared unpinned client"
13166        );
13167
13168        let _https = HttpsComponent::new();
13169        assert_eq!(
13170            build_client_call_count() - baseline,
13171            2,
13172            "HttpsComponent::new() must build exactly one more shared unpinned client"
13173        );
13174    }
13175
13176    #[test]
13177    fn test_component_endpoints_share_unpinned_client() {
13178        let component = HttpComponent::new();
13179        let baseline = build_client_call_count();
13180
13181        let endpoint_ctx = NoOpComponentContext;
13182        for uri in [
13183            "http://localhost:1/a?allowInternal=true",
13184            "http://localhost:1/b?allowInternal=true",
13185        ] {
13186            let _endpoint = component
13187                .create_endpoint(uri, &endpoint_ctx)
13188                .expect("create endpoint");
13189        }
13190
13191        assert_eq!(
13192            build_client_call_count() - baseline,
13193            0,
13194            "create_endpoint must clone the component's shared unpinned client, \
13195             never build a fresh one"
13196        );
13197    }
13198
13199    #[test]
13200    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
13201        let component = HttpComponent::new();
13202        let baseline = build_client_call_count();
13203
13204        let ctx = test_producer_ctx();
13205        let endpoint_ctx = NoOpComponentContext;
13206        for i in 0..3 {
13207            let endpoint = component
13208                .create_endpoint(
13209                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
13210                    &endpoint_ctx,
13211                )
13212                .expect("create endpoint");
13213            let _producer = endpoint
13214                .create_producer(rt(), &ctx)
13215                .expect("create producer");
13216        }
13217
13218        assert_eq!(
13219            build_client_call_count() - baseline,
13220            0,
13221            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13222             must reuse the component's shared unpinned client and build \
13223             no additional clients"
13224        );
13225    }
13226}