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:None,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 = camel_api::redact::redact_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        // rc-nftni (drainclaim): capture the context-global counter once;
1855        // every envelope this raw-sender consumer constructs carries a
1856        // claim minted at the acceptance dequeue below.
1857        let in_flight = ctx.in_flight_counter();
1858
1859        let path = self.config.path.clone();
1860        let registry_for_cleanup = registry.clone();
1861        let server_exited = registry.server_exited.clone();
1862        let cancel_token = ctx.cancel_token();
1863        let kernel = self.kernel.clone();
1864        // Set when the loop exits because the shared server died. The
1865        // post-loop cleanup still runs, then `start()` returns Err so
1866        // camel-core's consumer watcher emits a CrashNotification for THIS
1867        // route and supervision backoff engages (ADR-0007).
1868        let mut server_died = false;
1869        loop {
1870            tokio::select! {
1871                _ = ctx.cancelled() => {
1872                    break;
1873                }
1874                _ = server_exited.cancelled() => {
1875                    // Shared transport death: this route's consumer cannot
1876                    // continue. Fail (do NOT hang in Running) — parity with
1877                    // per-route transport death, which also surfaces as a
1878                    // consumer-task error.
1879                    server_died = true;
1880                    break;
1881                }
1882                 envelope = env_rx.recv() => {
1883                    let Some(envelope) = envelope else { break; };
1884
1885                    // rc-nftni: mint at acceptance — the dequeue of the
1886                    // dispatcher's RequestEnvelope is where this consumer
1887                    // takes ownership of the wire request. The claim is held
1888                    // across the authn await, the route channel, and the
1889                    // pipeline; every early exit in the per-request task
1890                    // (cancel-503, auth denial) drops it, and a failed push
1891                    // rolls it back with the dropped envelope (RAII).
1892                    let claim =
1893                        in_flight.as_ref().map(camel_component_api::InFlightClaim::attach);
1894
1895                    // Build Exchange from HTTP request
1896                    let mut msg = Message::default();
1897
1898                    // Set standard Camel HTTP headers
1899                    msg.set_header("CamelHttpMethod",
1900                        serde_json::Value::String(envelope.method.clone()));
1901                    msg.set_header("CamelHttpPath",
1902                        serde_json::Value::String(envelope.path.clone()));
1903                    msg.set_header("CamelHttpQuery",
1904                        serde_json::Value::String(envelope.query.clone()));
1905
1906                    // Set path-parameter headers from REST template
1907                    // match. Expert guidance E2: the consumer is
1908                    // responsible for translating the dispatcher's
1909                    // matched params into `CamelHttpPath_<param>`
1910                    // headers on the Exchange, matching the convention
1911                    // used by Camel HTTP for templated routes.
1912                    for (param_name, param_value) in &envelope.path_params {
1913                        msg.set_header(
1914                            format!("CamelHttpPath_{param_name}"),
1915                            serde_json::Value::String(param_value.clone()),
1916                        );
1917                    }
1918
1919                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1920                    for (k, v) in &envelope.headers {
1921                        if let Ok(val_str) = v.to_str() {
1922                            msg.set_header(
1923                                title_case_header(k.as_str()),
1924                                serde_json::Value::String(val_str.to_string()),
1925                            );
1926                        }
1927                    }
1928
1929                    // Body: always arrives as Body::Stream (native streaming)
1930                    // Routes can call into_bytes() if they need to materialize
1931                    msg.body = Body::Stream(envelope.body);
1932
1933                    #[allow(unused_mut)]
1934                    let mut exchange = Exchange::new(msg);
1935
1936                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1937                    #[cfg(feature = "otel")]
1938                    {
1939                        let headers: HashMap<String, String> = envelope
1940                            .headers
1941                            .iter()
1942                            .filter_map(|(k, v)| {
1943                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1944                            })
1945                            .collect();
1946                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1947                    }
1948
1949                    let reply_tx = envelope.reply_tx;
1950                    let sender = ctx.sender().clone();
1951                    let path_clone = path.clone();
1952                    let cancel = cancel_token.clone();
1953                    // Task 2.9 boundary-auth inputs: the raw header map and
1954                    // the request URI (path + query) feed kernel credential
1955                    // extraction inside the per-request task.
1956                    let auth_headers = envelope.headers.clone();
1957                    let auth_uri: http::Uri = {
1958                        let full = if envelope.query.is_empty() {
1959                            envelope.path.clone()
1960                        } else {
1961                            format!("{}?{}", envelope.path, envelope.query)
1962                        };
1963                        // A malformed path cannot become a valid `Uri`; the
1964                        // empty default then carries no credentials, so
1965                        // extraction finds nothing and authn fails closed.
1966                        full.parse().unwrap_or_default()
1967                    };
1968                    let kernel = kernel.clone();
1969
1970                    // Spawn a task to handle this request concurrently
1971                    //
1972                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1973                    // true concurrent request processing. This change was introduced as part of the
1974                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1975                    //
1976                    // Rationale:
1977                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1978                    //    the consumer's main loop until the pipeline processing completes
1979                    // 2. This blocking would prevent multiple HTTP requests from being processed
1980                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1981                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1982                    //    defeating the purpose of pipeline-side concurrency
1983                    // 4. By spawning a task per request, we allow the consumer loop to continue
1984                    //    accepting new requests while existing ones are processed in the pipeline
1985                    //
1986                    // This approach effectively decouples request acceptance from pipeline processing,
1987                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1988                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1989                    tokio::spawn(async move {
1990                        // Check for cancellation before sending to pipeline.
1991                        // Returns 503 (Service Unavailable) instead of letting the request
1992                        // enter a shutting-down pipeline. This is a behavioral change from
1993                        // the pre-concurrency implementation where cancellation during
1994                        // processing would result in a 500 (Internal Server Error).
1995                        // 503 is more semantically correct: the server is temporarily
1996                        // unable to handle the request due to shutdown.
1997                        if cancel.is_cancelled() {
1998                            let _ = reply_tx.send(HttpReply {
1999                                status: 503,
2000                                headers: vec![],
2001                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2002                            });
2003                            return;
2004                        }
2005
2006                        // ADR-0061 Task 2.9: kernel authentication at the
2007                        // request boundary. A `Public` plan passes through
2008                        // with no extraction; any other mode extracts per
2009                        // the plan's sources, authenticates through the
2010                        // kernel, and installs the typed carrier BEFORE the
2011                        // pipeline runs. A denial renders in the HTTP idiom
2012                        // (401 via `pipeline_error_to_reply`) and the route
2013                        // body never sees the request.
2014                        if let Some(kernel) = kernel.as_ref()
2015                            && !matches!(
2016                                kernel.plan.access_mode,
2017                                camel_api::security_policy::AccessMode::Public
2018                            )
2019                        {
2020                            let principal = match camel_auth::extract_token_multi(
2021                                &auth_headers,
2022                                &auth_uri,
2023                                &kernel.plan.credential_sources,
2024                            ) {
2025                                Some(extracted) => {
2026                                    match camel_auth::kernel_authenticate(
2027                                        &kernel.plan,
2028                                        &kernel.providers,
2029                                        &extracted,
2030                                    )
2031                                    .await
2032                                    {
2033                                        Ok(principal) => principal,
2034                                        Err(e) => {
2035                                            // log-policy: handler-owned
2036                                            tracing::warn!(
2037                                                path = %path_clone,
2038                                                error = %e,
2039                                                "HTTP request authentication failed"
2040                                            );
2041                                            let _ = reply_tx.send(pipeline_error_to_reply(
2042                                                e,
2043                                                &path_clone,
2044                                            ));
2045                                            return;
2046                                        }
2047                                    }
2048                                }
2049                                None => {
2050                                    // log-policy: handler-owned
2051                                    tracing::warn!(
2052                                        path = %path_clone,
2053                                        "HTTP request rejected: no credential found in any source"
2054                                    );
2055                                    let _ = reply_tx.send(pipeline_error_to_reply(
2056                                        CamelError::Unauthenticated(
2057                                            "no credential found in any source".to_string(),
2058                                        ),
2059                                        &path_clone,
2060                                    ));
2061                                    return;
2062                                }
2063                            };
2064                            camel_auth::install_carrier(&mut exchange, &principal);
2065                        }
2066
2067                        // Send through pipeline and await result
2068                        let (tx, rx) = tokio::sync::oneshot::channel();
2069                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
2070                            exchange,
2071                            reply_tx: Some(tx),
2072                            // rc-nftni: the acceptance-minted claim rides the
2073                            // envelope; the pipeline drain sites take it and
2074                            // hold it across the pipeline (release at
2075                            // completion; rejection paths above already
2076                            // dropped it).
2077                            in_flight_claim: claim,
2078                        };
2079
2080                        let result = match sender.send(envelope).await {
2081                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2082                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2083                        }
2084                        .and_then(|r| r);
2085
2086                        let reply = match result {
2087                            Ok(out) => {
2088                                let status = out
2089                                    .input
2090                                    .header("CamelHttpResponseCode")
2091                                    .and_then(|v| {
2092                                        let raw = v.as_u64()
2093                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2094                                        let code = raw as u16;
2095                                        (100..1000).contains(&code).then_some(code)
2096                                    })
2097                                    .unwrap_or(200);
2098
2099                                let user_content_type = out
2100                                    .input
2101                                    .header("Content-Type")
2102                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2103
2104                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2105                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2106                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2107                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2108                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2109                                        v.to_string().into_bytes(),
2110                                    )), Some("application/json".to_string())),
2111                                    Body::Stream(s) => {
2112                                        let ct = s.metadata.content_type.clone();
2113                                        match s.stream.lock().await.take() {
2114                                            Some(stream) => (
2115                                                HttpReplyBody::Stream(stream),
2116                                                ct,
2117                                            ),
2118                                            None => {
2119                                                // log-policy: system-broken
2120                                                tracing::error!(
2121                                                    "Body::Stream already consumed before HTTP reply — returning 500"
2122                                                );
2123                                                let error_reply = HttpReply {
2124                                                    status: 500,
2125                                                    headers: vec![],
2126                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2127                                                };
2128                                                if reply_tx.send(error_reply).is_err() {
2129                                                    debug!("reply_tx dropped before error reply could be sent");
2130                                                }
2131                                                return;
2132                                            }
2133                                        }
2134                                    }
2135                                    // Empty and future variants produce an empty reply body.
2136                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2137                                };
2138
2139                                let resp_headers = select_response_headers(
2140                                    &out.input.headers,
2141                                    user_content_type,
2142                                    inferred_content_type,
2143                                );
2144
2145                                HttpReply {
2146                                    status,
2147                                    headers: resp_headers,
2148                                    body: reply_body,
2149                                }
2150                            }
2151                            Err(e) => {
2152                                pipeline_error_to_reply(e, &path_clone)
2153                            }
2154                        };
2155
2156                        // Reply to Axum handler (ignore error if client disconnected)
2157                        let _ = reply_tx.send(reply);
2158                    });
2159                }
2160            }
2161        }
2162
2163        // Deregister this consumer. Mirror the registration choice:
2164        // REST-registered consumers remove their (method, path) endpoint
2165        // WITHOUT touching sibling verbs on the same template (review C1);
2166        // legacy consumers clean up api_routes.
2167        if let Some(method) = &self.config.method {
2168            registry_for_cleanup
2169                .unregister_rest_endpoint(method, &path)
2170                .await;
2171        } else {
2172            registry_for_cleanup.unregister_api_route(&path).await;
2173        }
2174
2175        // Leave the shared-server entry: `unregister` is a no-op today (no
2176        // refcount exists — stale D-L10 wording removed, rc-szmob review).
2177        // Dead servers are evicted lazily by `get_or_spawn_internal`, which
2178        // checks `monitor_task.is_finished()` and rebinds on the next spawn
2179        // (e.g. a supervision restart after this consumer's Err).
2180        ServerRegistry::global()
2181            .unregister(&self.config.host, self.config.port)
2182            .await;
2183
2184        if server_died {
2185            // log-policy: system-broken
2186            tracing::error!(
2187                host = %camel_api::redact::redact_host(&self.config.host),
2188                port = self.config.port,
2189                path = %path,
2190                "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2191            );
2192            // The error value is logged upstream by supervision (ADR-0076):
2193            // the host must ride the canonical masker, message structure
2194            // unchanged (bd rc-8bxeo item 3).
2195            return Err(CamelError::RouteError(format!(
2196                "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2197                camel_api::redact::redact_host(&self.config.host),
2198                self.config.port
2199            )));
2200        }
2201
2202        Ok(())
2203    }
2204
2205    async fn stop(&mut self) -> Result<(), CamelError> {
2206        Ok(())
2207    }
2208
2209    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2210        camel_component_api::ConcurrencyModel::Concurrent { max: None }
2211    }
2212
2213    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
2214    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
2215    // Opting into Explicit startup makes ctx.start() await the bind+register
2216    // completion so listeners fail fast on bind errors (previously a silent
2217    // background log) and external markers can reliably detect listener-bound
2218    // state.
2219    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2220        camel_component_api::ConsumerStartupMode::Explicit
2221    }
2222
2223    // Task 2.9: capture the kernel state (compiled plan + provider registry)
2224    // wired by the route controller before start(). See `HttpKernelAuth`.
2225    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2226        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2227    }
2228}
2229
2230// ---------------------------------------------------------------------------
2231// HttpComponent / HttpsComponent
2232// ---------------------------------------------------------------------------
2233
2234pub struct HttpComponent {
2235    config: HttpConfig,
2236    pinned_cache: std::sync::Arc<PinnedClientCache>,
2237    client: reqwest::Client,
2238    /// Set at construction when `tls.strict` is on and the configured
2239    /// material fails to load; surfaced as an endpoint-creation failure
2240    /// (rc-ayrwk).
2241    strict_tls_error: Option<CamelError>,
2242}
2243
2244#[cfg(test)]
2245thread_local! {
2246    static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2247    static BUILD_CLIENT_FALLBACKS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2248}
2249
2250/// Shared builder assembly for [`build_client`]: everything except the
2251/// terminal `build()` — proxy, timeouts, pool, redirect policy, DNS-pin
2252/// override, and the permissive (warn-on-degrade) TLS material loads
2253/// (rc-ayrwk / audit F2-7).
2254fn client_builder(
2255    config: &HttpConfig,
2256    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2257) -> reqwest::ClientBuilder {
2258    let mut builder = reqwest::Client::builder()
2259        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
2260        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2261        .pool_max_idle_per_host(config.pool_max_idle_per_host)
2262        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2263
2264    // Redirects are always handled manually in the producer's send path
2265    // so that each hop can be SSRF-validated. reqwest's built-in redirect
2266    // policy is sync and cannot perform async DNS resolution or SSRF checks.
2267    builder = builder.redirect(reqwest::redirect::Policy::none());
2268
2269    if let Some((host, addrs)) = resolve_override {
2270        builder = builder.resolve_to_addrs(host, addrs);
2271    }
2272
2273    if let Some(tls) = &config.tls
2274        && tls.enabled
2275    {
2276        if tls.insecure || !tls.verify_peer {
2277            // log-policy: handler-owned
2278            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2279            builder = builder.danger_accept_invalid_certs(true);
2280        }
2281
2282        if let Some(ca_path) = &tls.ca_cert_path {
2283            // Audit 2026-08-31, F2-7: a configured CA that fails to load must
2284            // never degrade silently to system roots. Loud warn (config error
2285            // class: fail-fast would break existing deployments relying on the
2286            // fallback; the warning is the operator signal).
2287            match std::fs::read(ca_path) {
2288                Ok(ca_bytes) => {
2289                    // Under the rustls backend `Certificate::from_pem`
2290                    // never fails (it defers parsing), so the parse-error
2291                    // warn below is effectively dead and a file with zero
2292                    // parseable PEM CERTIFICATE sections would silently
2293                    // contribute no roots. Warn on that case explicitly
2294                    // (e_glm stage-4 finding 1).
2295                    let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2296                        .filter(|r| r.is_ok())
2297                        .count();
2298                    if pem_sections == 0 {
2299                        // log-policy: handler-owned
2300                        tracing::warn!(
2301                            "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2302                        );
2303                    }
2304                    match reqwest::Certificate::from_pem(&ca_bytes)
2305                        .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2306                    {
2307                        Ok(ca_cert) => {
2308                            builder = builder.add_root_certificate(ca_cert);
2309                        }
2310                        Err(e) => {
2311                            // log-policy: handler-owned
2312                            tracing::warn!(
2313                                error = %e,
2314                                "configured CA certificate failed to parse — falling back to system roots"
2315                            );
2316                        }
2317                    }
2318                }
2319                Err(e) => {
2320                    // log-policy: handler-owned
2321                    tracing::warn!(
2322                        error = %e,
2323                        "configured CA certificate file unreadable — falling back to system roots"
2324                    );
2325                }
2326            }
2327        }
2328
2329        // mTLS identity: BOTH files must load and parse, or the identity is
2330        // absent. A partial failure previously meant silently downgrading to
2331        // non-mTLS — now loud.
2332        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2333            match (std::fs::read(cert_path), std::fs::read(key_path)) {
2334                (Ok(cert_bytes), Ok(key_bytes)) => {
2335                    let mut identity_pem = cert_bytes;
2336                    identity_pem.extend_from_slice(&key_bytes);
2337                    match reqwest::Identity::from_pem(&identity_pem) {
2338                        Ok(identity) => {
2339                            builder = builder.identity(identity);
2340                        }
2341                        Err(e) => {
2342                            // log-policy: handler-owned
2343                            tracing::warn!(
2344                                error = %e,
2345                                "configured mTLS identity failed to parse — client certificate NOT used"
2346                            );
2347                        }
2348                    }
2349                }
2350                (cert_r, key_r) => {
2351                    // log-policy: handler-owned
2352                    tracing::warn!(
2353                        cert_ok = cert_r.is_ok(),
2354                        key_ok = key_r.is_ok(),
2355                        "configured mTLS cert/key file unreadable — client certificate NOT used"
2356                    );
2357                }
2358            }
2359        }
2360    }
2361
2362    builder
2363}
2364
2365/// Bundled-root TLS config for the CA-less-platform fallback (rc-3j4mq):
2366/// Mozilla's root set from `webpki-roots`, precedent rc-ayy11 (camel-cli
2367/// redis-tls pure-rust roots).
2368///
2369/// Why a preconfigured `rustls::ClientConfig` and not a reqwest root
2370/// knob: reqwest 0.13 removed `tls_built_in_root_certs` and its
2371/// webpki-roots feature, `Certificate::from_der` needs full DER
2372/// certificates while webpki-roots ships pre-parsed `TrustAnchor`s, and
2373/// `tls_certs_merge` still routes through rustls-platform-verifier
2374/// (which hard-errors on android/apple targets when extra roots are
2375/// set). `tls_backend_preconfigured` swaps the whole TLS backend; on
2376/// reqwest 0.13.4 the preconfigured path builds its connector without
2377/// consulting platform roots, so the empty-CA-store builder error cannot
2378/// recur there.
2379fn webpki_root_client_config() -> rustls::ClientConfig {
2380    // Mirror reqwest's own provider resolution (async_impl/client.rs):
2381    // process-default provider when the host installed one (camel-cli
2382    // installs ring), else the aws-lc-rs default reqwest's `rustls`
2383    // feature falls back to.
2384    let provider = rustls::crypto::CryptoProvider::get_default()
2385        .cloned()
2386        .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
2387    rustls::ClientConfig::builder_with_provider(provider)
2388        .with_safe_default_protocol_versions()
2389        // Stock rustls providers (ring, aws-lc-rs) always support the
2390        // safe default TLS versions; this config is static, not input- or
2391        // platform-dependent.
2392        .expect("stock rustls provider supports the safe default protocol versions") // allow-unwrap
2393        .with_root_certificates(rustls::RootCertStore {
2394            roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
2395        })
2396        .with_no_client_auth()
2397    // No ALPN override: the workspace reqwest builds without the http2
2398    // feature, so the primary path negotiates plain HTTP/1.1. Sending no
2399    // ALPN extension yields the same HTTP/1.1 outcome here without
2400    // duplicating reqwest feature knowledge in this crate.
2401}
2402
2403/// Fallback path when the platform-verifier client build fails (typical
2404/// trigger: a platform without a system CA store at the probed paths,
2405/// e.g. Android/Termux — the eager `HttpComponent::new()` in the BASE
2406/// flavor used to panic there, rc-3j4mq). Startup must never panic on
2407/// CA-less platforms, so the retry swaps the TLS backend for the bundled
2408/// Mozilla root set. The fallback is ONLY reachable through a failed
2409/// primary build — it never activates when native roots load.
2410fn webpki_fallback_client(
2411    config: &HttpConfig,
2412    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2413    first_error: reqwest::Error,
2414) -> reqwest::Client {
2415    #[cfg(test)]
2416    BUILD_CLIENT_FALLBACKS.with(|c| c.set(c.get() + 1));
2417
2418    // log-policy: handler-owned
2419    tracing::warn!(
2420        error = %first_error,
2421        "HTTP client build failed on platform TLS roots — the platform CA store \
2422         is missing or unreadable (typical on Android/Termux). Retrying with \
2423         bundled Mozilla root certificates; platform trust settings do not \
2424         apply to this client"
2425    );
2426    if let Some(tls) = &config.tls
2427        && tls.enabled
2428        && (tls.ca_cert_path.is_some()
2429            || tls.client_cert_path.is_some()
2430            || tls.client_key_path.is_some())
2431    {
2432        // The preconfigured backend ignores reqwest's per-builder TLS
2433        // material; only a load failure in the primary build can reach
2434        // here with material configured, so name the degradation.
2435        // log-policy: handler-owned
2436        tracing::warn!(
2437            "configured TLS material (custom CA / mTLS identity) is not carried \
2438             into the webpki fallback client — the material failed to load or \
2439             the platform verifier rejected it"
2440        );
2441    }
2442
2443    match client_builder(config, resolve_override)
2444        .tls_backend_preconfigured(webpki_root_client_config())
2445        .build()
2446    {
2447        Ok(client) => client,
2448        Err(second_error) => {
2449            // Unreachable on reqwest 0.13.4: the preconfigured backend has
2450            // no fallible stage (no platform verifier, no root-store
2451            // parse) unless the http3 feature is on, which this workspace
2452            // does not enable. Kept as an honest, loudly-logged terminal
2453            // instead of silently re-panicking: reaching it means the TLS
2454            // stack is broken process-wide, CA store or not.
2455            // log-policy: system-broken
2456            tracing::error!(
2457                error = %second_error,
2458                "webpki fallback client build failed — TLS stack broken process-wide"
2459            );
2460            reqwest::Client::builder()
2461                .no_proxy()
2462                .redirect(reqwest::redirect::Policy::none())
2463                .tls_backend_preconfigured(webpki_root_client_config())
2464                .build()
2465                .expect("preconfigured webpki-rooted client build has no fallible stage") // allow-unwrap
2466        }
2467    }
2468}
2469
2470/// Build the shared/dns-pinned reqwest client. Infallible at call sites:
2471/// on a platform whose CA store cannot be loaded the build retries on
2472/// bundled Mozilla roots (see [`webpki_fallback_client`]) instead of
2473/// panicking (rc-3j4mq).
2474pub(crate) fn build_client(
2475    config: &HttpConfig,
2476    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2477) -> reqwest::Client {
2478    #[cfg(test)]
2479    BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2480
2481    match client_builder(config, resolve_override).build() {
2482        Ok(client) => client,
2483        Err(first_error) => webpki_fallback_client(config, resolve_override, first_error),
2484    }
2485}
2486
2487/// Eagerly load and parse the configured TLS material when strict mode is
2488/// on (audit 2026-08-31 R3 / rc-ayrwk). Returns the first failure as an
2489/// `EndpointCreationFailed` error; `None` when the material loads, or when
2490/// strict mode is off (the permissive F2-7 fallback with its loud warns
2491/// stays the default for back-compat).
2492///
2493/// Mirrors the four load sites in [`build_client`]: CA unreadable, CA
2494/// unparseable, mTLS cert/key unreadable, mTLS identity unparseable.
2495fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2496    let tls = config.tls.as_ref()?;
2497    if !tls.enabled || !tls.strict {
2498        return None;
2499    }
2500    if let Some(ca_path) = &tls.ca_cert_path {
2501        match std::fs::read(ca_path) {
2502            Ok(ca_bytes) => {
2503                // `reqwest::Certificate::{from_pem,from_der}` defer parsing
2504                // under rustls, and unparseable entries are silently
2505                // skipped at client build — so strict validation must be
2506                // eager AND match what the backend actually enforces:
2507                // a PEM bundle with at least one parseable CERTIFICATE
2508                // section (rustls-pemfile). A raw-DER file is rejected
2509                // outright: the rustls backend never honors lone-DER
2510                // bytes here (they wrap unvalidated and are dropped at
2511                // root-store insertion), so certifying one under strict
2512                // would certify an unenforced config (e_glm stage-4
2513                // finding 1). Operators convert DER bundles to PEM.
2514                let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2515                    .filter(|r| r.is_ok())
2516                    .count();
2517                if pem_sections == 0 {
2518                    return Some(CamelError::EndpointCreationFailed(format!(
2519                        "tls.strict: configured CA certificate '{ca_path}' has no \
2520                         parseable PEM CERTIFICATE section (DER bundles are not \
2521                         enforced by the TLS backend — convert to PEM)"
2522                    )));
2523                }
2524            }
2525            Err(e) => {
2526                return Some(CamelError::EndpointCreationFailed(format!(
2527                    "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
2528                )));
2529            }
2530        }
2531    }
2532    // A half-configured mTLS pair (cert XOR key) previously degraded
2533    // silently to non-mTLS even under strict — reject it (e_glm stage-4
2534    // finding 2).
2535    if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
2536        return Some(CamelError::EndpointCreationFailed(
2537            "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
2538        ));
2539    }
2540    if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2541        match (std::fs::read(cert_path), std::fs::read(key_path)) {
2542            (Ok(mut cert_bytes), Ok(key_bytes)) => {
2543                cert_bytes.extend_from_slice(&key_bytes);
2544                if reqwest::Identity::from_pem(&cert_bytes).is_err() {
2545                    return Some(CamelError::EndpointCreationFailed(
2546                        "tls.strict: configured mTLS identity failed to parse".to_string(),
2547                    ));
2548                }
2549            }
2550            _ => {
2551                return Some(CamelError::EndpointCreationFailed(
2552                    "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
2553                ));
2554            }
2555        }
2556    }
2557    None
2558}
2559
2560#[cfg(test)]
2561pub(crate) fn build_client_call_count() -> u64 {
2562    BUILD_CLIENT_CALLS.with(|c| c.get())
2563}
2564
2565#[cfg(test)]
2566pub(crate) fn build_client_fallback_count() -> u64 {
2567    BUILD_CLIENT_FALLBACKS.with(|c| c.get())
2568}
2569
2570impl HttpComponent {
2571    pub fn new() -> Self {
2572        let config = HttpConfig::default();
2573        let strict_err = strict_tls_error(&config);
2574        Self {
2575            client: build_client(&config, None),
2576            config,
2577            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2578                PINNED_CLIENT_TTL,
2579                PINNED_CLIENT_MAX_ENTRIES,
2580            )),
2581            strict_tls_error: strict_err,
2582        }
2583    }
2584
2585    pub fn with_config(config: HttpConfig) -> Self {
2586        let strict_err = strict_tls_error(&config);
2587        Self {
2588            client: build_client(&config, None),
2589            config,
2590            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2591                PINNED_CLIENT_TTL,
2592                PINNED_CLIENT_MAX_ENTRIES,
2593            )),
2594            strict_tls_error: strict_err,
2595        }
2596    }
2597
2598    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2599        match config {
2600            Some(cfg) => Self::with_config(cfg),
2601            None => Self::new(),
2602        }
2603    }
2604}
2605
2606impl Default for HttpComponent {
2607    fn default() -> Self {
2608        Self::new()
2609    }
2610}
2611
2612impl Component for HttpComponent {
2613    fn scheme(&self) -> &str {
2614        "http"
2615    }
2616
2617    fn metadata(&self) -> ComponentMetadata {
2618        HttpEndpointConfig::metadata()
2619    }
2620
2621    fn create_endpoint(
2622        &self,
2623        uri: &str,
2624        ctx: &dyn camel_component_api::ComponentContext,
2625    ) -> Result<Box<dyn Endpoint>, CamelError> {
2626        if let Some(err) = &self.strict_tls_error {
2627            return Err(err.clone());
2628        }
2629        self.config.validate()?;
2630        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2631        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2632        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2633            server_config.host.clone(),
2634            server_config.port,
2635        )));
2636        self.pinned_cache
2637            .wire(HttpComponentKind::Http, ctx.metrics());
2638        Ok(Box::new(HttpEndpoint {
2639            uri: uri.to_string(),
2640            config,
2641            server_config,
2642            client: self.client.clone(),
2643            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2644            http_config: self.config.clone(),
2645        }))
2646    }
2647}
2648
2649pub struct HttpsComponent {
2650    config: HttpConfig,
2651    pinned_cache: std::sync::Arc<PinnedClientCache>,
2652    client: reqwest::Client,
2653    /// Set at construction when `tls.strict` is on and the configured
2654    /// material fails to load; surfaced as an endpoint-creation failure
2655    /// (rc-ayrwk).
2656    strict_tls_error: Option<CamelError>,
2657}
2658
2659impl HttpsComponent {
2660    pub fn new() -> Self {
2661        let config = HttpConfig::default();
2662        let strict_err = strict_tls_error(&config);
2663        Self {
2664            client: build_client(&config, None),
2665            config,
2666            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2667                PINNED_CLIENT_TTL,
2668                PINNED_CLIENT_MAX_ENTRIES,
2669            )),
2670            strict_tls_error: strict_err,
2671        }
2672    }
2673
2674    pub fn with_config(config: HttpConfig) -> Self {
2675        let strict_err = strict_tls_error(&config);
2676        Self {
2677            client: build_client(&config, None),
2678            config,
2679            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2680                PINNED_CLIENT_TTL,
2681                PINNED_CLIENT_MAX_ENTRIES,
2682            )),
2683            strict_tls_error: strict_err,
2684        }
2685    }
2686
2687    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2688        match config {
2689            Some(cfg) => Self::with_config(cfg),
2690            None => Self::new(),
2691        }
2692    }
2693}
2694
2695impl Default for HttpsComponent {
2696    fn default() -> Self {
2697        Self::new()
2698    }
2699}
2700
2701impl Component for HttpsComponent {
2702    fn scheme(&self) -> &str {
2703        "https"
2704    }
2705
2706    fn metadata(&self) -> ComponentMetadata {
2707        // HTTPS shares the same URI option surface and capabilities as HTTP.
2708        // Only the scheme and description differ.
2709        let mut meta = HttpEndpointConfig::metadata();
2710        meta.scheme = "https".to_string();
2711        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2712        meta
2713    }
2714
2715    fn create_endpoint(
2716        &self,
2717        uri: &str,
2718        ctx: &dyn camel_component_api::ComponentContext,
2719    ) -> Result<Box<dyn Endpoint>, CamelError> {
2720        if let Some(err) = &self.strict_tls_error {
2721            return Err(err.clone());
2722        }
2723        self.config.validate()?;
2724        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2725        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2726        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2727            server_config.host.clone(),
2728            server_config.port,
2729        )));
2730        self.pinned_cache
2731            .wire(HttpComponentKind::Https, ctx.metrics());
2732        Ok(Box::new(HttpEndpoint {
2733            uri: uri.to_string(),
2734            config,
2735            server_config,
2736            client: self.client.clone(),
2737            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2738            http_config: self.config.clone(),
2739        }))
2740    }
2741}
2742
2743// ---------------------------------------------------------------------------
2744// HttpEndpoint
2745// ---------------------------------------------------------------------------
2746
2747struct HttpEndpoint {
2748    uri: String,
2749    config: HttpEndpointConfig,
2750    server_config: HttpServerConfig,
2751    client: reqwest::Client,
2752    pinned_cache: std::sync::Arc<PinnedClientCache>,
2753    http_config: HttpConfig,
2754}
2755
2756impl Endpoint for HttpEndpoint {
2757    fn uri(&self) -> &str {
2758        &self.uri
2759    }
2760
2761    fn create_consumer(
2762        &self,
2763        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2764    ) -> Result<Box<dyn Consumer>, CamelError> {
2765        // Scheme/config consistency check (spec §5) — uses parsed scheme
2766        // from HttpServerConfig, not a fragile port-443 heuristic.
2767        let scheme_is_https = self.server_config.scheme == "https";
2768        let has_tls = self.server_config.tls_config.is_some();
2769
2770        if scheme_is_https && !has_tls {
2771            return Err(CamelError::EndpointCreationFailed(
2772                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2773            ));
2774        }
2775        if !scheme_is_https && has_tls {
2776            return Err(CamelError::EndpointCreationFailed(
2777                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2778            ));
2779        }
2780        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2781    }
2782
2783    fn create_producer(
2784        &self,
2785        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2786        _ctx: &ProducerContext,
2787    ) -> Result<BoxProcessor, CamelError> {
2788        let producer = HttpProducer {
2789            config: Arc::new(self.config.clone()),
2790            client: self.client.clone(),
2791            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2792            http_config: Arc::new(self.http_config.clone()),
2793            runtime: rt,
2794        };
2795        if let Some(ref provider) = self.config.token_provider {
2796            let layer = BearerTokenLayer::new(Arc::clone(provider));
2797            Ok(BoxProcessor::new(layer.layer(producer)))
2798        } else {
2799            Ok(BoxProcessor::new(producer))
2800        }
2801    }
2802}
2803
2804// ---------------------------------------------------------------------------
2805// HttpProducer
2806// ---------------------------------------------------------------------------
2807
2808#[derive(Clone)]
2809struct HttpProducer {
2810    config: Arc<HttpEndpointConfig>,
2811    client: reqwest::Client,
2812    pinned_cache: std::sync::Arc<PinnedClientCache>,
2813    http_config: Arc<HttpConfig>,
2814    /// Runtime observability handle powering the component-ops facade at
2815    /// the request boundary (`("http","request")`, dashboard-observability
2816    /// Task 4.3). The retained `e:http:accept*` labels are consumer-side
2817    /// (server accept loop) — different boundary, no collision with
2818    /// `e:http:request`.
2819    runtime: Arc<dyn RuntimeObservability>,
2820}
2821
2822impl HttpProducer {
2823    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2824        if let Some(ref method) = config.http_method {
2825            return method.to_uppercase();
2826        }
2827        if let Some(method) = exchange
2828            .input
2829            .header("CamelHttpMethod")
2830            .and_then(|v| v.as_str())
2831        {
2832            return method.to_uppercase();
2833        }
2834        if !exchange.input.body.is_empty() {
2835            return "POST".to_string();
2836        }
2837        "GET".to_string()
2838    }
2839
2840    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2841        // bridgeEndpoint=true: exchange URL headers (CamelHttpUri,
2842        // CamelHttpPath, CamelHttpQuery) are ignored per Apache Camel
2843        // bridging semantics. The endpoint's own query still rides: the
2844        // same raw-preserving, consumed-option-filtered query as the
2845        // non-bridge path (bridgeEndpoint itself is a consumed option),
2846        // with programmatic query_params appending absent keys after the
2847        // raw base. This check MUST come before the CamelHttpUri override
2848        // so bridging wins over that header.
2849        if config.bridge_endpoint {
2850            let Some(query) = resolve_endpoint_query(config)? else {
2851                return Ok(config.base_url.clone());
2852            };
2853            // Validation only (rc-ph7z2): a malformed base still errors
2854            // through the redacted-diagnostic path below. The parsed value
2855            // is NEVER re-emitted — assembly is verbatim string
2856            // composition, authored bytes end-to-end: no WHATWG
2857            // normalization (dot-segment collapse, default-port strip,
2858            // scheme/host lowercasing), matching every other arm (Papal
2859            // Direction A).
2860            let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2861                CamelError::ProcessorError(format!(
2862                    "invalid base URL '{}': {e}",
2863                    redact_url_for_diagnostics(&config.base_url)
2864                ))
2865            })?;
2866            let mut url = config.base_url.clone();
2867            url.push('?');
2868            url.push_str(&query);
2869            return Ok(url);
2870        }
2871
2872        if let Some(uri) = exchange
2873            .input
2874            .header("CamelHttpUri")
2875            .and_then(|v| v.as_str())
2876        {
2877            // Host fence (allowedUriHosts): opt-in, fail-closed. Evaluated
2878            // on the raw override before any path/query assembly; a
2879            // rejection renders the URL only through the diagnostics
2880            // redaction path (ADR-0051).
2881            if let Some(fence) = &config.allowed_uri_hosts
2882                && !uri_host_allowed(uri, fence)?
2883            {
2884                return Err(CamelError::ProcessorError(format!(
2885                    "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2886                    redact_url_for_diagnostics(uri)
2887                )));
2888            }
2889            // The override replaces the base URL; its own query is the
2890            // higher-precedence source for composition (ADR-0071) — the
2891            // endpoint base query does not ride an override. Split at the
2892            // first `?` so CamelHttpPath applies to the path component
2893            // and the queries merge at pair level, never a second `?`
2894            // marker.
2895            let (base, override_query) = match uri.split_once('?') {
2896                Some((base, query)) => (base, Some(query)),
2897                None => (uri, None),
2898            };
2899            // Resolve-time span validation for the override URI's own query
2900            // (rc-m4xk1): a forbidden byte is a resolve error naming the
2901            // byte, never a verbatim ride that later surfaces as a reqwest
2902            // send error. Covers both downstream arms — the verbatim push
2903            // and merge_header_query, which validates only the header side.
2904            if let Some(query) = override_query {
2905                for (_key, span) in raw_query_pairs(query)? {
2906                    validate_raw_query_span(span)?;
2907                }
2908            }
2909            let mut url = base.to_string();
2910            if let Some(path) = exchange
2911                .input
2912                .header("CamelHttpPath")
2913                .and_then(|v| v.as_str())
2914            {
2915                if !url.ends_with('/') && !path.starts_with('/') {
2916                    url.push('/');
2917                }
2918                url.push_str(path);
2919            }
2920            if let Some(query) = exchange
2921                .input
2922                .header("CamelHttpQuery")
2923                .and_then(|v| v.as_str())
2924            {
2925                if let Some(merged) = merge_header_query(override_query, query)? {
2926                    url.push('?');
2927                    url.push_str(&merged);
2928                }
2929                return Ok(url);
2930            }
2931            if let Some(query) = override_query {
2932                url.push('?');
2933                url.push_str(query);
2934            }
2935            return Ok(url);
2936        }
2937
2938        let mut url = config.base_url.clone();
2939
2940        if let Some(path) = exchange
2941            .input
2942            .header("CamelHttpPath")
2943            .and_then(|v| v.as_str())
2944        {
2945            if !url.ends_with('/') && !path.starts_with('/') {
2946                url.push('/');
2947            }
2948            url.push_str(path);
2949        }
2950
2951        if let Some(query) = exchange
2952            .input
2953            .header("CamelHttpQuery")
2954            .and_then(|v| v.as_str())
2955        {
2956            // Compose: the endpoint query (raw-preserving,
2957            // consumed-option-filtered) comes first and wins collisions;
2958            // header pairs append verbatim for absent keys (ADR-0071).
2959            // An empty header leaves the endpoint query unchanged.
2960            if let Some(merged) =
2961                merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2962            {
2963                url.push('?');
2964                url.push_str(&merged);
2965            }
2966            return Ok(url);
2967        }
2968
2969        if let Some(query) = resolve_endpoint_query(config)? {
2970            url.push('?');
2971            url.push_str(&query);
2972        }
2973
2974        Ok(url)
2975    }
2976
2977    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2978        status >= range.0 && status <= range.1
2979    }
2980}
2981
2982/// One allowlist entry of the `CamelHttpUri` host fence (`allowedUriHosts`
2983/// endpoint option). DNS hosts are stored ASCII-lowercased; IPv6 literals
2984/// in bracketed canonical form (the `url` crate's host serialization). A
2985/// `port` of `None` is a host-only entry and permits any port.
2986#[derive(Clone, Debug, PartialEq, Eq)]
2987pub struct AllowedUriHost {
2988    /// Canonical host: lowercased DNS name or bracketed IPv6 literal.
2989    pub host: String,
2990    /// `Some` pins the entry to one effective port; `None` permits any.
2991    pub port: Option<u16>,
2992}
2993
2994/// Parse the `allowedUriHosts` option value: comma-separated `host` or
2995/// `host:port` entries. Bracketed IPv6 is supported (`[::1]:8443`, bare
2996/// `[::1]` host-only). Empty segments are dropped. Segments are parsed
2997/// through the `url` crate (with an `http://` scheme injected) so DNS
2998/// names are lowercased and ports range-checked; anything it rejects is a
2999/// malformed entry. A value yielding zero valid entries is also an error.
3000/// Both failure modes fail endpoint creation (fail-closed).
3001fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
3002    let mut entries = Vec::new();
3003    for segment in raw.split(',') {
3004        let segment = segment.trim();
3005        if segment.is_empty() {
3006            continue;
3007        }
3008        let parsed = url::Url::parse(&format!("http://{segment}"))
3009            .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
3010        // A segment carrying a path or userinfo is a typo'd entry — the
3011        // spec's "any other malformed entry" clause. Silently narrowing it
3012        // to its hostname would widen or skew the fence.
3013        if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
3014            return Err(invalid_allowed_uri_host_entry(segment));
3015        }
3016        let Some(host) = parsed.host_str() else {
3017            return Err(invalid_allowed_uri_host_entry(segment));
3018        };
3019        entries.push(AllowedUriHost {
3020            host: host.to_string(),
3021            port: parsed.port(),
3022        });
3023    }
3024    if entries.is_empty() {
3025        return Err(CamelError::InvalidUri(
3026            "allowedUriHosts declares no valid host entries".to_string(),
3027        ));
3028    }
3029    Ok(entries)
3030}
3031
3032fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
3033    CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
3034}
3035
3036/// Whether `url_str` matches the fence. Parse failure or a host-less URL
3037/// is fail-closed (`Ok(false)`). DNS hosts compare case-insensitively
3038/// (both sides are lowercased by the `url` crate); IPv6 compares in
3039/// bracketed canonical form. A host-only entry permits any port; a
3040/// `host:port` entry matches only the effective port — the explicit port
3041/// or the scheme default (443 for https, 80 for http).
3042pub(crate) fn uri_host_allowed(
3043    url_str: &str,
3044    fence: &[AllowedUriHost],
3045) -> Result<bool, CamelError> {
3046    let Ok(parsed) = url::Url::parse(url_str) else {
3047        return Ok(false);
3048    };
3049    let Some(host) = parsed.host_str() else {
3050        return Ok(false);
3051    };
3052    let effective_port = parsed.port().or(match parsed.scheme() {
3053        "https" => Some(443_u16),
3054        "http" => Some(80),
3055        _ => None,
3056    });
3057    Ok(fence.iter().any(|entry| {
3058        entry.host == host
3059            && match entry.port {
3060                None => true,
3061                Some(port) => effective_port == Some(port),
3062            }
3063    }))
3064}
3065
3066/// Serialize the outbound query for the endpoint base.
3067///
3068/// Authored raw pairs come first, byte-for-byte minus consumed option keys
3069/// (order, separators and authored escapes — including `RAW(...)` text —
3070/// preserved); then programmatic `query_params` entries whose key is absent
3071/// from the authored pairs, in declaration order with minimal RFC-3986
3072/// encoding (`%20`, never `+`). Authored keys always win — no duplication,
3073/// no override.
3074///
3075/// Returns `Ok(None)` when no query component is emitted: no pairs at all,
3076/// or a non-empty raw query whose every pair was consumed. A bare `?`
3077/// marker (`raw_query == Some("")`) always emits the query component.
3078fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
3079    let mut parts: Vec<String> = Vec::new();
3080    let mut authored_keys = std::collections::HashSet::new();
3081
3082    if let Some(raw) = config.raw_query.as_deref() {
3083        for (key, span) in raw_query_pairs(raw)? {
3084            authored_keys.insert(key.clone());
3085            if is_consumed_option(&key) {
3086                continue;
3087            }
3088            validate_raw_query_span(span)?;
3089            parts.push(span.to_string());
3090        }
3091    }
3092
3093    for (key, value) in &config.query_params {
3094        if !authored_keys.contains(key.as_str()) {
3095            parts.push(format!(
3096                "{}={}",
3097                encode_query_component(key),
3098                encode_query_component(value)
3099            ));
3100        }
3101    }
3102
3103    if parts.is_empty() && config.raw_query.as_deref() != Some("") {
3104        return Ok(None);
3105    }
3106    Ok(Some(parts.join("&")))
3107}
3108
3109/// Compose the outbound query when a `CamelHttpQuery` exchange header is
3110/// present (ADR-0071). `higher_precedence` — the endpoint query in the
3111/// base arm, the override URI's own query in the override arm — comes
3112/// first and wins any key collision; header pairs append verbatim for
3113/// absent keys only. An empty header leaves the higher-precedence query
3114/// unchanged (no additional `?` marker). Header spans are validated, not
3115/// re-encoded: a byte forbidden in a query component is a resolve error
3116/// naming the byte (Wave-A law).
3117fn merge_header_query(
3118    higher_precedence: Option<&str>,
3119    header_query: &str,
3120) -> Result<Option<String>, CamelError> {
3121    if header_query.is_empty() {
3122        return Ok(higher_precedence.map(str::to_string));
3123    }
3124    let mut parts: Vec<String> = Vec::new();
3125    let mut higher_keys = std::collections::HashSet::new();
3126    for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
3127        higher_keys.insert(key);
3128        parts.push(span.to_string());
3129    }
3130    for (key, span) in raw_query_pairs(header_query)? {
3131        validate_raw_query_span(span)?;
3132        if !higher_keys.contains(key.as_str()) {
3133            parts.push(span.to_string());
3134        }
3135    }
3136    if parts.is_empty() {
3137        return Ok(None);
3138    }
3139    Ok(Some(parts.join("&")))
3140}
3141
3142/// Bytes that may appear unescaped in a URI query component. RFC 3986
3143/// (`query = *( pchar / "/" / "?" )`) admits unreserved, sub-delims, `:`,
3144/// `@`, `/`, `?`, and `%` — with ONE deliberate exclusion from the RFC set:
3145/// the apostrophe (`'`, 0x27). reqwest's WHATWG URL parser re-encodes 0x27
3146/// to `%27` in the special-query percent-encode set (http/https), so an
3147/// authored apostrophe can never ride the wire verbatim; admitting it would
3148/// silently normalize authored bytes (rc-nmupb). Authors write `%27`
3149/// explicitly when they mean the byte on the wire. The WHATWG set's other
3150/// extras (`"`, `` ` ``, `<`, `>`) are already rejected here — they are not
3151/// RFC 3986 query-legal bytes, so no special exclusion is needed for them.
3152fn is_legal_query_byte(byte: u8) -> bool {
3153    matches!(byte,
3154        b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
3155        | b'-' | b'.' | b'_' | b'~'
3156        | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
3157        | b':' | b'@' | b'/' | b'?'
3158        | b'%')
3159}
3160
3161/// Reject an authored raw pair carrying a byte that is not legal in a query
3162/// component (e.g. literal space, `#`, non-ASCII). The serializer never
3163/// silently re-encodes operator-authored bytes: "byte-for-byte" is bounded
3164/// to wire-legal bytes, and the check fires before the resolved string
3165/// reaches any consumer (SSRF pre-check, diagnostics redaction).
3166fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
3167    for &byte in span.as_bytes() {
3168        if !is_legal_query_byte(byte) {
3169            return Err(CamelError::ProcessorError(format!(
3170                "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
3171            )));
3172        }
3173    }
3174    Ok(())
3175}
3176
3177/// Minimal RFC-3986 percent-encoding for one programmatic query component:
3178/// unreserved bytes pass through, every other byte encodes as uppercase
3179/// hex. A space encodes as `%20`, never `+`.
3180fn encode_query_component(component: &str) -> String {
3181    const HEX: &[u8; 16] = b"0123456789ABCDEF";
3182    let mut out = String::with_capacity(component.len());
3183    for &byte in component.as_bytes() {
3184        match byte {
3185            b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3186                out.push(byte as char);
3187            }
3188            _ => {
3189                out.push('%');
3190                out.push(HEX[(byte >> 4) as usize] as char);
3191                out.push(HEX[(byte & 0x0f) as usize] as char);
3192            }
3193        }
3194    }
3195    out
3196}
3197
3198/// Redact credentials from a URL before it reaches logs or error values
3199/// (ADR-0051 redact-by-construction). Masks userinfo (`user:pass@`) and
3200/// the query string (which commonly carries API keys/tokens). Host and
3201/// path stay visible for diagnosability. Fragments are never echoed: a
3202/// fragment (OAuth2 callback tokens such as `#access_token=...`) is
3203/// dropped and replaced with the `#[redacted]` sentinel in both the
3204/// parsed arm and the unparseable arm. Fail-closed: when the parse fails
3205/// and any authority window contains `@`, only the `[redacted]`
3206/// sentinel is returned. Every authority window is scanned: windows are
3207/// enumerated over maximal runs of `/` and `\` — pure-slash runs of two
3208/// or more characters, backslash-bearing runs only behind an RFC 3986
3209/// scheme prefix (see [`camel_api::redact`] for the canonical window
3210/// rule) — each window starts immediately after the run (so evaders like
3211/// `scheme:////user:pass@evil/` cannot hide a `@` behind a slash run)
3212/// and ends at the next `/`, `?`, or `#`; scanning all windows keeps
3213/// later `//user:pass@` substrings from hiding behind a benign first
3214/// window.
3215///
3216/// The parsed arm keeps `url::Url::parse` (the authority can only be
3217/// judged by the parser) and masks the real authority accessors, then
3218/// delegates wholesale to the canonical string surgery in
3219/// [`camel_api::redact::redact_url`]: rust-url can park later-window
3220/// userinfo bytes in the path (`https://h//user:pass@evil/`), and the
3221/// canonical helper owns window masking, `?`/`#` sentinel composition
3222/// (one per distinct introducer, first-occurrence order), and the
3223/// 256-byte UTF-8 cap. The unparseable arm delegates to
3224/// [`camel_api::redact::redact_url_fail_closed`].
3225pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3226    match url::Url::parse(raw) {
3227        Ok(mut u) => {
3228            // Fail closed when an authority marker was accepted but no
3229            // host was stored: userinfo-shaped bytes can hide in the path
3230            // behind the marker, and empty-host schemes (`file:///us@r/x`,
3231            // `unix:///@socket`) can put a `@` in that window too. Such
3232            // inputs are sentineled wholesale — deliberate fail-closed
3233            // over-redaction per ADR-0051.
3234            if !u.cannot_be_a_base()
3235                && u.host_str().is_none()
3236                && camel_api::redact::window_has_at_sign(raw)
3237            {
3238                return "[redacted]".to_string();
3239            }
3240            if !u.username().is_empty() || u.password().is_some() {
3241                let _ = u.set_username("***");
3242                let _ = u.set_password(None);
3243            }
3244            // Query and fragment stay on the rendered URL; the canonical
3245            // redactor drops them and composes the sentinels.
3246            let s = u.to_string();
3247            camel_api::redact::redact_url(&s)
3248        }
3249        Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3250    }
3251}
3252
3253/// Maximum bytes of an upstream error response body embedded into
3254/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
3255/// malicious or compromised upstream), so it is truncated and lossy-decoded to
3256/// bound log injection / DLQ payload size.
3257const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3258
3259fn truncate_error_body(body: &[u8]) -> String {
3260    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3261        String::from_utf8_lossy(body).into_owned()
3262    } else {
3263        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3264        s.push_str("...[truncated]");
3265        s
3266    }
3267}
3268
3269impl HttpProducer {
3270    /// Whether the HTTP method is entity-enclosing (may carry a request
3271    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
3272    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
3273    /// §9.3.1/§9.3.2).
3274    fn is_entity_enclosing(method: &str) -> bool {
3275        matches!(method, "POST" | "PUT" | "PATCH")
3276    }
3277}
3278
3279impl Service<Exchange> for HttpProducer {
3280    type Response = Exchange;
3281    type Error = CamelError;
3282    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3283
3284    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3285        Poll::Ready(Ok(()))
3286    }
3287
3288    fn call(&mut self, exchange: Exchange) -> Self::Future {
3289        let config = self.config.clone();
3290        let shared_client = self.client.clone();
3291        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3292        let http_config = self.http_config.clone();
3293        let component_metrics = self.runtime.component_metrics();
3294
3295        Box::pin(async move {
3296            let mut exchange = exchange;
3297            let outcome = async {
3298                let method_str = HttpProducer::resolve_method(&exchange, &config);
3299                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
3300                // and PATCH may carry a request body. Any other resolved method
3301                // drops the exchange body before the request is built (Apache
3302                // Camel `HttpMethods` parity).
3303                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3304                let url = HttpProducer::resolve_url(&exchange, &config)?;
3305
3306                // SECURITY: Validate URL for SSRF
3307                ssrf::validate_url_for_ssrf(&url, &config)?;
3308
3309                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
3310                // (L-H2). When the URL uses a domain name and SSRF protection is active,
3311                // reuse the endpoint's cached DNS-pinned client for that validated
3312                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
3313                // repeated requests keep one connection pool without re-resolving DNS.
3314                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
3315                // URLs use the endpoint's unpinned shared client.
3316                let resolved = ssrf::resolve_initial_url_for_ssrf(
3317                    &url,
3318                    config.allow_internal,
3319                    config.allow_cleartext,
3320                )
3321                .await?;
3322                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3323                    pinned_cache
3324                        .get_or_build(host.as_str(), addrs, || {
3325                            build_client(&http_config, Some((host.as_str(), addrs)))
3326                        })
3327                        .await
3328                } else {
3329                    shared_client.clone()
3330                };
3331
3332                debug!(
3333                    correlation_id = %exchange.correlation_id(),
3334                    method = %method_str,
3335                    url = %redact_url_for_diagnostics(&url),
3336                    "HTTP request"
3337                );
3338
3339                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3340                    CamelError::ProcessorError(format!(
3341                        "Invalid HTTP method '{}': {}",
3342                        method_str, e
3343                    ))
3344                })?;
3345
3346                // Collect headers for potential redirect replay
3347                let mut collected_headers: Vec<(
3348                    reqwest::header::HeaderName,
3349                    reqwest::header::HeaderValue,
3350                )> = Vec::new();
3351
3352                if let Some(user_agent) = &config.user_agent
3353                    && !config.bridge_endpoint
3354                {
3355                    match constructed_header("user-agent", user_agent) {
3356                        Ok((_, val)) => {
3357                            collected_headers.push((reqwest::header::USER_AGENT, val));
3358                        }
3359                        Err(drop) => debug!(
3360                            correlation_id = %exchange.correlation_id(),
3361                            header = %drop.name,
3362                            "outbound header dropped: {}",
3363                            drop.reason
3364                        ),
3365                    }
3366                }
3367
3368                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
3369                #[cfg(feature = "otel")]
3370                let should_inject_otel = !config.bridge_endpoint;
3371                #[cfg(feature = "otel")]
3372                if should_inject_otel {
3373                    let mut otel_headers = HashMap::new();
3374                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3375                    for (k, v) in otel_headers {
3376                        match constructed_header(&k, &v) {
3377                            Ok((name, val)) => collected_headers.push((name, val)),
3378                            Err(drop) => debug!(
3379                                correlation_id = %exchange.correlation_id(),
3380                                header = %drop.name,
3381                                "outbound header dropped: {}",
3382                                drop.reason
3383                            ),
3384                        }
3385                    }
3386                }
3387
3388                let conn_tokens = header_policy::connection_tokens(
3389                    exchange
3390                        .input
3391                        .headers
3392                        .iter()
3393                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3394                        .filter_map(|(_, v)| v.as_str()),
3395                );
3396
3397                let outbound = select_outbound_headers(
3398                    &exchange.input.headers,
3399                    &config.skip_request_headers,
3400                    &conn_tokens,
3401                );
3402                for drop in &outbound.drops {
3403                    if let Some(value_kind) = drop.value_kind {
3404                        debug!(
3405                            correlation_id = %exchange.correlation_id(),
3406                            header = %drop.name,
3407                            value_kind = value_kind,
3408                            "outbound header dropped: {}",
3409                            drop.reason
3410                        );
3411                    } else {
3412                        debug!(
3413                            correlation_id = %exchange.correlation_id(),
3414                            header = %drop.name,
3415                            "outbound header dropped: {}",
3416                            drop.reason
3417                        );
3418                    }
3419                }
3420                collected_headers.extend(outbound.accepted);
3421
3422                // Auth headers
3423                if !config.bridge_endpoint {
3424                    match &config.auth {
3425                        HttpAuth::None => {}
3426                        HttpAuth::Basic { username, password } => {
3427                            use base64::Engine;
3428                            // allow-secret: credentials combined for base64 Basic auth header
3429                            let credentials = format!("{username}:{password}");
3430                            let encoded =
3431                                base64::engine::general_purpose::STANDARD.encode(credentials);
3432                            // Base64 output is always header-safe; the guard is kept
3433                            // for uniformity with Bearer.
3434                            match constructed_header("authorization", &format!("Basic {encoded}")) {
3435                                Ok((_, val)) => {
3436                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3437                                }
3438                                Err(drop) => debug!(
3439                                    correlation_id = %exchange.correlation_id(),
3440                                    header = %drop.name,
3441                                    "outbound header dropped: {}",
3442                                    drop.reason
3443                                ),
3444                            }
3445                        }
3446                        HttpAuth::Bearer { token } => {
3447                            // allow-secret: Bearer token in Authorization header
3448                            let bearer = format!("Bearer {token}");
3449                            match constructed_header("authorization", &bearer) {
3450                                Ok((_, val)) => {
3451                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3452                                }
3453                                Err(drop) => debug!(
3454                                    correlation_id = %exchange.correlation_id(),
3455                                    header = %drop.name,
3456                                    "outbound header dropped: {}",
3457                                    drop.reason
3458                                ),
3459                            }
3460                        }
3461                    }
3462
3463                    if config.connection_close {
3464                        collected_headers.push((
3465                            reqwest::header::CONNECTION,
3466                            reqwest::header::HeaderValue::from_static("close"),
3467                        ));
3468                    }
3469                }
3470
3471                // Materialize body
3472                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3473                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3474                    if suppress_body {
3475                        // A stream body dropped under a non-entity-enclosing
3476                        // method always warns (its emptiness is unknowable) and
3477                        // stays consumed (mem::take). The stream attach arm below
3478                        // still runs its outer flag check, but the inner `if let
3479                        // Body::Stream` re-match fails on the now-Empty body, so
3480                        // no stream is attached and no AlreadyConsumed error can
3481                        // fire.
3482                        std::mem::take(&mut exchange.input.body);
3483                        // log-policy: handler-owned
3484                        tracing::warn!(
3485                            correlation_id = %exchange.correlation_id(),
3486                            method = %method_str,
3487                            "dropping request body for non-entity-enclosing HTTP method"
3488                        );
3489                    }
3490                    None // Streams can't be replayed on redirect
3491                } else {
3492                    let body = std::mem::take(&mut exchange.input.body);
3493                    let bytes = body.into_bytes(config.max_body_size).await?;
3494                    if bytes.is_empty() {
3495                        // Empty body: nothing to send and nothing to warn about.
3496                        None
3497                    } else if suppress_body {
3498                        // log-policy: handler-owned
3499                        tracing::warn!(
3500                            correlation_id = %exchange.correlation_id(),
3501                            method = %method_str,
3502                            "dropping request body for non-entity-enclosing HTTP method"
3503                        );
3504                        None
3505                    } else {
3506                        Some(bytes.to_vec())
3507                    }
3508                };
3509
3510                let response = if config.follow_redirects && !is_stream_body {
3511                    // Use manual redirect loop with per-hop SSRF validation.
3512                    // `client` is the pinned-or-shared binding for the initial
3513                    // request (a hostname initial request keeps its DNS-pinned
3514                    // client); `shared_client` is the unpinned endpoint client
3515                    // reused by IP-literal redirect hops.
3516                    ssrf::send_with_ssrf_safe_redirects(
3517                        &client,
3518                        &shared_client,
3519                        &pinned_cache,
3520                        &http_config,
3521                        &config,
3522                        method,
3523                        &url,
3524                        collected_headers,
3525                        materialized_body,
3526                        config.max_redirects,
3527                        config.response_timeout,
3528                    )
3529                    .await?
3530                } else {
3531                    // Direct send (no redirect following, or streaming body)
3532                    let mut request = client.request(method, &url);
3533
3534                    if let Some(timeout) = config.response_timeout {
3535                        request = request.timeout(timeout);
3536                    }
3537
3538                    for (name, value) in &collected_headers {
3539                        request = request.header(name, value);
3540                    }
3541
3542                    if is_stream_body {
3543                        if let Body::Stream(ref s) = exchange.input.body {
3544                            let mut stream_lock = s.stream.lock().await;
3545                            if let Some(stream) = stream_lock.take() {
3546                                request = request.body(reqwest::Body::wrap_stream(stream));
3547                            } else {
3548                                return Err(CamelError::AlreadyConsumed);
3549                            }
3550                        }
3551                    } else if let Some(ref body_bytes) = materialized_body {
3552                        request = request.body(body_bytes.clone());
3553                    }
3554
3555                    request.send().await.map_err(|e| {
3556                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3557                    })?
3558                };
3559
3560                let status_code = response.status().as_u16();
3561                let status_text = response
3562                    .status()
3563                    .canonical_reason()
3564                    .unwrap_or("Unknown")
3565                    .to_string();
3566
3567                for (key, value) in response.headers() {
3568                    if config
3569                        .skip_response_headers
3570                        .iter()
3571                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3572                    {
3573                        continue;
3574                    }
3575                    if let Ok(val_str) = value.to_str() {
3576                        exchange.input.set_header(
3577                            title_case_header(key.as_str()),
3578                            serde_json::Value::String(val_str.to_string()),
3579                        );
3580                    }
3581                }
3582
3583                exchange.input.set_header(
3584                    "CamelHttpResponseCode",
3585                    serde_json::Value::Number(status_code.into()),
3586                );
3587                exchange.input.set_header(
3588                    "CamelHttpResponseText",
3589                    serde_json::Value::String(status_text.clone()),
3590                );
3591
3592                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
3593                let read_timeout = Duration::from_millis(config.read_timeout_ms);
3594                let response_body = tokio::time::timeout(read_timeout, async {
3595                    // Check Content-Length header before allocating
3596                    if let Some(content_len) = response.content_length()
3597                        && content_len > config.max_response_bytes as u64
3598                    {
3599                        return Err(CamelError::ProcessorError(format!(
3600                            "Response body too large: {} bytes exceeds limit of {} bytes",
3601                            content_len, config.max_response_bytes
3602                        )));
3603                    }
3604                    // Use bytes_stream() for lazy streaming with size guard
3605                    use futures::TryStreamExt;
3606                    let mut stream = response.bytes_stream();
3607                    let mut total: usize = 0;
3608                    let mut collected = Vec::new();
3609                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
3610                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3611                    })? {
3612                        total += chunk.len();
3613                        if total > config.max_response_bytes {
3614                            return Err(CamelError::ProcessorError(format!(
3615                                "Response body too large: {} bytes exceeds limit of {} bytes",
3616                                total, config.max_response_bytes
3617                            )));
3618                        }
3619                        collected.push(chunk);
3620                    }
3621                    let mut result = bytes::BytesMut::with_capacity(total);
3622                    for chunk in collected {
3623                        result.extend_from_slice(&chunk);
3624                    }
3625                    Ok::<bytes::Bytes, CamelError>(result.freeze())
3626                })
3627                .await
3628                .map_err(|_| {
3629                    CamelError::ProcessorError(format!(
3630                        "Read timeout after {}ms",
3631                        config.read_timeout_ms
3632                    ))
3633                })??;
3634
3635                if config.throw_exception_on_failure
3636                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3637                {
3638                    return Err(CamelError::HttpOperationFailed {
3639                        method: method_str,
3640                        // ADR-0051 redact-by-construction: never embed
3641                        // userinfo/query credentials in the error value.
3642                        url: redact_url_for_diagnostics(&url),
3643                        status_code,
3644                        status_text,
3645                        response_body: Some(truncate_error_body(&response_body)),
3646                    });
3647                }
3648
3649                if !response_body.is_empty() {
3650                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3651                }
3652
3653                debug!(
3654                    correlation_id = %exchange.correlation_id(),
3655                    status = status_code,
3656                    url = %redact_url_for_diagnostics(&url),
3657                    "HTTP response"
3658                );
3659                Ok(exchange)
3660            }
3661            .await;
3662            // ("http","request") facade (dashboard-observability 4.3): the
3663            // request boundary is the full client round-trip — SSRF checks,
3664            // send, response read, and (with throwExceptionOnFailure) the
3665            // status gate. http runs no retry_async and the producer
3666            // previously emitted nothing, so no label collides with
3667            // e:http:request.
3668            component_metrics.observe("http", "request", outcome.is_err());
3669            outcome
3670        })
3671    }
3672}
3673
3674/// Serializes tests that mutate or depend on the global `ServerRegistry`.
3675///
3676/// `ServerRegistry::global()` is a process-wide singleton that persists
3677/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
3678/// with another test that has a live server on a fixed port (e.g. 9991),
3679/// the registry entry is removed while the OS socket is still bound, so
3680/// the next `get_or_spawn` call on that port fails with "Address already
3681/// in use". This mutex does not give blanket protection by itself. It
3682/// helps only where every participant follows the mutex law: the
3683/// consumer-test readiness helper holds it from `stage_listener` until
3684/// readiness-complete (http-test-harness spec, requirement
3685/// "Registry-mutation serialization during setup"), and each `reset()`
3686/// caller takes it before the reset.
3687#[cfg(test)]
3688pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3689
3690/// Poison-recovering acquire of REGISTRY_TEST_MUTEX (httpflake).
3691///
3692/// The mutex guards test SERIALIZATION only - the registry own data is
3693/// protected by its inner lock - so a sibling test that panics while
3694/// holding the guard must not poison the mutex and cascade failures
3695/// into every other holder. Recovery via into_inner is therefore safe
3696/// and keeps one failing test failing as ONE test.
3697#[cfg(test)]
3698pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3699    REGISTRY_TEST_MUTEX
3700        .lock()
3701        .unwrap_or_else(|poisoned| poisoned.into_inner())
3702}
3703
3704/// Serializes tests that mutate (or assert on) the process-global
3705/// SSL_CERT_FILE/SSL_CERT_DIR CA-probe env vars (rc-3j4mq). Poison-
3706/// recovering for the same reason as REGISTRY_TEST_MUTEX.
3707#[cfg(test)]
3708static CA_STORE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3709
3710#[cfg(test)]
3711pub(crate) fn lock_ca_store_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3712    CA_STORE_TEST_MUTEX
3713        .lock()
3714        .unwrap_or_else(|poisoned| poisoned.into_inner())
3715}
3716
3717/// Map a pipeline error to an HTTP reply.
3718///
3719/// Extracted from the inline `match` in `dispatch_handler` for unit
3720/// testability (rc-1dk4). Client-fault errors map to their 4xx codes
3721/// with a structured JSON error body: `TypeConversionFailed`/
3722/// `ValidationError` → 400, `UnsupportedMediaType` → 415 (media
3723/// negotiation gate, REST lowering), `NotAcceptable` → 406 (same
3724/// gate); `Unauthenticated`/`Unauthorized` keep their `401`/`403`
3725/// mappings; all other errors map to `500 Internal Server Error`.
3726fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3727    match e {
3728        CamelError::Unauthenticated(msg) => {
3729            tracing::warn!(error = %msg, path = %path, "Authentication failed");
3730            HttpReply {
3731                status: 401,
3732                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3733                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3734            }
3735        }
3736        CamelError::Unauthorized(msg) => {
3737            tracing::warn!(error = %msg, path = %path, "Authorization failed");
3738            HttpReply {
3739                status: 403,
3740                headers: vec![],
3741                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3742            }
3743        }
3744        CamelError::TypeConversionFailed(msg) => {
3745            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3746            json_error_reply(400, "bad_request", msg)
3747        }
3748        CamelError::ValidationError(msg) => {
3749            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3750            json_error_reply(400, "validation_error", msg)
3751        }
3752        CamelError::ConsumerStopping => {
3753            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3754            HttpReply {
3755                status: 503,
3756                headers: vec![],
3757                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3758            }
3759        }
3760        CamelError::UnsupportedMediaType { consumed, declared } => {
3761            tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3762            json_error_reply(
3763                415,
3764                "unsupported_media_type",
3765                format!("consumed {consumed}, declared {declared}"),
3766            )
3767        }
3768        CamelError::NotAcceptable { accept, produced } => {
3769            tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3770            json_error_reply(
3771                406,
3772                "not_acceptable",
3773                format!("accept {accept}, produced {produced}"),
3774            )
3775        }
3776        e => {
3777            // log-policy: handler-owned
3778            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3779            HttpReply {
3780                status: 500,
3781                headers: vec![],
3782                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3783            }
3784        }
3785    }
3786}
3787
3788/// Build a JSON error reply with the given status, error code, and message.
3789///
3790/// Shared by the `TypeConversionFailed`/`ValidationError` (400),
3791/// `UnsupportedMediaType` (415), and `NotAcceptable` (406) arms of
3792/// `pipeline_error_to_reply` so the four replies cannot drift apart. The
3793/// `unwrap_or_else(|_| "{}".to_string())` fallback keeps the reply valid
3794/// JSON even if serialization fails.
3795fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3796    let body = serde_json::to_string(&serde_json::json!({
3797        "error": code,
3798        "message": message,
3799    }))
3800    .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3801    HttpReply {
3802        status,
3803        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3804        body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3805    }
3806}
3807
3808/// Lowercase kind name for a JSON value, used in drop diagnostics so log
3809/// readers see *why* a header had no scalar string form without the value
3810/// itself ever entering diagnostics.
3811const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3812    match v {
3813        serde_json::Value::Null => "null",
3814        serde_json::Value::Bool(_) => "bool",
3815        serde_json::Value::Number(_) => "number",
3816        serde_json::Value::String(_) => "string",
3817        serde_json::Value::Array(_) => "array",
3818        serde_json::Value::Object(_) => "object",
3819    }
3820}
3821
3822/// Scalar string form of a JSON value: strings pass through, `Number` and
3823/// `Bool` are stringified, everything else has no single-value form.
3824/// Shared by the consumer reply finaliser and the producer outbound filter
3825/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
3826fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3827    match v {
3828        serde_json::Value::String(s) => Some(s.clone()),
3829        serde_json::Value::Number(n) => Some(n.to_string()),
3830        serde_json::Value::Bool(b) => Some(b.to_string()),
3831        _ => None,
3832    }
3833}
3834
3835/// Select the HTTP response headers emitted by the consumer reply finaliser
3836/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
3837/// `dispatch_handler` for unit testability.
3838///
3839/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
3840/// server-owned headers, plus `content-length`/`content-type` (re-derived),
3841/// and any header named by a `Connection` token. Scalar non-string values
3842/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3843/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
3844/// and arrays have no single-value form and are dropped. Every drop is
3845/// logged at DEBUG with the header name and reason — names only, never
3846/// values, so credentials cannot leak into diagnostics (ADR-0051).
3847/// Appends a single `Content-Type` from `user_content_type` falling back to
3848/// `inferred_content_type` when either is present.
3849fn select_response_headers(
3850    headers: &HashMap<String, serde_json::Value>,
3851    user_content_type: Option<String>,
3852    inferred_content_type: Option<String>,
3853) -> Vec<(String, String)> {
3854    let conn_tokens = header_policy::connection_tokens(
3855        headers
3856            .iter()
3857            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3858            .filter_map(|(_, v)| v.as_str()),
3859    );
3860    let mut selected: Vec<(String, String)> = Vec::new();
3861    for (k, v) in headers {
3862        if k.starts_with("Camel") {
3863            debug!(header = %k, "reply header dropped: Camel namespace");
3864            continue;
3865        }
3866        if header_policy::excluded_response(k, &conn_tokens) {
3867            debug!(header = %k, "reply header dropped: emission policy");
3868            continue;
3869        }
3870        match scalar_string_form(v) {
3871            Some(s) => selected.push((k.clone(), s)),
3872            None => debug!(
3873                header = %k,
3874                value_kind = json_value_kind(v),
3875                "reply header dropped: no scalar string form"
3876            ),
3877        }
3878    }
3879    if let Some(ct) = user_content_type.or(inferred_content_type) {
3880        selected.push(("Content-Type".to_string(), ct));
3881    }
3882    selected
3883}
3884
3885/// One outbound header drop: the exchange header name, a stable reason
3886/// string, and — when the drop was caused by the value having no scalar
3887/// string form — the JSON value kind. Names and kinds only, never values
3888/// (ADR-0051).
3889#[derive(Debug)]
3890struct OutboundHeaderDrop<'a> {
3891    name: &'a str,
3892    reason: &'static str,
3893    value_kind: Option<&'static str>,
3894}
3895
3896/// Outbound exchange-header selection result: headers accepted for the
3897/// wire plus drop records for call-site DEBUG logging.
3898struct OutboundHeaderSelection<'a> {
3899    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3900    drops: Vec<OutboundHeaderDrop<'a>>,
3901}
3902
3903/// Select the exchange headers the HTTP producer forwards on the outbound
3904/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
3905/// `HttpProducer::call` for unit testability.
3906///
3907/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
3908/// hop-by-hop/framing and connection-token-named headers excluded by the
3909/// outbound emission policy, and headers whose name or stringified value
3910/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
3911/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3912/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
3913/// and arrays have no single-value form and are dropped. Drops are returned
3914/// rather than logged so the call site can attach the correlation id; log
3915/// consumers see names and kinds only, never values (ADR-0051).
3916fn select_outbound_headers<'a>(
3917    headers: &'a HashMap<String, serde_json::Value>,
3918    skip_request_headers: &[String],
3919    conn_tokens: &[String],
3920) -> OutboundHeaderSelection<'a> {
3921    let mut accepted = Vec::new();
3922    let mut drops = Vec::new();
3923    for (key, value) in headers {
3924        if key.starts_with("Camel") {
3925            drops.push(OutboundHeaderDrop {
3926                name: key,
3927                reason: "Camel namespace",
3928                value_kind: None,
3929            });
3930            continue;
3931        }
3932        if skip_request_headers
3933            .iter()
3934            .any(|h| h.eq_ignore_ascii_case(key))
3935        {
3936            drops.push(OutboundHeaderDrop {
3937                name: key,
3938                reason: "skip_request_headers",
3939                value_kind: None,
3940            });
3941            continue;
3942        }
3943        if header_policy::excluded_outbound(key, conn_tokens) {
3944            drops.push(OutboundHeaderDrop {
3945                name: key,
3946                reason: "outbound emission policy",
3947                value_kind: None,
3948            });
3949            continue;
3950        }
3951        let Some(val_str) = scalar_string_form(value) else {
3952            drops.push(OutboundHeaderDrop {
3953                name: key,
3954                reason: "no scalar string form",
3955                value_kind: Some(json_value_kind(value)),
3956            });
3957            continue;
3958        };
3959        match constructed_header(key, &val_str) {
3960            Ok((name, val)) => accepted.push((name, val)),
3961            Err(drop) => drops.push(drop),
3962        }
3963    }
3964    OutboundHeaderSelection { accepted, drops }
3965}
3966
3967/// Construct a wire-ready `(HeaderName, HeaderValue)` pair for one outbound
3968/// header, or a drop record when the name or value fails construction
3969/// (rc-jbs1v). Drop records carry name and reason only, never values
3970/// (ADR-0051).
3971fn constructed_header<'a>(
3972    name: &'a str,
3973    value: &str,
3974) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3975    let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3976        Ok(header_name) => header_name,
3977        Err(_) => {
3978            return Err(OutboundHeaderDrop {
3979                name,
3980                reason: "invalid header name",
3981                value_kind: None,
3982            });
3983        }
3984    };
3985    let header_value = match reqwest::header::HeaderValue::from_str(value) {
3986        Ok(header_value) => header_value,
3987        Err(_) => {
3988            return Err(OutboundHeaderDrop {
3989                name,
3990                reason: "invalid header value",
3991                value_kind: None,
3992            });
3993        }
3994    };
3995    Ok((header_name, header_value))
3996}
3997
3998#[cfg(test)]
3999mod tests {
4000    use camel_component_api::test_support::NoopRuntimeObservability;
4001
4002    #[test]
4003    fn test_metadata_authmethod_enum_covers_runtime_vocab() {
4004        // The lint's R-URI-known kind check validates `authMethod` values
4005        // against the metadata Enum variants; the runtime accepts `none`
4006        // (case-insensitive, parse_auth_from_params → HttpAuth::None), so
4007        // the variant list must cover it or camel lint false-positives on
4008        // runtime-legal routes (rc-68q6 review finding).
4009        use camel_api::component_metadata::OptionKind;
4010        let meta = super::HttpEndpointConfig::metadata();
4011        let auth = meta
4012            .uri_options
4013            .iter()
4014            .find(|o| o.name == "authMethod")
4015            .expect("authMethod option must exist");
4016        let OptionKind::Enum(variants) = &auth.kind else {
4017            panic!("authMethod kind must be Enum; got {:?}", auth.kind);
4018        };
4019        for value in ["None", "Basic", "Bearer"] {
4020            assert!(
4021                variants.iter().any(|v| v.eq_ignore_ascii_case(value)),
4022                "metadata authMethod variants must cover runtime-accepted `{value}`; got {variants:?}"
4023            );
4024        }
4025    }
4026
4027    // Producer/consumer tests drive the component-ops facade on every
4028    // call (dashboard-observability 4.3), so even non-observability tests
4029    // must supply a collector-returning runtime — Noop everywhere.
4030    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4031        std::sync::Arc::new(NoopRuntimeObservability)
4032    }
4033    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4034        std::sync::Arc::new(NoopRuntimeObservability)
4035    }
4036    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4037        std::sync::Arc::new(NoopRuntimeObservability)
4038    }
4039
4040    use super::*;
4041    use crate::config::TlsConfig;
4042    use crate::rest_match::PathSegment;
4043    use camel_component_api::{Message, NoOpComponentContext};
4044    use std::sync::Arc;
4045    use std::time::Duration;
4046
4047    fn test_producer_ctx() -> ProducerContext {
4048        ProducerContext::new()
4049    }
4050
4051    // -----------------------------------------------------------------------
4052    // Security: credential redaction (audit 2026-08-31, finding F3-1)
4053    // -----------------------------------------------------------------------
4054
4055    /// ADR-0076: bare `host` log fields route through the canonical
4056    /// [`camel_api::redact::redact_host`] (bd rc-8bxeo promoted the
4057    /// crate-local twin — `redact_url_for_diagnostics` never opens an
4058    /// authority window on a base-less string). Thin local pin — the
4059    /// full matrix lives in camel-api's
4060    /// `redact_host_masks_userinfo_keeps_clean_hosts`.
4061    #[test]
4062    fn canonical_redact_host_pinned() {
4063        assert_eq!(
4064            camel_api::redact::redact_host("host.example:8080"),
4065            "host.example:8080"
4066        );
4067        assert_eq!(camel_api::redact::redact_host("a@b@c"), "***@c");
4068    }
4069
4070    #[test]
4071    fn redact_url_drops_oauth2_fragment_access_token() {
4072        let redacted =
4073            redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
4074        assert!(
4075            !redacted.contains("SECRET"),
4076            "fragment access token leaked: {redacted}"
4077        );
4078        assert!(
4079            !redacted.contains("access_token"),
4080            "fragment key leaked: {redacted}"
4081        );
4082        assert!(
4083            redacted.ends_with("#[redacted]"),
4084            "fragment must be replaced with the sentinel: {redacted}"
4085        );
4086    }
4087
4088    #[test]
4089    fn redact_url_drops_oauth2_fragment_id_token() {
4090        let redacted =
4091            redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
4092        assert!(
4093            !redacted.contains("eyJhbG"),
4094            "id token payload leaked: {redacted}"
4095        );
4096        assert!(
4097            !redacted.contains("id_token"),
4098            "id token key leaked: {redacted}"
4099        );
4100        assert!(
4101            !redacted.contains("SECRET"),
4102            "id token signature leaked: {redacted}"
4103        );
4104        assert!(
4105            redacted.ends_with("#[redacted]"),
4106            "fragment must be replaced with the sentinel: {redacted}"
4107        );
4108    }
4109
4110    #[test]
4111    fn redact_url_drops_generic_fragment_kv() {
4112        let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
4113        assert!(
4114            !redacted.contains("abc123"),
4115            "fragment value leaked: {redacted}"
4116        );
4117        assert!(
4118            !redacted.contains("session="),
4119            "fragment key leaked: {redacted}"
4120        );
4121        assert!(
4122            redacted.contains("#[redacted]"),
4123            "fragment must be replaced with the sentinel: {redacted}"
4124        );
4125    }
4126
4127    #[test]
4128    fn redact_url_query_and_fragment_sentinels_compose() {
4129        let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
4130        assert_eq!(
4131            redacted, "https://h.example/p?[redacted]#[redacted]",
4132            "query and fragment sentinels must compose: {redacted}"
4133        );
4134    }
4135
4136    #[test]
4137    fn redact_url_drops_benign_fragment_too() {
4138        // Fragments never reach the wire, so nothing in them is diagnostic:
4139        // strictest-wins drops benign fragments too.
4140        let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
4141        assert_eq!(
4142            redacted, "https://h.example/docs#[redacted]",
4143            "benign fragment must still be dropped: {redacted}"
4144        );
4145    }
4146
4147    #[test]
4148    fn redact_url_unparseable_fragment_credentials_dropped() {
4149        let raw = "ht tps://app.example/cb#access_token=SECRET";
4150        assert!(
4151            url::Url::parse(raw).is_err(),
4152            "fixture must be unparseable: {raw}"
4153        );
4154        let redacted = redact_url_for_diagnostics(raw);
4155        assert!(
4156            !redacted.contains("SECRET"),
4157            "unparseable fragment token leaked: {redacted}"
4158        );
4159        assert!(
4160            !redacted.contains("access_token"),
4161            "unparseable fragment bytes leaked: {redacted}"
4162        );
4163        assert!(
4164            redacted.contains("#[redacted]"),
4165            "unparseable fragment must end in the sentinel: {redacted}"
4166        );
4167    }
4168
4169    #[test]
4170    fn redact_url_double_slash_evader_sentinel() {
4171        // url::Url::parse accepts this (empty host allowed for non-special
4172        // schemes), parking userinfo-shaped bytes in the opaque path.
4173        let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
4174        assert_eq!(
4175            redacted, "[redacted]",
4176            "double-slash evader must fail closed: {redacted}"
4177        );
4178    }
4179
4180    #[test]
4181    fn redact_url_triple_slash_evader_sentinel() {
4182        let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
4183        assert_eq!(
4184            redacted, "[redacted]",
4185            "triple-slash evader must fail closed: {redacted}"
4186        );
4187    }
4188
4189    #[test]
4190    fn redact_url_bare_protocol_relative_userinfo_sentinel() {
4191        let redacted = redact_url_for_diagnostics("//user:pass@evil");
4192        assert_eq!(
4193            redacted, "[redacted]",
4194            "protocol-relative userinfo must fail closed: {redacted}"
4195        );
4196    }
4197
4198    #[test]
4199    fn redact_url_empty_host_userinfo_sentinel() {
4200        // url::Url::parse rejects this with EmptyHost; the failure arm must
4201        // fail closed without panicking on the empty host.
4202        let redacted = redact_url_for_diagnostics("scheme://user@");
4203        assert_eq!(
4204            redacted, "[redacted]",
4205            "empty-host userinfo must fail closed: {redacted}"
4206        );
4207    }
4208
4209    #[test]
4210    fn redact_url_unparseable_slash_run_evader_sentinel() {
4211        // Unlike `scheme:////user:pass@evil/` (parses Ok, host=None, and
4212        // hits the parsed-arm guard), the space in the scheme forces the
4213        // parse to fail, driving the failure arm's slash-run skip directly.
4214        let raw = "schem e:////user:pass@evil/";
4215        assert!(
4216            url::Url::parse(raw).is_err(),
4217            "fixture must be unparseable: {raw}"
4218        );
4219        let redacted = redact_url_for_diagnostics(raw);
4220        assert_eq!(
4221            redacted, "[redacted]",
4222            "unparseable slash-run evader must fail closed: {redacted}"
4223        );
4224    }
4225
4226    #[test]
4227    fn redact_url_unparseable_later_window_userinfo_sentinel() {
4228        // The first `//` window ("ho st") carries no `@`, but a later
4229        // `//user:pass@evil/` window does. The scan must consider every
4230        // `//` window, not just the first, or the credentials echo.
4231        let raw = "http://ho st/a//user:pass@evil/";
4232        assert!(
4233            url::Url::parse(raw).is_err(),
4234            "fixture must be unparseable: {raw}"
4235        );
4236        let redacted = redact_url_for_diagnostics(raw);
4237        assert_eq!(
4238            redacted, "[redacted]",
4239            "userinfo in a later // window must fail closed: {redacted}"
4240        );
4241    }
4242
4243    #[test]
4244    fn redact_url_parsed_later_window_userinfo_masked() {
4245        // rust-url accepts this with host `h` and parks the userinfo bytes
4246        // in the path, so the accessor mask never fires. The parsed arm
4247        // must apply the same window-masking surgery as the string-based
4248        // redactors or the later window renders verbatim.
4249        let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4250        assert!(
4251            !redacted.contains("user:pass"),
4252            "parsed later-window userinfo leaked: {redacted}"
4253        );
4254        assert!(
4255            redacted.contains("h//***@evil/"),
4256            "later window must be masked in place: {redacted}"
4257        );
4258    }
4259
4260    #[test]
4261    fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4262        // Real userinfo is masked by the accessor step; the window surgery
4263        // on the rendered string must not double-mask it (`***@h` stays),
4264        // and the later `x@y` path window must still be masked.
4265        let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4266        assert!(
4267            redacted.contains("***@h"),
4268            "accessor mask must survive the window surgery: {redacted}"
4269        );
4270        assert!(
4271            !redacted.contains("user:pass"),
4272            "real userinfo leaked: {redacted}"
4273        );
4274        assert!(
4275            !redacted.contains("x@y"),
4276            "later path window leaked: {redacted}"
4277        );
4278    }
4279
4280    #[test]
4281    fn redact_url_backslash_authority_ruling() {
4282        // Probe outcome: url::Url::parse accepts this input. http is a
4283        // special scheme, so backslashes normalize to slashes and the
4284        // credentials land in real userinfo
4285        // (`http://user:pass@evil/path`). The parsed arm must mask them
4286        // like any other userinfo.
4287        let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4288        assert!(
4289            redacted.contains("***@"),
4290            "backslash authority must be userinfo-masked: {redacted}"
4291        );
4292        assert!(
4293            !redacted.contains("user:pass"),
4294            "backslash authority must not leak credentials: {redacted}"
4295        );
4296    }
4297
4298    #[test]
4299    fn non_special_backslash_authority_masked() {
4300        // Non-special scheme: the url crate does not normalize the
4301        // backslashes, so the string carries no `//` run — the
4302        // scheme-prefixed backslash window must still suppress the
4303        // credentials.
4304        let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4305        assert!(
4306            !redacted.contains("user:pass"),
4307            "non-special backslash authority leaked: {redacted}"
4308        );
4309        assert!(
4310            !redacted.contains("pass"),
4311            "non-special backslash authority leaked a credential byte: {redacted}"
4312        );
4313        // Clean sibling stays visible (spec scenario's second given).
4314        assert_eq!(
4315            redact_url_for_diagnostics("foo:\\clean/path"),
4316            "foo:\\clean/path"
4317        );
4318    }
4319
4320    #[test]
4321    fn one_char_scheme_credential_content_masked() {
4322        // Single backslash after the one-character scheme `x:` with
4323        // credential-shaped window content (`:` before the last `@`).
4324        let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4325        assert!(
4326            !redacted.contains("user:pass"),
4327            "one-char-scheme backslash authority leaked: {redacted}"
4328        );
4329        assert!(
4330            !redacted.contains("pass"),
4331            "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4332        );
4333    }
4334
4335    #[test]
4336    fn drive_and_unc_inputs_stay_visible() {
4337        // Drive path: single backslash after a one-character scheme, no
4338        // `:` in the candidate window — no qualifying backslash window.
4339        // The parse-success arm lowercases the scheme (`C:` → `c:`); the
4340        // diagnostic content must stay visible with no sentinel and no
4341        // mask (spec scenario: query-redaction/cap rules only).
4342        let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4343        assert!(
4344            !drive.contains("[redacted]"),
4345            "drive path must not be sentineled: {drive}"
4346        );
4347        assert!(
4348            !drive.contains("***"),
4349            "drive path must not be masked: {drive}"
4350        );
4351        assert!(
4352            drive.contains("x@corp"),
4353            "drive path keeps its at-sign content visible: {drive}"
4354        );
4355        // UNC path: no scheme prefix before the backslash run; the
4356        // unparseable arm renders it byte-identically.
4357        let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4358        assert_eq!(unc, "\\\\server\\x@y");
4359        assert!(
4360            !unc.contains("[redacted]"),
4361            "UNC path must not be sentineled: {unc}"
4362        );
4363    }
4364
4365    #[test]
4366    fn redact_url_masks_userinfo_and_query() {
4367        let redacted =
4368            redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4369        assert!(
4370            !redacted.contains("secretpass"),
4371            "password must be masked: {redacted}"
4372        );
4373        assert!(
4374            !redacted.contains("token=abc123"),
4375            "query must be masked: {redacted}"
4376        );
4377        assert!(
4378            !redacted.contains("user@"),
4379            "username must be masked: {redacted}"
4380        );
4381        assert!(
4382            redacted.contains("internal.example"),
4383            "host stays visible: {redacted}"
4384        );
4385        assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4386    }
4387
4388    #[test]
4389    fn redact_url_keeps_clean_urls_visible() {
4390        let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4391        assert_eq!(redacted, "https://api.example.com/v1/items");
4392    }
4393
4394    #[test]
4395    fn redact_url_masks_password_only_userinfo() {
4396        let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4397        assert!(
4398            !redacted.contains("pwsecret"),
4399            "password-only userinfo leaked: {redacted}"
4400        );
4401        assert_eq!(redacted, "http://***@host.example/");
4402
4403        let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4404        assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4405        assert_eq!(redacted, "http://***@host.example/api");
4406
4407        let redacted = redact_url_for_diagnostics("http://host.example/api");
4408        assert_eq!(redacted, "http://host.example/api");
4409    }
4410
4411    #[test]
4412    fn redact_url_truncates_unparseable() {
4413        let long = "x".repeat(1000);
4414        let redacted = redact_url_for_diagnostics(&long);
4415        assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4416    }
4417
4418    /// e_gpt stage-4: the 256-byte cap must not split an appended sentinel.
4419    /// The base is truncated at `256 - sentinel_len` BEFORE the sentinel is
4420    /// appended, so the sentinel always renders intact and the total stays
4421    /// ≤ 256. Both arms (parsed and unparseable) are exercised.
4422    #[test]
4423    fn redact_url_keeps_sentinels_intact_under_256_cap() {
4424        // Parsed arm: base (scheme+host+path) is 250 bytes, so byte 256
4425        // lands inside the appended `?[redacted]` (starts at 250) pre-fix.
4426        let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4427        assert!(
4428            url::Url::parse(&parsed).is_ok(),
4429            "fixture must parse: {parsed}"
4430        );
4431        let redacted = redact_url_for_diagnostics(&parsed);
4432        assert!(redacted.len() <= 256, "len={}", redacted.len());
4433        assert!(
4434            redacted.ends_with("?[redacted]"),
4435            "parsed-arm sentinel must render intact: {redacted}"
4436        );
4437
4438        // Unparseable arm: base is 249 bytes, so byte 256 lands inside the
4439        // appended `?[redacted]` (starts at 249) pre-fix.
4440        let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4441        assert!(
4442            url::Url::parse(&unparseable).is_err(),
4443            "fixture must not parse: {unparseable}"
4444        );
4445        let redacted = redact_url_for_diagnostics(&unparseable);
4446        assert!(redacted.len() <= 256, "len={}", redacted.len());
4447        assert!(
4448            redacted.ends_with("?[redacted]"),
4449            "unparseable-arm sentinel must render intact: {redacted}"
4450        );
4451    }
4452
4453    #[test]
4454    fn redact_url_suppresses_unparseable_authority_credentials() {
4455        let fixtures = [
4456            "http://u:secretpw@/x",
4457            "http://u:secretpw@host:99999/x",
4458            "http://u:secretpw@host:99999",
4459            "//u:secretpw@h/x",
4460        ];
4461        for fixture in fixtures {
4462            assert!(
4463                url::Url::parse(fixture).is_err(),
4464                "fixture must be unparseable: {fixture}"
4465            );
4466            let redacted = redact_url_for_diagnostics(fixture);
4467            assert_eq!(
4468                redacted, "[redacted]",
4469                "credential-bearing authority must be suppressed: {fixture}"
4470            );
4471        }
4472    }
4473
4474    #[test]
4475    fn redact_url_bd_repro_never_leaks_credentials() {
4476        let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4477        assert!(
4478            !redacted.contains("user:pa%ss"),
4479            "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4480        );
4481        assert!(
4482            !redacted.contains("pa%ss"),
4483            "bd rc-2i5c5 repro leaked password: {redacted}"
4484        );
4485    }
4486
4487    #[test]
4488    fn redact_url_unparseable_query_redacted_short_and_long() {
4489        let short = "http://host:99999/path?token=shortsecret";
4490        assert!(
4491            url::Url::parse(short).is_err(),
4492            "fixture must be unparseable: {short}"
4493        );
4494        let redacted = redact_url_for_diagnostics(short);
4495        assert_eq!(
4496            redacted, "http://host:99999/path?[redacted]",
4497            "short unparseable query must end with the suffix: {redacted}"
4498        );
4499
4500        let mut long = String::from("http://host:99999/");
4501        long.push_str(&"a".repeat(300));
4502        long.push_str("?token=longsecret");
4503        assert!(
4504            url::Url::parse(&long).is_err(),
4505            "fixture must be unparseable: {long}"
4506        );
4507        let redacted = redact_url_for_diagnostics(&long);
4508        assert!(
4509            !redacted.contains("longsecret"),
4510            "long unparseable query leaked a query byte: {redacted}"
4511        );
4512        assert!(
4513            redacted.len() <= 256,
4514            "long unparseable query must be capped: {} bytes",
4515            redacted.len()
4516        );
4517    }
4518
4519    #[test]
4520    fn redact_url_unparseable_sentinels_compose_both() {
4521        // Compose-both rule: one sentinel per distinct introducer found in
4522        // the raw string, in first-occurrence order.
4523        let raw = "ht tp://h.example/p?a=1#tok=x";
4524        assert!(
4525            url::Url::parse(raw).is_err(),
4526            "fixture must be unparseable: {raw}"
4527        );
4528        assert_eq!(
4529            redact_url_for_diagnostics(raw),
4530            "ht tp://h.example/p?[redacted]#[redacted]",
4531            "query and fragment sentinels must compose: {raw}"
4532        );
4533    }
4534
4535    #[test]
4536    fn redact_url_unparseable_sentinels_compose_fragment_first() {
4537        let raw = "ht tp://h.example/p#tok=x?a=1";
4538        assert!(
4539            url::Url::parse(raw).is_err(),
4540            "fixture must be unparseable: {raw}"
4541        );
4542        assert_eq!(
4543            redact_url_for_diagnostics(raw),
4544            "ht tp://h.example/p#[redacted]?[redacted]",
4545            "sentinels must follow the introducers' first-occurrence order: {raw}"
4546        );
4547    }
4548
4549    #[test]
4550    fn redact_url_unparseable_utf8_straddle_no_panic() {
4551        let fixture = format!("a{}", "é".repeat(200));
4552        let redacted = redact_url_for_diagnostics(&fixture);
4553        assert!(
4554            redacted.len() <= 256,
4555            "straddle fixture must be capped: {} bytes",
4556            redacted.len()
4557        );
4558        assert!(
4559            redacted.len() >= 253,
4560            "straddle fixture must not over-truncate: {} bytes",
4561            redacted.len()
4562        );
4563        assert!(
4564            fixture.is_char_boundary(redacted.len()),
4565            "cut must land on a UTF-8 char boundary: {} bytes",
4566            redacted.len()
4567        );
4568    }
4569
4570    #[test]
4571    fn redact_url_at_sign_outside_authority_window_visible() {
4572        let at_sign_in_path = "http://host:99999/x@y";
4573        assert!(
4574            url::Url::parse(at_sign_in_path).is_err(),
4575            "fixture must be unparseable: {at_sign_in_path}"
4576        );
4577        assert_eq!(
4578            redact_url_for_diagnostics(at_sign_in_path),
4579            at_sign_in_path,
4580            "at-sign in path must not be suppressed"
4581        );
4582        // mailto parses as a cannot-be-a-base URL (no is_err precondition).
4583        assert_eq!(
4584            redact_url_for_diagnostics("mailto:user@example.com"),
4585            "mailto:user@example.com",
4586            "at-sign in mailto must round-trip byte-identically"
4587        );
4588    }
4589
4590    #[test]
4591    fn parse_success_fragment_composes() {
4592        // Parsed arm: the fragment stays on the rendered URL and the
4593        // canonical redactor drops it and appends the sentinel.
4594        assert_eq!(
4595            redact_url_for_diagnostics("https://h/p#access_token=x"),
4596            "https://h/p#[redacted]"
4597        );
4598        // A `?` inside the fragment composes both sentinels, in
4599        // first-occurrence order (# before ?).
4600        assert_eq!(
4601            redact_url_for_diagnostics("https://h/cb#f?state=x"),
4602            "https://h/cb#[redacted]?[redacted]"
4603        );
4604    }
4605
4606    #[test]
4607    fn err_arm_delegation_pin() {
4608        // Unparseable (port 99999) with userinfo in the authority window:
4609        // the Err arm delegates wholesale to the fail-closed canonical
4610        // redactor — nothing of the URL is rendered.
4611        assert_eq!(
4612            redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
4613            "[redacted]"
4614        );
4615        // Cross-surface fixture: same unparseable port without userinfo —
4616        // drop at `?`, append the query sentinel.
4617        assert_eq!(
4618            redact_url_for_diagnostics("http://h:99999/p?token=secret"),
4619            "http://h:99999/p?[redacted]"
4620        );
4621    }
4622
4623    #[test]
4624    fn truncate_error_body_caps_attacker_body() {
4625        let big = vec![b'A'; 10 * 1024 * 1024];
4626        let truncated = truncate_error_body(&big);
4627        assert!(
4628            truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
4629            "body must be capped near {} bytes, got {}",
4630            MAX_ERROR_RESPONSE_BODY_BYTES,
4631            truncated.len()
4632        );
4633        assert!(truncated.ends_with("...[truncated]"));
4634    }
4635
4636    #[test]
4637    fn truncate_error_body_keeps_small_body() {
4638        assert_eq!(truncate_error_body(b"boom"), "boom");
4639    }
4640
4641    #[test]
4642    fn test_http_config_defaults() {
4643        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
4644        assert_eq!(config.base_url, "http://localhost:8080/api");
4645        assert!(config.http_method.is_none());
4646        assert!(config.throw_exception_on_failure);
4647        assert_eq!(config.ok_status_code_range, (200, 299));
4648        assert!(config.response_timeout.is_none());
4649        assert!(matches!(config.auth, HttpAuth::None));
4650        assert!(!config.bridge_endpoint);
4651        assert!(!config.connection_close);
4652    }
4653
4654    #[test]
4655    fn test_http_config_scheme() {
4656        // UriConfig trait method returns "http" as primary scheme
4657        assert_eq!(HttpEndpointConfig::scheme(), "http");
4658    }
4659
4660    #[test]
4661    fn test_http_config_from_components() {
4662        // Test from_components directly (trait method)
4663        let components = camel_component_api::UriComponents {
4664            scheme: "https".to_string(),
4665            path: "//api.example.com/v1".to_string(),
4666            params: std::collections::HashMap::from([(
4667                "httpMethod".to_string(),
4668                "POST".to_string(),
4669            )]),
4670            raw_query: None,
4671        };
4672        let config = HttpEndpointConfig::from_components(components).unwrap();
4673        assert_eq!(config.base_url, "https://api.example.com/v1");
4674        assert_eq!(config.http_method, Some("POST".to_string()));
4675    }
4676
4677    #[test]
4678    fn test_http_config_with_options() {
4679        let config = HttpEndpointConfig::from_uri(
4680            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
4681        ).unwrap();
4682        assert_eq!(config.base_url, "https://api.example.com/v1");
4683        assert_eq!(config.http_method, Some("PUT".to_string()));
4684        assert!(!config.throw_exception_on_failure);
4685        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
4686    }
4687
4688    #[test]
4689    fn test_http_endpoint_config_auth_and_headers_options() {
4690        let config = HttpEndpointConfig::from_uri(
4691            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
4692        )
4693        .unwrap();
4694
4695        assert!(matches!(
4696            config.auth,
4697            HttpAuth::Basic { username, password } if username == "u" && password == "p"
4698        ));
4699        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
4700        assert!(config.bridge_endpoint);
4701        assert!(config.connection_close);
4702        assert_eq!(
4703            config.skip_request_headers,
4704            vec!["authorization".to_string(), "x-secret".to_string()]
4705        );
4706        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
4707    }
4708
4709    #[test]
4710    fn test_http_endpoint_config_bearer_auth() {
4711        let config = HttpEndpointConfig::from_uri(
4712            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
4713        )
4714        .unwrap();
4715        assert!(matches!(
4716            config.auth,
4717            HttpAuth::Bearer { token } if token == "t"
4718        ));
4719    }
4720
4721    #[test]
4722    fn rejects_cookie_handling_inmemory() {
4723        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
4724        match result {
4725            Err(CamelError::InvalidUri(msg)) => {
4726                assert!(
4727                    msg.contains("cookieHandling is not supported"),
4728                    "expected rejection message, got: {msg}"
4729                );
4730            }
4731            other => panic!("expected InvalidUri error, got: {other:?}"),
4732        }
4733    }
4734
4735    #[test]
4736    fn rejects_cookie_handling_disabled() {
4737        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
4738        match result {
4739            Err(CamelError::InvalidUri(msg)) => {
4740                assert!(
4741                    msg.contains("cookieHandling is not supported"),
4742                    "expected rejection message, got: {msg}"
4743                );
4744            }
4745            other => panic!("expected InvalidUri error, got: {other:?}"),
4746        }
4747    }
4748
4749    #[test]
4750    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
4751        let config = HttpConfig::default()
4752            .with_response_timeout_ms(999)
4753            .with_allow_internal(true)
4754            .with_blocked_hosts(vec!["evil.com".to_string()])
4755            .with_max_body_size(12345);
4756        let endpoint =
4757            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4758        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4759        assert!(endpoint.allow_internal);
4760        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4761        assert_eq!(endpoint.max_body_size, 12345);
4762    }
4763
4764    #[test]
4765    fn test_from_uri_with_defaults_uri_overrides_config() {
4766        let config = HttpConfig::default()
4767            .with_response_timeout_ms(999)
4768            .with_allow_internal(true)
4769            .with_blocked_hosts(vec!["evil.com".to_string()])
4770            .with_max_body_size(12345);
4771        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4772            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4773            &config,
4774        )
4775        .unwrap();
4776        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4777        assert!(!endpoint.allow_internal);
4778        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4779        assert_eq!(endpoint.max_body_size, 99);
4780    }
4781
4782    #[test]
4783    fn test_http_config_ok_status_range() {
4784        let config =
4785            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4786        assert_eq!(config.ok_status_code_range, (200, 204));
4787    }
4788
4789    #[test]
4790    fn test_http_config_wrong_scheme() {
4791        let result = HttpEndpointConfig::from_uri("file:/tmp");
4792        assert!(result.is_err());
4793    }
4794
4795    #[test]
4796    fn test_http_component_scheme() {
4797        let component = HttpComponent::new();
4798        assert_eq!(component.scheme(), "http");
4799    }
4800
4801    // -----------------------------------------------------------------------
4802    // tls.strict — fail-closed knob (audit 2026-08-31 R3 / rc-ayrwk).
4803    // Default stays permissive (F2-7 warns); strict fails endpoint creation
4804    // on any CA/mTLS load failure.
4805    // -----------------------------------------------------------------------
4806
4807    #[test]
4808    fn tls_strict_defaults_false_on_deserialize() {
4809        let tls: TlsConfig = serde_json::from_value(serde_json::json!({
4810            "enabled": true
4811        }))
4812        .unwrap();
4813        assert!(!tls.strict, "absent strict must default to false");
4814    }
4815
4816    fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
4817        HttpConfig {
4818            tls: Some(TlsConfig {
4819                enabled: true,
4820                strict,
4821                ca_cert_path: ca_path.map(|p| p.to_string()),
4822                ..TlsConfig::default()
4823            }),
4824            ..HttpConfig::default()
4825        }
4826    }
4827
4828    #[test]
4829    fn strict_tls_missing_ca_fails_endpoint_creation() {
4830        let component =
4831            HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
4832        let err = component
4833            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4834            .err()
4835            .expect("strict + missing CA must fail endpoint creation");
4836        assert!(
4837            err.to_string().contains("tls.strict"),
4838            "must name the strict knob: {err}"
4839        );
4840        assert!(
4841            err.to_string().contains("unreadable"),
4842            "must name the failure class: {err}"
4843        );
4844    }
4845
4846    #[test]
4847    fn strict_tls_unparseable_ca_fails_endpoint_creation() {
4848        let path = camel_component_api::test_support::tls::write_pem_tmp(
4849            "strict-bad-ca.pem",
4850            "not a certificate",
4851        );
4852        let component =
4853            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4854        let err = component
4855            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4856            .err()
4857            .expect("strict + unparseable CA must fail endpoint creation");
4858        assert!(
4859            err.to_string()
4860                .contains("no parseable PEM CERTIFICATE section"),
4861            "must name the failure class: {err}"
4862        );
4863    }
4864
4865    #[test]
4866    fn strict_tls_der_file_rejected_not_certified() {
4867        // e_glm stage-4 finding 1: a DER-looking file (first byte 0x30 =
4868        // ASCII '0') must NOT pass strict — the rustls backend never
4869        // enforces lone-DER bundles, so certifying one would certify an
4870        // unenforced config.
4871        let path = camel_component_api::test_support::tls::write_pem_tmp(
4872            "strict-der-ca.pem",
4873            "00garbage-bytes",
4874        );
4875        let component =
4876            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4877        let err = component
4878            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4879            .err()
4880            .expect("strict + DER file must fail endpoint creation");
4881        assert!(
4882            err.to_string().contains("convert to PEM"),
4883            "must tell the operator to convert: {err}"
4884        );
4885    }
4886
4887    #[test]
4888    fn strict_tls_half_mtls_pair_rejected() {
4889        // e_glm stage-4 finding 2: cert XOR key must fail under strict,
4890        // not silently degrade to non-mTLS.
4891        let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
4892        let component = HttpComponent::with_config(cfg);
4893        let err = component
4894            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4895            .err()
4896            .expect("strict + half mTLS pair must fail endpoint creation");
4897        assert!(
4898            err.to_string().contains("BOTH"),
4899            "must name the pair requirement: {err}"
4900        );
4901    }
4902
4903    #[test]
4904    fn strict_tls_valid_material_allows_endpoint_creation() {
4905        let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
4906        let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
4907        let component =
4908            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4909        assert!(
4910            component
4911                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4912                .is_ok(),
4913            "valid CA under strict must create the endpoint"
4914        );
4915    }
4916
4917    #[test]
4918    fn permissive_missing_ca_keeps_back_compat() {
4919        // strict absent (false): the F2-7 warn-and-fallback behavior stays;
4920        // endpoint creation succeeds.
4921        let component =
4922            HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
4923        assert!(
4924            component
4925                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4926                .is_ok(),
4927            "permissive mode must keep the back-compat fallback"
4928        );
4929    }
4930
4931    fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
4932        HttpConfig {
4933            tls: Some(TlsConfig {
4934                enabled: true,
4935                strict: true,
4936                client_cert_path: cert_path.map(|p| p.to_string()),
4937                client_key_path: key_path.map(|p| p.to_string()),
4938                ..TlsConfig::default()
4939            }),
4940            ..HttpConfig::default()
4941        }
4942    }
4943
4944    #[test]
4945    fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
4946        // Key present, cert file missing: a half-readable mTLS pair must
4947        // fail creation under strict, not silently drop the identity.
4948        let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4949        let key_path =
4950            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
4951        let component = HttpComponent::with_config(strict_mtls_config(
4952            Some("/nonexistent/cert.pem"),
4953            Some(key_path.to_str().unwrap()),
4954        ));
4955        let err = component
4956            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4957            .err()
4958            .expect("strict + unreadable mTLS pair must fail endpoint creation");
4959        assert!(
4960            err.to_string().contains("tls.strict"),
4961            "must name the strict knob: {err}"
4962        );
4963        assert!(
4964            err.to_string().contains("unreadable"),
4965            "must name the failure class: {err}"
4966        );
4967    }
4968
4969    #[test]
4970    fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
4971        let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4972        let cert_path =
4973            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
4974        let key_path =
4975            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
4976        let component = HttpComponent::with_config(strict_mtls_config(
4977            Some(cert_path.to_str().unwrap()),
4978            Some(key_path.to_str().unwrap()),
4979        ));
4980        assert!(
4981            component
4982                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4983                .is_ok(),
4984            "valid mTLS pair under strict must create the endpoint"
4985        );
4986    }
4987
4988    #[test]
4989    fn test_https_component_scheme() {
4990        let component = HttpsComponent::new();
4991        assert_eq!(component.scheme(), "https");
4992    }
4993
4994    #[test]
4995    fn test_http_endpoint_creates_consumer() {
4996        let component = HttpComponent::new();
4997        let ctx = NoOpComponentContext;
4998        let endpoint = component
4999            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
5000            .unwrap();
5001        assert!(endpoint.create_consumer(rt()).is_ok());
5002    }
5003
5004    #[test]
5005    fn test_https_endpoint_creates_consumer_errors_without_tls() {
5006        let component = HttpsComponent::new();
5007        let ctx = NoOpComponentContext;
5008        let endpoint = component
5009            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
5010            .unwrap();
5011        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
5012        assert!(endpoint.create_consumer(rt()).is_err());
5013    }
5014
5015    #[test]
5016    fn test_http_endpoint_creates_producer() {
5017        let ctx = test_producer_ctx();
5018        let component = HttpComponent::new();
5019        let endpoint_ctx = NoOpComponentContext;
5020        let endpoint = component
5021            .create_endpoint("http://localhost/api", &endpoint_ctx)
5022            .unwrap();
5023        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
5024    }
5025
5026    // -----------------------------------------------------------------------
5027    // Producer tests
5028    // -----------------------------------------------------------------------
5029
5030    #[tokio::test]
5031    async fn test_producer_with_token_provider() {
5032        use camel_auth::oauth2::TokenProvider;
5033        use tower::ServiceExt;
5034
5035        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
5036            Arc::new(std::sync::Mutex::new(None));
5037        let captured_clone = Arc::clone(&captured_auth);
5038
5039        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5040        let port = listener.local_addr().unwrap().port();
5041
5042        let _handle = tokio::spawn(async move {
5043            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5044            if let Ok((mut stream, _)) = listener.accept().await {
5045                let mut buf = vec![0u8; 8192];
5046                let n = stream.read(&mut buf).await.unwrap_or(0);
5047                let request = String::from_utf8_lossy(&buf[..n]).to_string();
5048                let auth = request
5049                    .lines()
5050                    .find(|l| l.to_lowercase().starts_with("authorization:"))
5051                    .map(|l| {
5052                        l.split(':')
5053                            .nth(1)
5054                            .map(|s| s.trim().to_string())
5055                            .unwrap_or_default()
5056                    });
5057                *captured_clone.lock().unwrap() = auth;
5058                let body = r#"{"echo":"ok"}"#;
5059                let resp = format!(
5060                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5061                    body.len(),
5062                    body
5063                );
5064                let _ = stream.write_all(resp.as_bytes()).await;
5065            }
5066        });
5067
5068        #[derive(Debug)]
5069        struct StaticProvider;
5070        #[async_trait::async_trait]
5071        impl TokenProvider for StaticProvider {
5072            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
5073                Ok("injected-token".into())
5074            }
5075        }
5076
5077        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
5078        let ctx = test_producer_ctx();
5079        let component = HttpComponent::new();
5080        let endpoint_ctx = NoOpComponentContext;
5081        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
5082        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5083
5084        let exchange = Exchange::new(Message::new("hello"));
5085
5086        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
5087        let mut layered = layer.layer(producer);
5088        let result = layered.ready().await.unwrap().call(exchange).await;
5089        assert!(result.is_ok(), "producer call failed: {:?}", result);
5090
5091        tokio::time::sleep(Duration::from_millis(100)).await;
5092        let auth = captured_auth.lock().unwrap().take();
5093        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
5094    }
5095
5096    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
5097        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5098        let addr = listener.local_addr().unwrap();
5099        let url = format!("http://127.0.0.1:{}", addr.port());
5100
5101        let handle = tokio::spawn(async move {
5102            loop {
5103                if let Ok((mut stream, _)) = listener.accept().await {
5104                    tokio::spawn(async move {
5105                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5106                        let mut buf = vec![0u8; 4096];
5107                        let n = stream.read(&mut buf).await.unwrap_or(0);
5108                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
5109
5110                        let method = request.split_whitespace().next().unwrap_or("GET");
5111
5112                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
5113                        let response = format!(
5114                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
5115                            body.len(),
5116                            body
5117                        );
5118                        let _ = stream.write_all(response.as_bytes()).await;
5119                    });
5120                }
5121            }
5122        });
5123
5124        (url, handle)
5125    }
5126
5127    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
5128        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5129        let addr = listener.local_addr().unwrap();
5130        let url = format!("http://127.0.0.1:{}", addr.port());
5131
5132        let handle = tokio::spawn(async move {
5133            loop {
5134                if let Ok((mut stream, _)) = listener.accept().await {
5135                    let status = status;
5136                    tokio::spawn(async move {
5137                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5138                        let mut buf = vec![0u8; 4096];
5139                        let _ = stream.read(&mut buf).await;
5140
5141                        let status_text = match status {
5142                            404 => "Not Found",
5143                            500 => "Internal Server Error",
5144                            _ => "Error",
5145                        };
5146                        let body = "error body";
5147                        let response = format!(
5148                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
5149                            status,
5150                            status_text,
5151                            body.len(),
5152                            body
5153                        );
5154                        let _ = stream.write_all(response.as_bytes()).await;
5155                    });
5156                }
5157            }
5158        });
5159
5160        (url, handle)
5161    }
5162
5163    async fn start_request_capturing_server() -> (
5164        String,
5165        Arc<std::sync::Mutex<Option<String>>>,
5166        tokio::task::JoinHandle<()>,
5167    ) {
5168        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5169        let port = listener.local_addr().unwrap().port();
5170        let url = format!("http://127.0.0.1:{port}");
5171        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
5172        let captured_clone = Arc::clone(&captured);
5173        let handle = tokio::spawn(async move {
5174            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5175            if let Ok((mut stream, _)) = listener.accept().await {
5176                let mut buf = vec![0u8; 16384];
5177                let n = stream.read(&mut buf).await.unwrap_or(0);
5178                let request = String::from_utf8_lossy(&buf[..n]).to_string();
5179                if request.contains("\r\n\r\n") {
5180                    *captured_clone.lock().unwrap() = Some(request);
5181                }
5182                let body = r#"{"echo":"ok"}"#;
5183                let resp = format!(
5184                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5185                    body.len(),
5186                    body
5187                );
5188                let _ = stream.write_all(resp.as_bytes()).await;
5189            }
5190        });
5191        (url, captured, handle)
5192    }
5193
5194    #[tokio::test]
5195    async fn test_http_producer_get_request() {
5196        use tower::ServiceExt;
5197
5198        let (url, _handle) = start_test_server().await;
5199        let ctx = test_producer_ctx();
5200
5201        let component = HttpComponent::new();
5202        let endpoint_ctx = NoOpComponentContext;
5203        let endpoint = component
5204            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5205            .unwrap();
5206        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5207
5208        let exchange = Exchange::new(Message::default());
5209        let result = producer.oneshot(exchange).await.unwrap();
5210
5211        let status = result
5212            .input
5213            .header("CamelHttpResponseCode")
5214            .and_then(|v| v.as_u64())
5215            .unwrap();
5216        assert_eq!(status, 200);
5217
5218        assert!(!result.input.body.is_empty());
5219    }
5220
5221    #[tokio::test]
5222    async fn producer_excludes_host_and_framing() {
5223        use tower::ServiceExt;
5224
5225        let (url, captured, _handle) = start_request_capturing_server().await;
5226        let ctx = test_producer_ctx();
5227        let component = HttpComponent::new();
5228        let endpoint_ctx = NoOpComponentContext;
5229        let endpoint = component
5230            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5231            .unwrap();
5232        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5233
5234        let mut exchange = Exchange::new(Message::default());
5235        exchange.input.set_header("Host", "localhost");
5236        exchange.input.set_header("Content-Length", "42");
5237        exchange.input.set_header("Connection", "keep-alive");
5238        exchange.input.set_header("Upgrade", "h2c");
5239
5240        let result = producer.oneshot(exchange).await;
5241        assert!(result.is_ok(), "producer call failed: {:?}", result);
5242
5243        tokio::time::sleep(Duration::from_millis(100)).await;
5244        let request = captured
5245            .lock()
5246            .unwrap()
5247            .take()
5248            .expect("no outbound request captured");
5249        let lower = request.to_ascii_lowercase();
5250        assert!(
5251            !lower.contains("\r\nhost: localhost"),
5252            "forwarded Host: localhost must be stripped\n{request}"
5253        );
5254        assert!(
5255            !lower.contains("content-length: 42"),
5256            "exchange Content-Length must not be copied\n{request}"
5257        );
5258        assert!(
5259            !lower.lines().any(|l| l.starts_with("connection:")),
5260            "Connection header must not be forwarded\n{request}"
5261        );
5262        assert!(
5263            !lower.lines().any(|l| l.starts_with("upgrade:")),
5264            "Upgrade header must not be forwarded\n{request}"
5265        );
5266        let host_header = lower
5267            .lines()
5268            .find(|l| l.starts_with("host:"))
5269            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5270            .expect("outbound Host header must be set by reqwest");
5271        assert!(
5272            host_header.starts_with("127.0.0.1:"),
5273            "outbound Host '{host_header}' must match the capture-server address"
5274        );
5275    }
5276
5277    #[tokio::test]
5278    async fn producer_forwards_request_only_headers() {
5279        use tower::ServiceExt;
5280
5281        let (url, captured, _handle) = start_request_capturing_server().await;
5282        let ctx = test_producer_ctx();
5283        let component = HttpComponent::new();
5284        let endpoint_ctx = NoOpComponentContext;
5285        let endpoint = component
5286            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5287            .unwrap();
5288        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5289
5290        let mut exchange = Exchange::new(Message::default());
5291        exchange.input.set_header("Accept", "application/json");
5292        exchange.input.set_header("User-Agent", "myclient/1.0");
5293
5294        let result = producer.oneshot(exchange).await;
5295        assert!(result.is_ok(), "producer call failed: {:?}", result);
5296
5297        tokio::time::sleep(Duration::from_millis(100)).await;
5298        let request = captured
5299            .lock()
5300            .unwrap()
5301            .take()
5302            .expect("no outbound request captured");
5303        let lower = request.to_ascii_lowercase();
5304        assert!(
5305            lower.contains("accept: application/json"),
5306            "request-only Accept header must be forwarded\n{request}"
5307        );
5308        assert!(
5309            lower.contains("user-agent: myclient/1.0"),
5310            "request-only User-Agent header must be forwarded\n{request}"
5311        );
5312    }
5313
5314    // -----------------------------------------------------------------------
5315    // Configured-header construction failures are surfaced, never silent
5316    // (rc-jbs1v)
5317    // -----------------------------------------------------------------------
5318
5319    /// Build an endpoint whose URI parses normally but whose `user_agent`
5320    /// and `auth` are then overridden programmatically, so CRLF-bearing
5321    /// test values never pass through URI parsing.
5322    fn endpoint_with_config_overrides(
5323        base_url: &str,
5324        user_agent: Option<String>,
5325        auth: HttpAuth,
5326    ) -> HttpEndpoint {
5327        let uri = format!("{base_url}/api/test?allowInternal=true");
5328        let mut config =
5329            HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5330        config.user_agent = user_agent;
5331        config.auth = auth;
5332        HttpEndpoint {
5333            uri: uri.clone(),
5334            config,
5335            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5336            client: reqwest::Client::new(),
5337            pinned_cache: Arc::new(PinnedClientCache::new(
5338                PINNED_CLIENT_TTL,
5339                PINNED_CLIENT_MAX_ENTRIES,
5340            )),
5341            http_config: HttpConfig::default(),
5342        }
5343    }
5344
5345    /// A configured user-agent / bearer token that fails `HeaderValue`
5346    /// construction must be dropped with a DEBUG record (name + reason
5347    /// only, never the value — ADR-0051) and reach the wire absent, while
5348    /// a valid config passes through unchanged.
5349    #[tracing_test::traced_test]
5350    #[tokio::test]
5351    async fn producer_invalid_configured_headers_surfaced() {
5352        use tower::ServiceExt;
5353
5354        let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5355        let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5356        let ctx = test_producer_ctx();
5357
5358        let bad_producer = endpoint_with_config_overrides(
5359            &bad_url,
5360            Some("bad\r\nua".to_string()),
5361            HttpAuth::Bearer {
5362                token: "tok\r\nen".to_string(),
5363            },
5364        )
5365        .create_producer(rt(), &ctx)
5366        .unwrap();
5367        let ok_producer = endpoint_with_config_overrides(
5368            &ok_url,
5369            Some("httpsweep-ok/1".to_string()),
5370            HttpAuth::Bearer {
5371                token: "valid-token".to_string(),
5372            },
5373        )
5374        .create_producer(rt(), &ctx)
5375        .unwrap();
5376
5377        let bad_exchange = Exchange::new(Message::default());
5378        let ok_exchange = Exchange::new(Message::default());
5379        let bad_cid = bad_exchange.correlation_id().to_string();
5380        let ok_cid = ok_exchange.correlation_id().to_string();
5381
5382        let bad_result = bad_producer.oneshot(bad_exchange).await;
5383        assert!(
5384            bad_result.is_ok(),
5385            "invalid-config producer call failed: {bad_result:?}"
5386        );
5387        let ok_result = ok_producer.oneshot(ok_exchange).await;
5388        assert!(
5389            ok_result.is_ok(),
5390            "valid-config producer call failed: {ok_result:?}"
5391        );
5392
5393        tokio::time::sleep(Duration::from_millis(100)).await;
5394        let bad_request = bad_captured
5395            .lock()
5396            .unwrap()
5397            .take()
5398            .expect("no outbound request captured");
5399        let ok_request = ok_captured
5400            .lock()
5401            .unwrap()
5402            .take()
5403            .expect("no outbound request captured");
5404
5405        // Invalid config: neither header reaches the wire. Value-absence,
5406        // not "any UA" — reqwest may inject a default user-agent.
5407        let bad_lower = bad_request.to_ascii_lowercase();
5408        assert!(
5409            !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5410            "invalid Bearer token must not reach the wire\n{bad_request}"
5411        );
5412        assert!(
5413            !bad_request.contains("bad\r\nua"),
5414            "invalid configured user-agent must not reach the wire\n{bad_request}"
5415        );
5416
5417        logs_assert(|lines: &[&str]| {
5418            let drops: Vec<&&str> = lines
5419                .iter()
5420                .filter(|l| {
5421                    l.contains("outbound header dropped")
5422                        && l.contains(&format!("correlation_id={bad_cid}"))
5423                })
5424                .collect();
5425            if drops.len() != 2 {
5426                return Err(format!(
5427                    "expected exactly 2 drop records for {bad_cid}, found {}",
5428                    drops.len()
5429                ));
5430            }
5431            let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5432            let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5433            let reason_ok = drops
5434                .iter()
5435                .all(|l| l.contains("outbound header dropped: invalid header value"));
5436            match (has_ua, has_auth, reason_ok) {
5437                (true, true, true) => Ok(()),
5438                _ => Err(format!(
5439                    "drop records mismatched: user-agent={has_ua} \
5440                     authorization={has_auth} reason-ok={reason_ok}"
5441                )),
5442            }
5443        });
5444        logs_assert(|lines: &[&str]| {
5445            if lines
5446                .iter()
5447                .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5448            {
5449                Err("sentinel CRLF values leaked into logs".to_string())
5450            } else {
5451                Ok(())
5452            }
5453        });
5454
5455        // Valid config: both headers reach the wire exactly as configured,
5456        // with zero drop records.
5457        let ok_lower = ok_request.to_ascii_lowercase();
5458        assert!(
5459            ok_lower.contains("user-agent: httpsweep-ok/1"),
5460            "valid configured user-agent must reach the wire\n{ok_request}"
5461        );
5462        assert!(
5463            ok_lower.contains("authorization: bearer valid-token"),
5464            "valid Bearer token must reach the wire\n{ok_request}"
5465        );
5466        logs_assert(|lines: &[&str]| {
5467            let hits = lines
5468                .iter()
5469                .filter(|l| {
5470                    l.contains("outbound header dropped")
5471                        && l.contains(&format!("correlation_id={ok_cid}"))
5472                })
5473                .count();
5474            match hits {
5475                0 => Ok(()),
5476                n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5477            }
5478        });
5479    }
5480
5481    #[tokio::test]
5482    async fn producer_honours_skip_request_headers() {
5483        use tower::ServiceExt;
5484
5485        let (url, captured, _handle) = start_request_capturing_server().await;
5486        let ctx = test_producer_ctx();
5487        let component = HttpComponent::new();
5488        let endpoint_ctx = NoOpComponentContext;
5489        let endpoint = component
5490            .create_endpoint(
5491                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5492                &endpoint_ctx,
5493            )
5494            .unwrap();
5495        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5496
5497        let mut exchange = Exchange::new(Message::default());
5498        exchange.input.set_header("Authorization", "Bearer x");
5499
5500        let result = producer.oneshot(exchange).await;
5501        assert!(result.is_ok(), "producer call failed: {:?}", result);
5502
5503        tokio::time::sleep(Duration::from_millis(100)).await;
5504        let request = captured
5505            .lock()
5506            .unwrap()
5507            .take()
5508            .expect("no outbound request captured");
5509        assert!(
5510            !request.to_ascii_lowercase().contains("authorization"),
5511            "Authorization must be stripped by skipRequestHeaders\n{request}"
5512        );
5513    }
5514
5515    #[tokio::test]
5516    async fn producer_stringifies_scalar_header_values_on_wire() {
5517        use tower::ServiceExt;
5518
5519        let (url, captured, _handle) = start_request_capturing_server().await;
5520        let ctx = test_producer_ctx();
5521        let component = HttpComponent::new();
5522        let endpoint_ctx = NoOpComponentContext;
5523        let endpoint = component
5524            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5525            .unwrap();
5526        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5527
5528        let mut exchange = Exchange::new(Message::default());
5529        exchange.input.set_header("X-Retries", serde_json::json!(3));
5530        exchange
5531            .input
5532            .set_header("X-Enabled", serde_json::json!(true));
5533        exchange
5534            .input
5535            .set_header("X-Obj", serde_json::json!({"a": 1}));
5536
5537        let result = producer.oneshot(exchange).await;
5538        assert!(result.is_ok(), "producer call failed: {:?}", result);
5539
5540        tokio::time::sleep(Duration::from_millis(100)).await;
5541        let request = captured
5542            .lock()
5543            .unwrap()
5544            .take()
5545            .expect("no outbound request captured");
5546        let lower = request.to_ascii_lowercase();
5547        assert!(
5548            lower.contains("x-retries: 3"),
5549            "numeric header must reach the wire stringified\n{request}"
5550        );
5551        assert!(
5552            lower.contains("x-enabled: true"),
5553            "bool header must reach the wire stringified\n{request}"
5554        );
5555        assert!(
5556            !lower.contains("x-obj:"),
5557            "object header has no single-value form and must not reach the wire\n{request}"
5558        );
5559    }
5560
5561    #[tokio::test]
5562    async fn test_http_producer_post_with_body() {
5563        use tower::ServiceExt;
5564
5565        let (url, _handle) = start_test_server().await;
5566        let ctx = test_producer_ctx();
5567
5568        let component = HttpComponent::new();
5569        let endpoint_ctx = NoOpComponentContext;
5570        let endpoint = component
5571            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
5572            .unwrap();
5573        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5574
5575        let exchange = Exchange::new(Message::new("request body"));
5576        let result = producer.oneshot(exchange).await.unwrap();
5577
5578        let status = result
5579            .input
5580            .header("CamelHttpResponseCode")
5581            .and_then(|v| v.as_u64())
5582            .unwrap();
5583        assert_eq!(status, 200);
5584    }
5585
5586    #[tokio::test]
5587    async fn test_http_producer_method_from_header() {
5588        use tower::ServiceExt;
5589
5590        let (url, _handle) = start_test_server().await;
5591        let ctx = test_producer_ctx();
5592
5593        let component = HttpComponent::new();
5594        let endpoint_ctx = NoOpComponentContext;
5595        let endpoint = component
5596            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5597            .unwrap();
5598        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5599
5600        let mut exchange = Exchange::new(Message::default());
5601        exchange.input.set_header(
5602            "CamelHttpMethod",
5603            serde_json::Value::String("DELETE".to_string()),
5604        );
5605
5606        let result = producer.oneshot(exchange).await.unwrap();
5607        let status = result
5608            .input
5609            .header("CamelHttpResponseCode")
5610            .and_then(|v| v.as_u64())
5611            .unwrap();
5612        assert_eq!(status, 200);
5613    }
5614
5615    #[tokio::test]
5616    async fn test_http_producer_forced_method() {
5617        use tower::ServiceExt;
5618
5619        let (url, _handle) = start_test_server().await;
5620        let ctx = test_producer_ctx();
5621
5622        let component = HttpComponent::new();
5623        let endpoint_ctx = NoOpComponentContext;
5624        let endpoint = component
5625            .create_endpoint(
5626                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
5627                &endpoint_ctx,
5628            )
5629            .unwrap();
5630        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5631
5632        let exchange = Exchange::new(Message::default());
5633        let result = producer.oneshot(exchange).await.unwrap();
5634
5635        let status = result
5636            .input
5637            .header("CamelHttpResponseCode")
5638            .and_then(|v| v.as_u64())
5639            .unwrap();
5640        assert_eq!(status, 200);
5641    }
5642
5643    #[tokio::test]
5644    async fn test_http_producer_throw_exception_on_failure() {
5645        use tower::ServiceExt;
5646
5647        let (url, _handle) = start_status_server(404).await;
5648        let ctx = test_producer_ctx();
5649
5650        let component = HttpComponent::new();
5651        let endpoint_ctx = NoOpComponentContext;
5652        let endpoint = component
5653            .create_endpoint(
5654                &format!("{url}/not-found?allowInternal=true"),
5655                &endpoint_ctx,
5656            )
5657            .unwrap();
5658        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5659
5660        let exchange = Exchange::new(Message::default());
5661        let result = producer.oneshot(exchange).await;
5662        assert!(result.is_err());
5663
5664        match result.unwrap_err() {
5665            CamelError::HttpOperationFailed { status_code, .. } => {
5666                assert_eq!(status_code, 404);
5667            }
5668            e => panic!("Expected HttpOperationFailed, got: {e}"),
5669        }
5670    }
5671
5672    #[tokio::test]
5673    async fn test_http_producer_no_throw_on_failure() {
5674        use tower::ServiceExt;
5675
5676        let (url, _handle) = start_status_server(500).await;
5677        let ctx = test_producer_ctx();
5678
5679        let component = HttpComponent::new();
5680        let endpoint_ctx = NoOpComponentContext;
5681        let endpoint = component
5682            .create_endpoint(
5683                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
5684                &endpoint_ctx,
5685            )
5686            .unwrap();
5687        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5688
5689        let exchange = Exchange::new(Message::default());
5690        let result = producer.oneshot(exchange).await.unwrap();
5691
5692        let status = result
5693            .input
5694            .header("CamelHttpResponseCode")
5695            .and_then(|v| v.as_u64())
5696            .unwrap();
5697        assert_eq!(status, 500);
5698    }
5699
5700    #[tokio::test]
5701    async fn test_http_producer_uri_override() {
5702        use tower::ServiceExt;
5703
5704        let (url, _handle) = start_test_server().await;
5705        let ctx = test_producer_ctx();
5706
5707        let component = HttpComponent::new();
5708        let endpoint_ctx = NoOpComponentContext;
5709        let endpoint = component
5710            .create_endpoint(
5711                "http://localhost:1/does-not-exist?allowInternal=true",
5712                &endpoint_ctx,
5713            )
5714            .unwrap();
5715        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5716
5717        let mut exchange = Exchange::new(Message::default());
5718        exchange.input.set_header(
5719            "CamelHttpUri",
5720            serde_json::Value::String(format!("{url}/api")),
5721        );
5722
5723        let result = producer.oneshot(exchange).await.unwrap();
5724        let status = result
5725            .input
5726            .header("CamelHttpResponseCode")
5727            .and_then(|v| v.as_u64())
5728            .unwrap();
5729        assert_eq!(status, 200);
5730    }
5731
5732    #[tokio::test]
5733    async fn test_http_producer_response_headers_mapped() {
5734        use tower::ServiceExt;
5735
5736        let (url, _handle) = start_test_server().await;
5737        let ctx = test_producer_ctx();
5738
5739        let component = HttpComponent::new();
5740        let endpoint_ctx = NoOpComponentContext;
5741        let endpoint = component
5742            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5743            .unwrap();
5744        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5745
5746        let exchange = Exchange::new(Message::default());
5747        let result = producer.oneshot(exchange).await.unwrap();
5748
5749        assert!(
5750            result.input.header("Content-Type").is_some(),
5751            "Response should have Content-Type header"
5752        );
5753        assert!(result.input.header("CamelHttpResponseText").is_some());
5754    }
5755
5756    // -----------------------------------------------------------------------
5757    // Bug fix tests: Client configuration per-endpoint
5758    // -----------------------------------------------------------------------
5759
5760    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
5761        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5762        let addr = listener.local_addr().unwrap();
5763        let url = format!("http://127.0.0.1:{}", addr.port());
5764
5765        let handle = tokio::spawn(async move {
5766            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5767            loop {
5768                if let Ok((mut stream, _)) = listener.accept().await {
5769                    tokio::spawn(async move {
5770                        let mut buf = vec![0u8; 4096];
5771                        let n = stream.read(&mut buf).await.unwrap_or(0);
5772                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
5773
5774                        // Check if this is a request to /final
5775                        if request.contains("GET /final") {
5776                            let body = r#"{"status":"final"}"#;
5777                            let response = format!(
5778                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5779                                body.len(),
5780                                body
5781                            );
5782                            let _ = stream.write_all(response.as_bytes()).await;
5783                        } else {
5784                            // Redirect to /final
5785                            // Connection: close stops the client pooling the
5786                            // connection the server drops right after this
5787                            // response (pooled-race, rc-u3aw class).
5788                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5789                            let _ = stream.write_all(response.as_bytes()).await;
5790                        }
5791                    });
5792                }
5793            }
5794        });
5795
5796        (url, handle)
5797    }
5798
5799    struct CapturedRequest {
5800        method: String,
5801        path: String,
5802        body: Vec<u8>,
5803        content_length: Option<String>,
5804        transfer_encoding: Option<String>,
5805    }
5806
5807    /// Parse a request head plus its Content-Length-driven body from a freshly
5808    /// accepted connection. Returns `None` if the client closes before sending
5809    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
5810    /// keep-alive connections and never sends FIN) and does NOT rely on a
5811    /// single fixed-size read (a segmented small body would flake).
5812    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
5813        use tokio::io::AsyncReadExt;
5814
5815        // Read the request head (up to and including the terminating CRLF CRLF).
5816        let mut buf: Vec<u8> = Vec::new();
5817        let mut chunk = [0u8; 4096];
5818        let head_end: usize;
5819        loop {
5820            let n = stream.read(&mut chunk).await.unwrap_or(0);
5821            if n == 0 {
5822                return None;
5823            }
5824            buf.extend_from_slice(&chunk[..n]);
5825            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
5826                head_end = pos + 4;
5827                break;
5828            }
5829        }
5830
5831        // Parse the request head.
5832        let head = String::from_utf8_lossy(&buf[..head_end]);
5833        let mut lines = head.split("\r\n");
5834        let request_line = lines.next().unwrap_or("");
5835        let mut parts = request_line.split_whitespace();
5836        let method = parts.next().unwrap_or("").to_string();
5837        let path = parts.next().unwrap_or("").to_string();
5838
5839        let mut content_length: Option<String> = None;
5840        let mut transfer_encoding: Option<String> = None;
5841        for line in lines {
5842            if let Some((name, value)) = line.split_once(':') {
5843                let name = name.trim().to_ascii_lowercase();
5844                let value = value.trim().to_string();
5845                if name == "content-length" {
5846                    content_length = Some(value);
5847                } else if name == "transfer-encoding" {
5848                    transfer_encoding = Some(value);
5849                }
5850            }
5851        }
5852
5853        // Content-Length-driven exact read. A missing header means a 0-length body.
5854        let body_len: usize = content_length
5855            .as_deref()
5856            .and_then(|v| v.parse::<usize>().ok())
5857            .unwrap_or(0);
5858
5859        let mut body: Vec<u8> = buf[head_end..].to_vec();
5860        while body.len() < body_len {
5861            let n = stream.read(&mut chunk).await.unwrap_or(0);
5862            if n == 0 {
5863                break;
5864            }
5865            body.extend_from_slice(&chunk[..n]);
5866        }
5867        body.truncate(body_len);
5868
5869        Some(CapturedRequest {
5870            method,
5871            path,
5872            body,
5873            content_length,
5874            transfer_encoding,
5875        })
5876    }
5877
5878    /// A raw-TCP capture server. Each connection parses the request head, then
5879    /// performs a Content-Length-driven exact read of the body (see
5880    /// [`capture_request`]). Each connection is dropped after the response so
5881    /// every hop opens a fresh connection.
5882    async fn start_capture_server() -> (
5883        String,
5884        tokio::task::JoinHandle<()>,
5885        Arc<Mutex<Vec<CapturedRequest>>>,
5886    ) {
5887        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5888        let addr = listener.local_addr().unwrap();
5889        let url = format!("http://127.0.0.1:{}", addr.port());
5890
5891        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5892        let captured_for_return = Arc::clone(&captured);
5893
5894        let handle = tokio::spawn(async move {
5895            use tokio::io::AsyncWriteExt;
5896            loop {
5897                if let Ok((mut stream, _)) = listener.accept().await {
5898                    let captured = Arc::clone(&captured);
5899                    tokio::spawn(async move {
5900                        let Some(req) = capture_request(&mut stream).await else {
5901                            return;
5902                        };
5903                        captured.lock().unwrap().push(req);
5904
5905                        // 200 OK with Content-Length: 0 and no body, then drop
5906                        // the stream so the client opens a fresh connection.
5907                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
5908                        let _ = stream.write_all(response.as_bytes()).await;
5909                    });
5910                }
5911            }
5912        });
5913
5914        (url, handle, captured_for_return)
5915    }
5916
5917    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
5918    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
5919    /// whose `/final` path answers `200 OK` with an empty body. Every hop
5920    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
5921    /// the connection after responding so each hop is a fresh connection.
5922    async fn start_redirect_capture_server() -> (
5923        String,
5924        tokio::task::JoinHandle<()>,
5925        Arc<Mutex<Vec<CapturedRequest>>>,
5926    ) {
5927        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5928        let addr = listener.local_addr().unwrap();
5929        let url = format!("http://127.0.0.1:{}", addr.port());
5930
5931        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5932        let captured_for_return = Arc::clone(&captured);
5933
5934        let handle = tokio::spawn(async move {
5935            use tokio::io::AsyncWriteExt;
5936            loop {
5937                if let Ok((mut stream, _)) = listener.accept().await {
5938                    let captured = Arc::clone(&captured);
5939                    tokio::spawn(async move {
5940                        let Some(req) = capture_request(&mut stream).await else {
5941                            return;
5942                        };
5943                        let path = req.path.clone();
5944                        captured.lock().unwrap().push(req);
5945
5946                        let (status_line, location) = match path.as_str() {
5947                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5948                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5949                            "/final" => ("HTTP/1.1 200 OK", None),
5950                            _ => ("HTTP/1.1 404 Not Found", None),
5951                        };
5952
5953                        let response = match location {
5954                            // Connection: close stops the client pooling the
5955                            // connection this handler drops right after the
5956                            // response (pooled-race, rc-u3aw class).
5957                            Some(loc) => format!(
5958                                "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5959                            ),
5960                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5961                        };
5962                        let _ = stream.write_all(response.as_bytes()).await;
5963                    });
5964                }
5965            }
5966        });
5967
5968        (url, handle, captured_for_return)
5969    }
5970
5971    #[tokio::test]
5972    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5973        use tower::ServiceExt;
5974
5975        let (url, _handle, captured) = start_capture_server().await;
5976        let ctx = test_producer_ctx();
5977
5978        let component = HttpComponent::with_config(HttpConfig::default());
5979        let endpoint_ctx = NoOpComponentContext;
5980        let endpoint = component
5981            .create_endpoint(
5982                &format!("{url}?httpMethod=GET&allowInternal=true"),
5983                &endpoint_ctx,
5984            )
5985            .unwrap();
5986        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5987
5988        let mut exchange = Exchange::new(Message::default());
5989        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5990
5991        let result = producer.oneshot(exchange).await.unwrap();
5992
5993        let status = result
5994            .input
5995            .header("CamelHttpResponseCode")
5996            .and_then(|v| v.as_u64())
5997            .unwrap();
5998        assert_eq!(status, 200);
5999
6000        let captured = captured.lock().unwrap();
6001        assert_eq!(captured.len(), 1, "expected exactly one captured request");
6002        let req = &captured[0];
6003        assert_eq!(req.method, "GET");
6004        // `httpMethod`/`allowInternal` are URI options, not request-target
6005        // query params, so the origin-form target is just "/".
6006        assert_eq!(req.path, "/");
6007        assert!(req.body.is_empty(), "GET must not carry a body");
6008        assert!(
6009            req.content_length.is_none(),
6010            "suppressed request must not carry Content-Length"
6011        );
6012        assert!(
6013            req.transfer_encoding.is_none(),
6014            "suppressed request must not carry Transfer-Encoding"
6015        );
6016
6017        // The exchange body is consumed by the producer (std::mem::take).
6018        assert!(
6019            result.input.body.is_empty(),
6020            "exchange body must be consumed"
6021        );
6022    }
6023
6024    #[tokio::test]
6025    async fn test_head_with_body_suppressed_via_header() {
6026        use tower::ServiceExt;
6027
6028        let (url, _handle, captured) = start_capture_server().await;
6029        let ctx = test_producer_ctx();
6030
6031        let component = HttpComponent::with_config(HttpConfig::default());
6032        let endpoint_ctx = NoOpComponentContext;
6033        let endpoint = component
6034            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6035            .unwrap();
6036        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6037
6038        let mut exchange = Exchange::new(Message::default());
6039        exchange.input.set_header(
6040            "CamelHttpMethod",
6041            serde_json::Value::String("HEAD".to_string()),
6042        );
6043        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6044
6045        let result = producer.oneshot(exchange).await.unwrap();
6046        let status = result
6047            .input
6048            .header("CamelHttpResponseCode")
6049            .and_then(|v| v.as_u64())
6050            .unwrap();
6051        assert_eq!(status, 200);
6052
6053        let captured = captured.lock().unwrap();
6054        assert_eq!(captured.len(), 1);
6055        let req = &captured[0];
6056        assert_eq!(req.method, "HEAD");
6057        assert!(req.body.is_empty(), "HEAD must not carry a body");
6058    }
6059
6060    #[tokio::test]
6061    async fn test_delete_options_trace_with_body_suppressed() {
6062        use tower::ServiceExt;
6063
6064        let (url, _handle, captured) = start_capture_server().await;
6065        let ctx = test_producer_ctx();
6066        let component = HttpComponent::with_config(HttpConfig::default());
6067        let endpoint_ctx = NoOpComponentContext;
6068
6069        for method in ["DELETE", "OPTIONS", "TRACE"] {
6070            let endpoint = component
6071                .create_endpoint(
6072                    &format!("{url}?httpMethod={method}&allowInternal=true"),
6073                    &endpoint_ctx,
6074                )
6075                .unwrap();
6076            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6077
6078            let mut exchange = Exchange::new(Message::default());
6079            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6080
6081            let result = producer.oneshot(exchange).await.unwrap();
6082            let status = result
6083                .input
6084                .header("CamelHttpResponseCode")
6085                .and_then(|v| v.as_u64())
6086                .unwrap();
6087            assert_eq!(status, 200, "method {method} should succeed");
6088        }
6089
6090        let captured = captured.lock().unwrap();
6091        assert_eq!(captured.len(), 3, "expected three captured requests");
6092        for method in ["DELETE", "OPTIONS", "TRACE"] {
6093            let req = captured
6094                .iter()
6095                .find(|r| r.method == method)
6096                .unwrap_or_else(|| panic!("missing captured request for {method}"));
6097            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
6098        }
6099    }
6100
6101    #[tokio::test]
6102    async fn test_post_put_patch_with_body_still_sent() {
6103        use tower::ServiceExt;
6104
6105        let (url, _handle, captured) = start_capture_server().await;
6106        let ctx = test_producer_ctx();
6107        let component = HttpComponent::with_config(HttpConfig::default());
6108        let endpoint_ctx = NoOpComponentContext;
6109
6110        for method in ["POST", "PUT", "PATCH"] {
6111            let endpoint = component
6112                .create_endpoint(
6113                    &format!("{url}?httpMethod={method}&allowInternal=true"),
6114                    &endpoint_ctx,
6115                )
6116                .unwrap();
6117            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6118
6119            let payload = format!("body-for-{method}");
6120            let mut exchange = Exchange::new(Message::default());
6121            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
6122
6123            let result = producer.oneshot(exchange).await.unwrap();
6124            let status = result
6125                .input
6126                .header("CamelHttpResponseCode")
6127                .and_then(|v| v.as_u64())
6128                .unwrap();
6129            assert_eq!(status, 200, "method {method} should succeed");
6130        }
6131
6132        let captured = captured.lock().unwrap();
6133        assert_eq!(captured.len(), 3, "expected three captured requests");
6134        for method in ["POST", "PUT", "PATCH"] {
6135            let req = captured
6136                .iter()
6137                .find(|r| r.method == method)
6138                .unwrap_or_else(|| panic!("missing captured request for {method}"));
6139            let expected = format!("body-for-{method}");
6140            assert!(!req.body.is_empty(), "{method} must still carry its body");
6141            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
6142        }
6143    }
6144
6145    /// A GET with a stream body must not attach the stream: the entity-enclosing
6146    /// gate drops the stream (mem::take) before the request is built, leaving
6147    /// the exchange body Empty instead of a partially-consumed Body::Stream.
6148    #[tokio::test]
6149    async fn test_stream_body_under_get_not_attached() {
6150        use tower::ServiceExt;
6151
6152        let (url, _handle, captured) = start_capture_server().await;
6153        let ctx = test_producer_ctx();
6154
6155        let component = HttpComponent::with_config(HttpConfig::default());
6156        let endpoint_ctx = NoOpComponentContext;
6157        let endpoint = component
6158            .create_endpoint(
6159                &format!("{url}?httpMethod=GET&allowInternal=true"),
6160                &endpoint_ctx,
6161            )
6162            .unwrap();
6163        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6164
6165        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6166            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
6167        let stream = Box::pin(futures::stream::iter(chunks));
6168        let mut exchange = Exchange::new(Message::default());
6169        exchange.input.body = Body::Stream(StreamBody {
6170            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6171            metadata: StreamMetadata::default(),
6172        });
6173
6174        let result = producer.oneshot(exchange).await.unwrap();
6175
6176        let status = result
6177            .input
6178            .header("CamelHttpResponseCode")
6179            .and_then(|v| v.as_u64())
6180            .unwrap();
6181        assert_eq!(status, 200);
6182
6183        let captured = captured.lock().unwrap();
6184        assert_eq!(captured.len(), 1, "expected exactly one captured request");
6185        assert!(
6186            captured[0].body.is_empty(),
6187            "GET must not carry a stream body"
6188        );
6189        assert!(
6190            captured[0].transfer_encoding.is_none(),
6191            "suppressed request must not carry Transfer-Encoding"
6192        );
6193        assert!(
6194            captured[0].content_length.is_none(),
6195            "suppressed request must not carry Content-Length"
6196        );
6197        assert!(
6198            result.input.body.is_empty(),
6199            "exchange body must be consumed to Empty, not left as a stream"
6200        );
6201    }
6202
6203    /// A suppressed body must never be replayed across 307/308 redirect hops:
6204    /// the gate empties `materialized_body` before the redirect loop runs, so
6205    /// neither the first hop nor the final hop carries the body.
6206    #[tokio::test]
6207    async fn test_redirect_hops_never_replay_suppressed_body() {
6208        use tower::ServiceExt;
6209
6210        let (url, _handle, captured) = start_redirect_capture_server().await;
6211        let ctx = test_producer_ctx();
6212
6213        let component =
6214            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6215        let endpoint_ctx = NoOpComponentContext;
6216
6217        for path in ["/hop307", "/hop308"] {
6218            let endpoint = component
6219                .create_endpoint(
6220                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
6221                    &endpoint_ctx,
6222                )
6223                .unwrap();
6224            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6225
6226            let mut exchange = Exchange::new(Message::default());
6227            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6228
6229            let result = producer.oneshot(exchange).await.unwrap();
6230            let status = result
6231                .input
6232                .header("CamelHttpResponseCode")
6233                .and_then(|v| v.as_u64())
6234                .unwrap();
6235            assert_eq!(
6236                status, 200,
6237                "redirect chain for {path} should end at /final"
6238            );
6239        }
6240
6241        // Two chains (307 and 308), each with two hops (redirect + final).
6242        let captured = captured.lock().unwrap();
6243        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6244        for req in captured.iter() {
6245            assert!(
6246                req.body.is_empty(),
6247                "hop {} {} must not carry a body",
6248                req.method,
6249                req.path
6250            );
6251        }
6252    }
6253
6254    /// The warn! emitted on a suppressed body renders three distinguishable
6255    /// substrings in the log line (tracing-subscriber default field format):
6256    ///   - the message:       "dropping request body ..."
6257    ///   - `method = %method_str`            → `method=GET`
6258    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
6259    /// The closure matches all three so exactly one warn per suppressed
6260    /// request is required (the "HTTP request" debug! also carries
6261    /// `method=GET` and the same `correlation_id=`, but not the message).
6262    #[tracing_test::traced_test]
6263    #[tokio::test]
6264    async fn test_suppressed_body_logs_exactly_one_warn() {
6265        use tower::ServiceExt;
6266
6267        let (url, _handle, _captured) = start_capture_server().await;
6268        let ctx = test_producer_ctx();
6269
6270        let component = HttpComponent::with_config(HttpConfig::default());
6271        let endpoint_ctx = NoOpComponentContext;
6272        let endpoint = component
6273            .create_endpoint(
6274                &format!("{url}?httpMethod=GET&allowInternal=true"),
6275                &endpoint_ctx,
6276            )
6277            .unwrap();
6278        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6279
6280        let mut exchange = Exchange::new(Message::default());
6281        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6282        let correlation_id = exchange.correlation_id().to_string();
6283
6284        let result = producer.oneshot(exchange).await.unwrap();
6285        let status = result
6286            .input
6287            .header("CamelHttpResponseCode")
6288            .and_then(|v| v.as_u64())
6289            .unwrap();
6290        assert_eq!(status, 200);
6291
6292        logs_assert(|lines: &[&str]| {
6293            let hits = lines
6294                .iter()
6295                .filter(|l| {
6296                    l.contains("dropping request body")
6297                        && l.contains("method=GET")
6298                        && l.contains(&format!("correlation_id={correlation_id}"))
6299                })
6300                .count();
6301            match hits {
6302                1 => Ok(()),
6303                n => Err(format!("expected exactly one body-drop warn, found {n}")),
6304            }
6305        });
6306    }
6307
6308    #[tracing_test::traced_test]
6309    #[tokio::test]
6310    async fn test_empty_body_get_emits_no_warn() {
6311        use tower::ServiceExt;
6312
6313        let (url, _handle, _captured) = start_capture_server().await;
6314        let ctx = test_producer_ctx();
6315
6316        let component = HttpComponent::with_config(HttpConfig::default());
6317        let endpoint_ctx = NoOpComponentContext;
6318        let endpoint = component
6319            .create_endpoint(
6320                &format!("{url}?httpMethod=GET&allowInternal=true"),
6321                &endpoint_ctx,
6322            )
6323            .unwrap();
6324        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6325
6326        let exchange = Exchange::new(Message::default());
6327        let result = producer.oneshot(exchange).await.unwrap();
6328        let status = result
6329            .input
6330            .header("CamelHttpResponseCode")
6331            .and_then(|v| v.as_u64())
6332            .unwrap();
6333        assert_eq!(status, 200);
6334
6335        logs_assert(|lines: &[&str]| {
6336            let hits = lines
6337                .iter()
6338                .filter(|l| l.contains("dropping request body"))
6339                .count();
6340            match hits {
6341                0 => Ok(()),
6342                n => Err(format!("expected no body-drop warn, found {n}")),
6343            }
6344        });
6345    }
6346
6347    #[tokio::test]
6348    async fn test_follow_redirects_false_does_not_follow() {
6349        use tower::ServiceExt;
6350
6351        let (url, _handle) = start_redirect_server().await;
6352        let ctx = test_producer_ctx();
6353
6354        let component =
6355            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6356        let endpoint_ctx = NoOpComponentContext;
6357        let endpoint = component
6358            .create_endpoint(
6359                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6360                &endpoint_ctx,
6361            )
6362            .unwrap();
6363        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6364
6365        let exchange = Exchange::new(Message::default());
6366        let result = producer.oneshot(exchange).await.unwrap();
6367
6368        // Should get 302, NOT follow redirect to 200
6369        let status = result
6370            .input
6371            .header("CamelHttpResponseCode")
6372            .and_then(|v| v.as_u64())
6373            .unwrap();
6374        assert_eq!(
6375            status, 302,
6376            "Should NOT follow redirect when followRedirects=false"
6377        );
6378    }
6379
6380    #[tokio::test]
6381    async fn test_follow_redirects_true_follows_redirect() {
6382        use tower::ServiceExt;
6383
6384        let (url, _handle) = start_redirect_server().await;
6385        let ctx = test_producer_ctx();
6386
6387        let component =
6388            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6389        let endpoint_ctx = NoOpComponentContext;
6390        let endpoint = component
6391            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6392            .unwrap();
6393        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6394
6395        let exchange = Exchange::new(Message::default());
6396        let result = producer.oneshot(exchange).await.unwrap();
6397
6398        // Should follow redirect and get 200
6399        let status = result
6400            .input
6401            .header("CamelHttpResponseCode")
6402            .and_then(|v| v.as_u64())
6403            .unwrap();
6404        assert_eq!(
6405            status, 200,
6406            "Should follow redirect when followRedirects=true"
6407        );
6408    }
6409
6410    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
6411    /// This verifies the manual redirect loop executes correctly.
6412    #[tokio::test]
6413    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6414        use tower::ServiceExt;
6415
6416        // Use the existing redirect server which redirects to /final on the same server
6417        let (url, _handle) = start_redirect_server().await;
6418        let ctx = test_producer_ctx();
6419
6420        let component =
6421            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6422        let endpoint_ctx = NoOpComponentContext;
6423        let endpoint = component
6424            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6425            .unwrap();
6426        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6427
6428        let exchange = Exchange::new(Message::default());
6429        let result = producer.oneshot(exchange).await;
6430
6431        // With allowInternal=true, the redirect should succeed
6432        assert!(
6433            result.is_ok(),
6434            "Redirect should succeed with allowInternal=true, got: {:?}",
6435            result
6436        );
6437        let exchange = result.unwrap();
6438        let status = exchange
6439            .input
6440            .header("CamelHttpResponseCode")
6441            .and_then(|v| v.as_u64())
6442            .unwrap();
6443        assert_eq!(status, 200, "Should follow redirect to /final");
6444    }
6445
6446    /// With allowInternal=true, redirects to private IPs should be followed.
6447    #[tokio::test]
6448    async fn test_redirect_to_private_ip_allowed_when_configured() {
6449        use tower::ServiceExt;
6450
6451        // Start a server that redirects to /final on the same server (127.0.0.1)
6452        let (url, _handle) = start_redirect_server().await;
6453        let ctx = test_producer_ctx();
6454
6455        let component =
6456            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6457        let endpoint_ctx = NoOpComponentContext;
6458        let endpoint = component
6459            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6460            .unwrap();
6461        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6462
6463        let exchange = Exchange::new(Message::default());
6464        let result = producer.oneshot(exchange).await.unwrap();
6465
6466        let status = result
6467            .input
6468            .header("CamelHttpResponseCode")
6469            .and_then(|v| v.as_u64())
6470            .unwrap();
6471        assert_eq!(
6472            status, 200,
6473            "Should follow redirect to private IP when allowInternal=true"
6474        );
6475    }
6476
6477    /// Integration test: with allowInternal=false (default), a redirect to a
6478    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
6479    #[tokio::test]
6480    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
6481        use tower::ServiceExt;
6482
6483        // Server that redirects to the AWS metadata endpoint (link-local private IP)
6484        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6485        let addr = listener.local_addr().unwrap();
6486        let url = format!("http://127.0.0.1:{}", addr.port());
6487
6488        let handle = tokio::spawn(async move {
6489            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6490            loop {
6491                if let Ok((mut stream, _)) = listener.accept().await {
6492                    tokio::spawn(async move {
6493                        let mut buf = vec![0u8; 4096];
6494                        let _ = stream.read(&mut buf).await;
6495                        // Always redirect to the metadata endpoint
6496                        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";
6497                        let _ = stream.write_all(response.as_bytes()).await;
6498                    });
6499                }
6500            }
6501        });
6502
6503        let ctx = test_producer_ctx();
6504        let component =
6505            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6506        let endpoint_ctx = NoOpComponentContext;
6507        // allowInternal=false is the default — do NOT set it
6508        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
6509        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6510
6511        let exchange = Exchange::new(Message::default());
6512        let result = producer.oneshot(exchange).await;
6513
6514        // Must be an error — SSRF guard blocks the redirect target
6515        assert!(
6516            result.is_err(),
6517            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
6518        );
6519        let err = result.unwrap_err().to_string();
6520        assert!(
6521            err.contains("blocked IP")
6522                || err.contains("private IP")
6523                || err.contains("SSRF")
6524                || err.contains("not allowed"),
6525            "Error should mention SSRF/IP blocking, got: {err}"
6526        );
6527
6528        handle.abort();
6529    }
6530
6531    /// Integration test: exceeding maxRedirects produces a clear error.
6532    #[tokio::test]
6533    async fn test_too_many_redirects_returns_error() {
6534        use tower::ServiceExt;
6535
6536        // Server that always redirects to itself (infinite loop)
6537        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6538        let addr = listener.local_addr().unwrap();
6539        let url = format!("http://127.0.0.1:{}", addr.port());
6540
6541        let handle = tokio::spawn(async move {
6542            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6543            loop {
6544                if let Ok((mut stream, _)) = listener.accept().await {
6545                    tokio::spawn(async move {
6546                        let mut buf = vec![0u8; 4096];
6547                        let _ = stream.read(&mut buf).await;
6548                        // Always redirect to /loop
6549                        // Connection: close stops the client pooling the
6550                        // connection the server drops right after this
6551                        // response (pooled-race, rc-u3aw).
6552                        let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6553                        let _ = stream.write_all(response.as_bytes()).await;
6554                    });
6555                }
6556            }
6557        });
6558
6559        let ctx = test_producer_ctx();
6560        let component =
6561            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6562        let endpoint_ctx = NoOpComponentContext;
6563        let endpoint = component
6564            .create_endpoint(
6565                &format!("{url}?allowInternal=true&maxRedirects=2"),
6566                &endpoint_ctx,
6567            )
6568            .unwrap();
6569        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6570
6571        let exchange = Exchange::new(Message::default());
6572        let result = producer.oneshot(exchange).await;
6573
6574        // With the fix, exceeding max redirects returns the redirect response
6575        // as-is instead of erroring. The 302 redirect response is returned
6576        // after followRedirects exhausts the allowed redirect count (2).
6577        // Disable throwExceptionOnFailure to inspect the raw response status.
6578        //
6579        // Old behavior: Err("Too many redirects (max 2)")
6580        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
6581        match result {
6582            Err(e) => {
6583                // If throw_exception_on_failure is on, we get HttpOperationFailed
6584                let msg = e.to_string();
6585                assert!(
6586                    msg.contains("HTTP operation failed") || msg.contains("302"),
6587                    "expected redirect-after-exhaustion error, got: {msg}"
6588                );
6589            }
6590            Ok(ex) => {
6591                let response_code = ex
6592                    .input
6593                    .header("CamelHttpResponseCode")
6594                    .and_then(|v| v.as_u64());
6595                assert_eq!(
6596                    response_code,
6597                    Some(302),
6598                    "expected 302 after exhausting redirects"
6599                );
6600            }
6601        }
6602
6603        handle.abort();
6604    }
6605
6606    #[tokio::test]
6607    async fn test_query_params_forwarded_to_http_request() {
6608        use tower::ServiceExt;
6609
6610        let (url, _handle) = start_test_server().await;
6611        let ctx = test_producer_ctx();
6612
6613        let component = HttpComponent::new();
6614        let endpoint_ctx = NoOpComponentContext;
6615        // apiKey is NOT a Camel option, should be forwarded as query param
6616        let endpoint = component
6617            .create_endpoint(
6618                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
6619                &endpoint_ctx,
6620            )
6621            .unwrap();
6622        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6623
6624        let exchange = Exchange::new(Message::default());
6625        let result = producer.oneshot(exchange).await.unwrap();
6626
6627        // The test server returns the request info in response
6628        // We just verify it succeeds (the query param was sent)
6629        let status = result
6630            .input
6631            .header("CamelHttpResponseCode")
6632            .and_then(|v| v.as_u64())
6633            .unwrap();
6634        assert_eq!(status, 200);
6635    }
6636
6637    #[test]
6638    fn test_non_camel_query_params_are_forwarded() {
6639        // Authored pairs ride raw_query (the sole carrier); query_params is
6640        // programmatic-only (http-query-wire-fidelity).
6641        let config = HttpEndpointConfig::from_uri(
6642            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
6643        )
6644        .unwrap();
6645
6646        // apiKey and token are NOT camel-http options: the authored bytes
6647        // (including the interleaved httpMethod) ride raw_query verbatim.
6648        assert_eq!(
6649            config.raw_query.as_deref(),
6650            Some("apiKey=secret123&httpMethod=GET&token=abc456")
6651        );
6652        assert!(config.query_params.is_empty());
6653    }
6654
6655    #[test]
6656    fn test_authored_query_bytes_survive_resolve_url() {
6657        let config =
6658            HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
6659        let exchange = Exchange::new(Message::default());
6660
6661        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
6662
6663        // Authored bytes ride verbatim: `%20` stays `%20` (never re-encoded
6664        // to `+` or double-encoded) and `+` stays `+`.
6665        assert!(url.contains("q=hello%20world"), "url was: {url}");
6666        assert!(url.contains("tag=a+b"), "url was: {url}");
6667    }
6668
6669    // -----------------------------------------------------------------------
6670    // Timeout tests (HTTP-004)
6671    // -----------------------------------------------------------------------
6672
6673    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
6674        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6675        let addr = listener.local_addr().unwrap();
6676        let url = format!("http://127.0.0.1:{}", addr.port());
6677
6678        let handle = tokio::spawn(async move {
6679            loop {
6680                if let Ok((mut stream, _)) = listener.accept().await {
6681                    let delay = delay_ms;
6682                    tokio::spawn(async move {
6683                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6684                        let mut buf = vec![0u8; 4096];
6685                        let _ = stream.read(&mut buf).await;
6686                        // Send headers immediately (no Content-Length → chunked)
6687                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
6688                        let _ = stream.write_all(headers.as_bytes()).await;
6689                        // Delay before sending body chunk
6690                        tokio::time::sleep(Duration::from_millis(delay)).await;
6691                        let body = r#"{"status":"slow"}"#;
6692                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
6693                        let _ = stream.write_all(chunk.as_bytes()).await;
6694                    });
6695                }
6696            }
6697        });
6698
6699        (url, handle)
6700    }
6701
6702    #[tokio::test]
6703    async fn test_http_producer_timeout() {
6704        use tower::ServiceExt;
6705
6706        // Server delays 500ms, client timeout is 100ms → should timeout
6707        let (url, _handle) = start_slow_server(500).await;
6708        let ctx = test_producer_ctx();
6709
6710        let component = HttpComponent::with_config(
6711            HttpConfig::default()
6712                .with_read_timeout_ms(100)
6713                .with_response_timeout_ms(30_000), // generous response timeout
6714        );
6715        let endpoint_ctx = NoOpComponentContext;
6716        let endpoint = component
6717            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
6718            .unwrap();
6719        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6720
6721        let exchange = Exchange::new(Message::default());
6722        let result = producer.oneshot(exchange).await;
6723
6724        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
6725        let err = result.unwrap_err().to_string();
6726        assert!(
6727            err.contains("Read timeout") || err.contains("timeout"),
6728            "Error should mention timeout, got: {}",
6729            err
6730        );
6731    }
6732
6733    #[tokio::test]
6734    async fn test_http_producer_no_timeout_when_fast() {
6735        use tower::ServiceExt;
6736
6737        let (url, _handle) = start_test_server().await;
6738        let ctx = test_producer_ctx();
6739
6740        let component =
6741            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
6742        let endpoint_ctx = NoOpComponentContext;
6743        let endpoint = component
6744            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6745            .unwrap();
6746        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6747
6748        let exchange = Exchange::new(Message::default());
6749        let result = producer.oneshot(exchange).await.unwrap();
6750
6751        let status = result
6752            .input
6753            .header("CamelHttpResponseCode")
6754            .and_then(|v| v.as_u64())
6755            .unwrap();
6756        assert_eq!(status, 200);
6757    }
6758
6759    // -----------------------------------------------------------------------
6760    // SSRF Protection tests
6761    // -----------------------------------------------------------------------
6762
6763    #[tokio::test]
6764    async fn test_http_producer_blocks_metadata_endpoint() {
6765        use tower::ServiceExt;
6766
6767        let ctx = test_producer_ctx();
6768        let component = HttpComponent::new();
6769        let endpoint_ctx = NoOpComponentContext;
6770        let endpoint = component
6771            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
6772            .unwrap();
6773        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6774
6775        let mut exchange = Exchange::new(Message::default());
6776        exchange.input.set_header(
6777            "CamelHttpUri",
6778            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
6779        );
6780
6781        let result = producer.oneshot(exchange).await;
6782        assert!(result.is_err(), "Should block AWS metadata endpoint");
6783
6784        let err = result.unwrap_err();
6785        assert!(
6786            err.to_string().contains("Private IP"),
6787            "Error should mention private IP blocking, got: {}",
6788            err
6789        );
6790    }
6791
6792    #[test]
6793    fn test_ssrf_config_defaults() {
6794        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
6795        assert!(
6796            !config.allow_internal,
6797            "Private IPs should be blocked by default"
6798        );
6799        assert!(
6800            config.blocked_hosts.is_empty(),
6801            "Blocked hosts should be empty by default"
6802        );
6803    }
6804
6805    #[test]
6806    fn test_ssrf_config_allow_internal() {
6807        let config =
6808            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
6809        assert!(
6810            config.allow_internal,
6811            "Private IPs should be allowed when explicitly set"
6812        );
6813    }
6814
6815    #[test]
6816    fn test_uri_option_allow_cleartext_parses() {
6817        let config =
6818            HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=true").unwrap();
6819        assert!(
6820            config.allow_cleartext,
6821            "allowCleartext=true must parse into the endpoint config"
6822        );
6823
6824        let plain = HttpEndpointConfig::from_uri("http://example.com/").unwrap();
6825        assert!(
6826            !plain.allow_cleartext,
6827            "cleartext consent must default to false"
6828        );
6829
6830        let err =
6831            HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=banana").unwrap_err();
6832        assert!(
6833            matches!(&err, CamelError::InvalidUri(msg) if msg.contains("allowCleartext")),
6834            "bad allowCleartext value must yield InvalidUri naming the option, got: {err:?}"
6835        );
6836    }
6837
6838    /// ADR-0081: a CamelHttpUri override to a public cleartext target is
6839    /// gated by the endpoint's `allowCleartext` consent — override URLs go
6840    /// through the same `validate_url_for_ssrf` as the base URL.
6841    #[test]
6842    fn test_camel_http_uri_override_public_cleartext_follows_endpoint_flags() {
6843        let endpoint =
6844            HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=false").unwrap();
6845        let err = crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint)
6846            .expect_err("public cleartext override must be rejected without consent");
6847        assert!(
6848            err.to_string().contains("allowCleartext"),
6849            "error must name the remedy, got: {err}"
6850        );
6851
6852        let endpoint =
6853            HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=true").unwrap();
6854        assert!(
6855            crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint).is_ok(),
6856            "endpoint consent must admit a public cleartext override"
6857        );
6858    }
6859
6860    #[test]
6861    fn test_ssrf_config_blocked_hosts() {
6862        let config = HttpEndpointConfig::from_uri(
6863            "http://example.com/api?blockedHosts=evil.com,malware.net",
6864        )
6865        .unwrap();
6866        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
6867    }
6868
6869    #[tokio::test]
6870    async fn test_http_producer_blocks_localhost() {
6871        use tower::ServiceExt;
6872
6873        let ctx = test_producer_ctx();
6874        let component = HttpComponent::new();
6875        let endpoint_ctx = NoOpComponentContext;
6876        let endpoint = component
6877            .create_endpoint("http://example.com/api", &endpoint_ctx)
6878            .unwrap();
6879        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6880
6881        let mut exchange = Exchange::new(Message::default());
6882        exchange.input.set_header(
6883            "CamelHttpUri",
6884            serde_json::Value::String("http://localhost:8080/internal".to_string()),
6885        );
6886
6887        let result = producer.oneshot(exchange).await;
6888        assert!(result.is_err(), "Should block localhost");
6889    }
6890
6891    #[tokio::test]
6892    async fn test_http_producer_blocks_loopback_ip() {
6893        use tower::ServiceExt;
6894
6895        let ctx = test_producer_ctx();
6896        let component = HttpComponent::new();
6897        let endpoint_ctx = NoOpComponentContext;
6898        let endpoint = component
6899            .create_endpoint("http://example.com/api", &endpoint_ctx)
6900            .unwrap();
6901        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6902
6903        let mut exchange = Exchange::new(Message::default());
6904        exchange.input.set_header(
6905            "CamelHttpUri",
6906            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
6907        );
6908
6909        let result = producer.oneshot(exchange).await;
6910        assert!(result.is_err(), "Should block loopback IP");
6911    }
6912
6913    #[tokio::test]
6914    async fn test_http_producer_allows_private_ip_when_enabled() {
6915        use tower::ServiceExt;
6916
6917        let ctx = test_producer_ctx();
6918        let component = HttpComponent::new();
6919        let endpoint_ctx = NoOpComponentContext;
6920        // With allowInternal=true, the validation should pass
6921        // (actual connection will fail, but that's expected)
6922        let endpoint = component
6923            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
6924            .unwrap();
6925        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6926
6927        let exchange = Exchange::new(Message::default());
6928
6929        // The request will fail because we can't connect, but it should NOT fail
6930        // due to SSRF protection
6931        let result = producer.oneshot(exchange).await;
6932        // We expect connection error, not SSRF error
6933        if let Err(ref e) = result {
6934            let err_str = e.to_string();
6935            assert!(
6936                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
6937                "Should not be SSRF error, got: {}",
6938                err_str
6939            );
6940        }
6941    }
6942
6943    // -----------------------------------------------------------------------
6944    // HttpServerConfig tests
6945    // -----------------------------------------------------------------------
6946
6947    #[test]
6948    fn test_http_server_config_parse() {
6949        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
6950        assert_eq!(cfg.host, "0.0.0.0");
6951        assert_eq!(cfg.port, 8080);
6952        assert_eq!(cfg.path, "/orders");
6953        assert_eq!(cfg.max_inflight_requests, 1024);
6954    }
6955
6956    #[test]
6957    fn test_http_server_config_scheme() {
6958        // UriConfig trait method returns "http" as primary scheme
6959        assert_eq!(HttpServerConfig::scheme(), "http");
6960    }
6961
6962    #[test]
6963    fn test_http_server_config_from_components() {
6964        // Test from_components directly (trait method)
6965        let components = camel_component_api::UriComponents {
6966            scheme: "https".to_string(),
6967            path: "//0.0.0.0:8443/api".to_string(),
6968            params: std::collections::HashMap::from([
6969                ("maxRequestBody".to_string(), "5242880".to_string()),
6970                ("maxInflightRequests".to_string(), "7".to_string()),
6971            ]),
6972            raw_query: None,
6973        };
6974        let cfg = HttpServerConfig::from_components(components).unwrap();
6975        assert_eq!(cfg.host, "0.0.0.0");
6976        assert_eq!(cfg.port, 8443);
6977        assert_eq!(cfg.path, "/api");
6978        assert_eq!(cfg.max_request_body, 5242880);
6979        assert_eq!(cfg.max_inflight_requests, 7);
6980    }
6981
6982    #[test]
6983    fn test_http_server_config_default_path() {
6984        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
6985        assert_eq!(cfg.path, "/");
6986    }
6987
6988    #[test]
6989    fn test_http_server_config_wrong_scheme() {
6990        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6991    }
6992
6993    #[test]
6994    fn test_http_server_config_invalid_port() {
6995        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6996    }
6997
6998    #[test]
6999    fn test_http_server_config_default_port_by_scheme() {
7000        // HTTP without explicit port should default to 80
7001        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
7002        assert_eq!(cfg_http.port, 80);
7003
7004        // HTTPS without explicit port should default to 443
7005        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
7006        assert_eq!(cfg_https.port, 443);
7007    }
7008
7009    #[test]
7010    fn test_request_envelope_and_reply_are_send() {
7011        fn assert_send<T: Send>() {}
7012        assert_send::<RequestEnvelope>();
7013        assert_send::<HttpReply>();
7014    }
7015
7016    // -----------------------------------------------------------------------
7017    // ServerRegistry tests
7018    // -----------------------------------------------------------------------
7019
7020    #[test]
7021    fn test_server_registry_global_is_singleton() {
7022        let r1 = ServerRegistry::global();
7023        let r2 = ServerRegistry::global();
7024        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
7025    }
7026
7027    #[allow(clippy::await_holding_lock)]
7028    #[tokio::test]
7029    async fn test_concurrent_get_or_spawn_returns_same_registry() {
7030        let _guard = lock_registry_test_mutex();
7031        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7032        let port = listener.local_addr().unwrap().port();
7033        drop(listener);
7034
7035        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
7036            Arc::new(std::sync::Mutex::new(Vec::new()));
7037
7038        let mut handles = Vec::new();
7039        for _ in 0..4 {
7040            let results = results.clone();
7041            handles.push(tokio::spawn(async move {
7042                let registry = ServerRegistry::global()
7043                    .get_or_spawn(
7044                        "127.0.0.1",
7045                        port,
7046                        2 * 1024 * 1024,
7047                        10 * 1024 * 1024,
7048                        1024,
7049                        test_rt(),
7050                        "test-route".into(),
7051                        None,
7052                    )
7053                    .await
7054                    .unwrap();
7055                results.lock().unwrap().push(registry);
7056            }));
7057        }
7058
7059        for h in handles {
7060            h.await.unwrap();
7061        }
7062
7063        let registries = results.lock().unwrap();
7064        assert_eq!(registries.len(), 4);
7065        for i in 1..registries.len() {
7066            assert!(
7067                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
7068                "all concurrent callers should get same route registry"
7069            );
7070        }
7071    }
7072
7073    #[test]
7074    fn test_server_registry_distinguishes_host_and_port() {
7075        let _guard = lock_registry_test_mutex();
7076        let rt = tokio::runtime::Runtime::new().expect("runtime");
7077        rt.block_on(async {
7078            let registry = ServerRegistry::global();
7079            // Use two distinct host values with same configured port key.
7080            // Port 0 is acceptable here because the registry key uses the configured
7081            // tuple, not the OS-assigned ephemeral port.
7082            let d1 = registry
7083                .get_or_spawn(
7084                    "127.0.0.1",
7085                    0,
7086                    1024 * 1024,
7087                    10 * 1024 * 1024,
7088                    1024,
7089                    test_rt(),
7090                    "test-route-1".into(),
7091                    None,
7092                )
7093                .await;
7094            let d2 = registry
7095                .get_or_spawn(
7096                    "0.0.0.0",
7097                    0,
7098                    1024 * 1024,
7099                    10 * 1024 * 1024,
7100                    1024,
7101                    test_rt(),
7102                    "test-route-2".into(),
7103                    None,
7104                )
7105                .await;
7106            assert!(d1.is_ok());
7107            assert!(d2.is_ok());
7108            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
7109        });
7110    }
7111
7112    #[allow(clippy::await_holding_lock)]
7113    #[tokio::test]
7114    async fn test_shared_server_max_request_body_policy_is_deterministic() {
7115        let _guard = lock_registry_test_mutex();
7116        let registry = ServerRegistry::global();
7117        // First registration: maxRequestBody = 1 MB
7118        let d1 = registry
7119            .get_or_spawn(
7120                "127.0.0.1",
7121                9991,
7122                1024 * 1024,
7123                10 * 1024 * 1024,
7124                1024,
7125                test_rt(),
7126                "test-route".into(),
7127                None,
7128            )
7129            .await;
7130        assert!(d1.is_ok());
7131
7132        // Second registration on same (host,port): maxRequestBody = 2 MB
7133        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
7134        let d2 = registry
7135            .get_or_spawn(
7136                "127.0.0.1",
7137                9991,
7138                2 * 1024 * 1024,
7139                10 * 1024 * 1024,
7140                1024,
7141                test_rt(),
7142                "test-route-2".into(),
7143                None,
7144            )
7145            .await;
7146        assert!(d2.is_err());
7147        let err = d2.unwrap_err();
7148        assert!(
7149            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
7150            "Expected incompatible maxRequestBody error, got: {}",
7151            err
7152        );
7153    }
7154
7155    #[test]
7156    fn test_server_registry_reset_clears_entries() {
7157        let _guard = lock_registry_test_mutex();
7158        let rt = tokio::runtime::Runtime::new().expect("runtime");
7159        rt.block_on(async {
7160            // Register something on a unique port
7161            let d1 = ServerRegistry::global()
7162                .get_or_spawn(
7163                    "127.0.0.1",
7164                    9992,
7165                    1024 * 1024,
7166                    10 * 1024 * 1024,
7167                    1024,
7168                    test_rt(),
7169                    "test-route".into(),
7170                    None,
7171                )
7172                .await;
7173            assert!(d1.is_ok());
7174
7175            // Verify entry exists
7176            let guard = ServerRegistry::global().inner.lock().expect("lock");
7177            assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
7178            drop(guard);
7179
7180            // Reset
7181            ServerRegistry::reset();
7182
7183            // Verify cleared
7184            let guard = ServerRegistry::global().inner.lock().expect("lock");
7185            assert!(
7186                guard.entries.is_empty(),
7187                "registry should be empty after reset, has {} entries",
7188                guard.entries.len()
7189            );
7190        });
7191    }
7192
7193    #[allow(clippy::await_holding_lock)]
7194    #[tokio::test]
7195    async fn registry_rejects_tls_on_plain_port() {
7196        // httpflake: this reset previously ran WITHOUT the registry test
7197        // mutex, so it could wipe another test's freshly staged entry
7198        // mid-window (traced 2026-09-14) — spec law: every reset caller
7199        // holds REGISTRY_TEST_MUTEX.
7200        let _guard = lock_registry_test_mutex();
7201        ServerRegistry::reset();
7202        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7203
7204        // First route: plain HTTP
7205        let _r1 = ServerRegistry::global()
7206            .get_or_spawn(
7207                "127.0.0.1",
7208                0,
7209                1024,
7210                1024,
7211                16,
7212                Arc::clone(&rt),
7213                "route-1".into(),
7214                None, // plain
7215            )
7216            .await;
7217
7218        // Second route: TLS on same port → must fail
7219        let result = ServerRegistry::global()
7220            .get_or_spawn(
7221                "127.0.0.1",
7222                0,
7223                1024,
7224                1024,
7225                16,
7226                Arc::clone(&rt),
7227                "route-2".into(),
7228                Some(crate::config::ServerTlsConfig {
7229                    cert_path: "/x.pem".into(),
7230                    key_path: "/y.pem".into(),
7231                }),
7232            )
7233            .await;
7234        assert!(result.is_err(), "must reject TLS on plain port");
7235    }
7236
7237    // -----------------------------------------------------------------------
7238    // D-L10: HTTP server is process-lifetime — it survives consumer
7239    // unregister (no refcount; dead servers are evicted on next spawn)
7240    // -----------------------------------------------------------------------
7241
7242    #[allow(clippy::await_holding_lock)]
7243    #[tokio::test]
7244    async fn test_unregister_last_http_route_keeps_server_alive() {
7245        let _guard = lock_registry_test_mutex();
7246        ServerRegistry::reset();
7247        let registry = ServerRegistry::global();
7248
7249        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7250        let port = listener.local_addr().unwrap().port();
7251        drop(listener); // Release — ServerRegistry will rebind
7252        let rt = test_rt();
7253
7254        // Register 2 routes on the same (host, port) — OnceCell returns the
7255        // same ServerHandle.
7256        let _r1 = registry
7257            .get_or_spawn(
7258                "127.0.0.1",
7259                port,
7260                1024 * 1024,
7261                10 * 1024 * 1024,
7262                16,
7263                rt.clone(),
7264                "test-route-1".into(),
7265                None,
7266            )
7267            .await
7268            .unwrap();
7269        let _r2 = registry
7270            .get_or_spawn(
7271                "127.0.0.1",
7272                port,
7273                1024 * 1024,
7274                10 * 1024 * 1024,
7275                16,
7276                rt,
7277                "test-route-2".into(),
7278                None,
7279            )
7280            .await
7281            .unwrap();
7282
7283        let key = ("127.0.0.1".to_string(), port);
7284        let cell = {
7285            let guard = registry.inner.lock().expect("lock");
7286            guard.entries.get(&key).expect("entry should exist").clone()
7287        };
7288
7289        // Unregister first route -> monitor still alive (count = 1).
7290        registry.unregister("127.0.0.1", port).await;
7291        {
7292            let handle = cell
7293                .get()
7294                .expect("handle should still exist after first unregister");
7295            assert!(
7296                !handle.monitor_task.is_finished(),
7297                "monitor task should still be alive after first unregister"
7298            );
7299        }
7300
7301        // Unregister second route -> server stays alive (process-lifetime).
7302        registry.unregister("127.0.0.1", port).await;
7303        tokio::time::sleep(Duration::from_millis(20)).await;
7304        {
7305            let handle = cell
7306                .get()
7307                .expect("handle should still exist after last unregister");
7308            assert!(
7309                !handle.monitor_task.is_finished(),
7310                "monitor task should still be alive — server is process-lifetime"
7311            );
7312        }
7313
7314        // Entry stays in registry for potential restart.
7315        {
7316            let guard = registry.inner.lock().expect("lock");
7317            assert!(
7318                guard.entries.contains_key(&key),
7319                "entry should remain in registry — server kept alive for restart"
7320            );
7321        }
7322    }
7323
7324    // -----------------------------------------------------------------------
7325    // Staged listeners (itest-bound-ports Task 1)
7326    // -----------------------------------------------------------------------
7327
7328    /// CLONE-FIXTURE: bind a std listener on `127.0.0.1:0`, retain a blocking
7329    /// std clone (`probe`) so the port stays reserved, and hand the original
7330    /// socket to tokio as a non-blocking listener. `tokio::net::TcpListener`
7331    /// has no `try_clone`, so clones come from the std handle.
7332    async fn clone_fixture_listener() -> (
7333        tokio::net::TcpListener,
7334        std::net::TcpListener,
7335        std::net::SocketAddr,
7336    ) {
7337        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7338        let probe = l.try_clone().expect("clone probe");
7339        l.set_nonblocking(true).expect("set_nonblocking");
7340        let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7341        let addr = listener.local_addr().expect("local_addr");
7342        (listener, probe, addr)
7343    }
7344
7345    /// Default-limit constants the existing registry tests in this file use.
7346    fn staged_limits() -> (usize, usize, usize) {
7347        (1024 * 1024, 10 * 1024 * 1024, 1024)
7348    }
7349
7350    #[allow(clippy::await_holding_lock)]
7351    #[tokio::test]
7352    async fn staged_listener_first_spawn_serves_without_second_bind() {
7353        let _guard = lock_registry_test_mutex();
7354        ServerRegistry::reset();
7355        let registry = ServerRegistry::global();
7356        let (listener, _probe, addr) = clone_fixture_listener().await;
7357        let port = addr.port();
7358        registry
7359            .stage_listener(listener)
7360            .await
7361            .expect("stage listener");
7362
7363        let (max_req, max_res, max_inflight) = staged_limits();
7364        let routes = registry
7365            .get_or_spawn(
7366                "127.0.0.1",
7367                port,
7368                max_req,
7369                max_res,
7370                max_inflight,
7371                test_rt(),
7372                "staged-first-spawn".into(),
7373                None,
7374            )
7375            .await
7376            .expect("spawn from staged listener must succeed");
7377
7378        assert_eq!(
7379            registry.bound_addr("127.0.0.1", port),
7380            Some(addr),
7381            "served socket must be the staged listener's addr"
7382        );
7383        // The probe clone shares the socket, so service is proven by an HTTP
7384        // response, not by accepting on the probe.
7385        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7386            .await
7387            .expect("http request against staged listener must connect");
7388        assert!(
7389            resp.status().as_u16() >= 200,
7390            "any status proves the staged socket serves"
7391        );
7392        drop(routes);
7393    }
7394
7395    #[allow(clippy::await_holding_lock)]
7396    #[tokio::test]
7397    async fn staged_entry_reused_by_second_caller() {
7398        let _guard = lock_registry_test_mutex();
7399        ServerRegistry::reset();
7400        let registry = ServerRegistry::global();
7401        let (listener, _probe, addr) = clone_fixture_listener().await;
7402        let port = addr.port();
7403        registry
7404            .stage_listener(listener)
7405            .await
7406            .expect("stage listener");
7407
7408        let (max_req, max_res, max_inflight) = staged_limits();
7409        let first = registry
7410            .get_or_spawn(
7411                "127.0.0.1",
7412                port,
7413                max_req,
7414                max_res,
7415                max_inflight,
7416                test_rt(),
7417                "staged-reuse-1".into(),
7418                None,
7419            )
7420            .await
7421            .expect("first spawn from staged listener");
7422        let second = registry
7423            .get_or_spawn(
7424                "127.0.0.1",
7425                port,
7426                max_req,
7427                max_res,
7428                max_inflight,
7429                test_rt(),
7430                "staged-reuse-2".into(),
7431                None,
7432            )
7433            .await
7434            .expect("second caller must reuse the entry");
7435        assert_eq!(
7436            registry.bound_addr("127.0.0.1", port),
7437            Some(addr),
7438            "entry reused — bound addr unchanged, no second bind"
7439        );
7440        drop(first);
7441        drop(second);
7442    }
7443
7444    #[allow(clippy::await_holding_lock)]
7445    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7446    async fn staged_race_two_callers_single_resolver() {
7447        let _guard = lock_registry_test_mutex();
7448        ServerRegistry::reset();
7449        let registry = ServerRegistry::global();
7450        let (listener, _probe, addr) = clone_fixture_listener().await;
7451        let port = addr.port();
7452        registry
7453            .stage_listener(listener)
7454            .await
7455            .expect("stage listener");
7456
7457        // Two racing callers for the exact staged key: the staged listener
7458        // must be consumed by the single cell-init winner and served to
7459        // both — never leave the winner binding a port the loser still
7460        // holds (EADDRINUSE).
7461        let (max_req, max_res, max_inflight) = staged_limits();
7462        let (first, second) = tokio::join!(
7463            registry.get_or_spawn(
7464                "127.0.0.1",
7465                port,
7466                max_req,
7467                max_res,
7468                max_inflight,
7469                test_rt(),
7470                "staged-race-1".into(),
7471                None,
7472            ),
7473            registry.get_or_spawn(
7474                "127.0.0.1",
7475                port,
7476                max_req,
7477                max_res,
7478                max_inflight,
7479                test_rt(),
7480                "staged-race-2".into(),
7481                None,
7482            ),
7483        );
7484        let first = first.expect("first racing caller must succeed");
7485        let second = second.expect("second racing caller must succeed");
7486        assert_eq!(
7487            registry.bound_addr("127.0.0.1", port),
7488            Some(addr),
7489            "single entry must be served from the staged socket — no EADDRINUSE path"
7490        );
7491        drop(first);
7492        drop(second);
7493    }
7494
7495    #[allow(clippy::await_holding_lock)]
7496    #[tokio::test]
7497    async fn unstaged_spawn_binds_legacy() {
7498        let _guard = lock_registry_test_mutex();
7499        ServerRegistry::reset();
7500        let registry = ServerRegistry::global();
7501        // Fresh port P2: reserve then release — the legacy path rebinds.
7502        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
7503        let port = probe.local_addr().expect("local addr").port();
7504        drop(probe);
7505
7506        let (max_req, max_res, max_inflight) = staged_limits();
7507        registry
7508            .get_or_spawn(
7509                "127.0.0.1",
7510                port,
7511                max_req,
7512                max_res,
7513                max_inflight,
7514                test_rt(),
7515                "legacy-bind".into(),
7516                None,
7517            )
7518            .await
7519            .expect("legacy bind spawn");
7520        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
7521            .await
7522            .expect("connect to freshly bound port must succeed");
7523        assert!(resp.status().as_u16() >= 200);
7524        assert_eq!(
7525            registry.bound_addr("127.0.0.1", port),
7526            Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
7527            "bound addr must be the legacy bound (host, port)"
7528        );
7529    }
7530
7531    #[allow(clippy::await_holding_lock)]
7532    #[tokio::test]
7533    async fn wrong_host_staged_port_fails_deterministically() {
7534        let _guard = lock_registry_test_mutex();
7535        ServerRegistry::reset();
7536        let registry = ServerRegistry::global();
7537        let (listener, _probe, addr) = clone_fixture_listener().await;
7538        let port = addr.port();
7539        registry
7540            .stage_listener(listener)
7541            .await
7542            .expect("stage listener under 127.0.0.1");
7543
7544        let (max_req, max_res, max_inflight) = staged_limits();
7545        let err = registry
7546            .get_or_spawn(
7547                "localhost",
7548                port,
7549                max_req,
7550                max_res,
7551                max_inflight,
7552                test_rt(),
7553                "conflict-probe".into(),
7554                None,
7555            )
7556            .await
7557            .expect_err("wrong host on staged port must fail deterministically");
7558        assert!(
7559            err.to_string().contains("staged listener conflict on port"),
7560            "unexpected error: {err}"
7561        );
7562
7563        // Slot untouched by the failed call: the correct host now consumes it.
7564        registry
7565            .get_or_spawn(
7566                "127.0.0.1",
7567                port,
7568                max_req,
7569                max_res,
7570                max_inflight,
7571                test_rt(),
7572                "conflict-after".into(),
7573                None,
7574            )
7575            .await
7576            .expect("correct host must serve the staged listener");
7577        assert_eq!(
7578            registry.bound_addr("127.0.0.1", port),
7579            Some(addr),
7580            "staged slot must be untouched by the conflicting call"
7581        );
7582    }
7583
7584    #[allow(clippy::await_holding_lock)]
7585    #[tokio::test]
7586    async fn duplicate_stage_same_key_rejected() {
7587        let _guard = lock_registry_test_mutex();
7588        ServerRegistry::reset();
7589        let registry = ServerRegistry::global();
7590        let (listener, probe, addr) = clone_fixture_listener().await;
7591        registry
7592            .stage_listener(listener)
7593            .await
7594            .expect("stage listener A");
7595
7596        // Second tokio handle to the SAME socket: clone the std probe handle.
7597        let dup = probe.try_clone().expect("clone2");
7598        dup.set_nonblocking(true).expect("set_nonblocking2");
7599        let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
7600
7601        let err = registry
7602            .stage_listener(b)
7603            .await
7604            .expect_err("duplicate stage must be rejected");
7605        assert!(
7606            err.to_string().contains("listener already staged"),
7607            "unexpected error: {err}"
7608        );
7609
7610        let (max_req, max_res, max_inflight) = staged_limits();
7611        registry
7612            .get_or_spawn(
7613                "127.0.0.1",
7614                addr.port(),
7615                max_req,
7616                max_res,
7617                max_inflight,
7618                test_rt(),
7619                "dup-stage-after".into(),
7620                None,
7621            )
7622            .await
7623            .expect("spawn from first staged listener");
7624        assert_eq!(
7625            registry.bound_addr("127.0.0.1", addr.port()),
7626            Some(addr),
7627            "first staged listener retained"
7628        );
7629    }
7630
7631    #[allow(clippy::await_holding_lock)]
7632    #[tokio::test]
7633    async fn distinct_keys_stage_independently() {
7634        let _guard = lock_registry_test_mutex();
7635        ServerRegistry::reset();
7636        let registry = ServerRegistry::global();
7637        let (l1, _p1, addr1) = clone_fixture_listener().await;
7638        let (l2, _p2, addr2) = clone_fixture_listener().await;
7639        registry.stage_listener(l1).await.expect("stage P1");
7640        registry.stage_listener(l2).await.expect("stage P2");
7641
7642        let (max_req, max_res, max_inflight) = staged_limits();
7643        registry
7644            .get_or_spawn(
7645                "127.0.0.1",
7646                addr1.port(),
7647                max_req,
7648                max_res,
7649                max_inflight,
7650                test_rt(),
7651                "distinct-1".into(),
7652                None,
7653            )
7654            .await
7655            .expect("spawn P1");
7656        registry
7657            .get_or_spawn(
7658                "127.0.0.1",
7659                addr2.port(),
7660                max_req,
7661                max_res,
7662                max_inflight,
7663                test_rt(),
7664                "distinct-2".into(),
7665                None,
7666            )
7667            .await
7668            .expect("spawn P2");
7669        assert_eq!(
7670            registry.bound_addr("127.0.0.1", addr1.port()),
7671            Some(addr1),
7672            "P1 bound addr must be its own listener"
7673        );
7674        assert_eq!(
7675            registry.bound_addr("127.0.0.1", addr2.port()),
7676            Some(addr2),
7677            "P2 bound addr must be its own listener"
7678        );
7679        let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
7680            .await
7681            .expect("connect P1");
7682        assert!(r1.status().as_u16() >= 200);
7683        let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
7684            .await
7685            .expect("connect P2");
7686        assert!(r2.status().as_u16() >= 200);
7687    }
7688
7689    #[allow(clippy::await_holding_lock)]
7690    #[tokio::test]
7691    async fn tls_prebound_listener_served() {
7692        use camel_component_api::test_support::tls;
7693
7694        // Install rustls crypto provider (aws-lc-rs — matches the existing
7695        // TLS registry tests).
7696        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7697
7698        let _guard = lock_registry_test_mutex();
7699        ServerRegistry::reset();
7700        let registry = ServerRegistry::global();
7701        let (listener, _probe, addr) = clone_fixture_listener().await;
7702        let port = addr.port();
7703
7704        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7705        let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
7706        let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
7707        let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
7708
7709        let (max_req, max_res, max_inflight) = staged_limits();
7710        let routes = registry
7711            .get_or_spawn_with_listener(
7712                listener,
7713                max_req,
7714                max_res,
7715                max_inflight,
7716                test_rt(),
7717                "staged-tls".into(),
7718                Some(crate::config::ServerTlsConfig {
7719                    cert_path: cert_path.to_string_lossy().into_owned(),
7720                    key_path: key_path.to_string_lossy().into_owned(),
7721                }),
7722            )
7723            .await
7724            .expect("spawn TLS server from pre-bound listener");
7725
7726        // Client with CA cert — REAL verification (no danger_accept_invalid),
7727        // same helper pattern as the existing TLS registry tests.
7728        let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
7729        let client = reqwest::Client::builder()
7730            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
7731            .build()
7732            .expect("build tls client");
7733
7734        let resp = client
7735            .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
7736            .send()
7737            .await
7738            .expect("TLS handshake + request must succeed");
7739        assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
7740        assert_eq!(
7741            registry.bound_addr("127.0.0.1", port),
7742            Some(addr),
7743            "bound addr equals the pre-bound listener addr"
7744        );
7745        drop(routes);
7746    }
7747
7748    #[allow(clippy::await_holding_lock)]
7749    #[tokio::test]
7750    async fn with_listener_direct_spawn_keyed_by_actual_addr() {
7751        let _guard = lock_registry_test_mutex();
7752        ServerRegistry::reset();
7753        let registry = ServerRegistry::global();
7754        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7755            .await
7756            .expect("bind un-staged listener");
7757        let addr = listener.local_addr().expect("local addr");
7758        let port = addr.port();
7759
7760        let (max_req, max_res, max_inflight) = staged_limits();
7761        registry
7762            .get_or_spawn_with_listener(
7763                listener,
7764                max_req,
7765                max_res,
7766                max_inflight,
7767                test_rt(),
7768                "with-listener".into(),
7769                None,
7770            )
7771            .await
7772            .expect("direct spawn from un-staged listener");
7773        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
7774            .await
7775            .expect("connect on actual port");
7776        assert!(resp.status().as_u16() >= 200);
7777        assert_eq!(
7778            registry.bound_addr("127.0.0.1", port),
7779            Some(addr),
7780            "registry key is the listener's actual port"
7781        );
7782
7783        registry
7784            .get_or_spawn(
7785                "127.0.0.1",
7786                port,
7787                max_req,
7788                max_res,
7789                max_inflight,
7790                test_rt(),
7791                "with-listener-reuse".into(),
7792                None,
7793            )
7794            .await
7795            .expect("legacy caller must reuse the entry");
7796        assert_eq!(
7797            registry.bound_addr("127.0.0.1", port),
7798            Some(addr),
7799            "entry reused — no second bind"
7800        );
7801    }
7802
7803    // -----------------------------------------------------------------------
7804    // Axum dispatch handler tests
7805    // -----------------------------------------------------------------------
7806
7807    #[tokio::test]
7808    async fn test_dispatch_handler_returns_404_for_unknown_path() {
7809        let registry = HttpRouteRegistry::new();
7810        // Nothing registered in route registry
7811        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7812        let port = listener.local_addr().unwrap().port();
7813        tokio::spawn(run_axum_server(
7814            listener,
7815            registry,
7816            2 * 1024 * 1024,
7817            10 * 1024 * 1024,
7818            Arc::new(tokio::sync::Semaphore::new(1024)),
7819            test_rt(),
7820            "test-route".into(),
7821        ));
7822
7823        // Wait for server to start
7824        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7825
7826        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
7827            .await
7828            .unwrap();
7829        assert_eq!(resp.status().as_u16(), 404);
7830    }
7831
7832    // -----------------------------------------------------------------------
7833    // HttpConsumer tests
7834    // -----------------------------------------------------------------------
7835
7836    #[tokio::test]
7837    async fn test_http_consumer_start_registers_path() {
7838        use camel_component_api::ConsumerContext;
7839
7840        // Get an OS-assigned free port
7841        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7842        let port = listener.local_addr().unwrap().port();
7843        drop(listener); // Release port — ServerRegistry will rebind it
7844
7845        let consumer_cfg = HttpServerConfig {
7846            scheme: "http".to_string(),
7847            host: "127.0.0.1".to_string(),
7848            port,
7849            path: "/ping".to_string(),
7850            max_request_body: 2 * 1024 * 1024,
7851            max_response_body: 10 * 1024 * 1024,
7852            max_inflight_requests: 1024,
7853            method: None,
7854            tls_config: None,
7855        };
7856        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7857
7858        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7859        let token = tokio_util::sync::CancellationToken::new();
7860        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7861
7862        tokio::spawn(async move {
7863            consumer.start(ctx).await.unwrap();
7864        });
7865
7866        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7867
7868        let client = reqwest::Client::new();
7869        let resp_future = client
7870            .post(format!("http://127.0.0.1:{port}/ping"))
7871            .body("hello world")
7872            .send();
7873
7874        let (http_result, _) = tokio::join!(resp_future, async {
7875            if let Some(mut envelope) = rx.recv().await {
7876                // Set a custom status code
7877                envelope.exchange.input.set_header(
7878                    "CamelHttpResponseCode",
7879                    serde_json::Value::Number(201.into()),
7880                );
7881                if let Some(reply_tx) = envelope.reply_tx {
7882                    let _ = reply_tx.send(Ok(envelope.exchange));
7883                }
7884            }
7885        });
7886
7887        let resp = http_result.unwrap();
7888        assert_eq!(resp.status().as_u16(), 201);
7889
7890        token.cancel();
7891    }
7892
7893    /// rc-nftni (drainclaim): the raw-sender dispatch path mints a claim at
7894    /// the acceptance dequeue and carries it on the envelope. Exact totals:
7895    /// 1 while the envelope is held, 0 after release. No wall-clock sleeps —
7896    /// readiness is the startup signal, the recv IS the barrier.
7897    #[tokio::test]
7898    async fn http_consumer_raw_dispatch_carries_in_flight_claim() {
7899        use std::sync::atomic::{AtomicU64, Ordering};
7900
7901        use camel_component_api::ConsumerContext;
7902        use camel_component_api::StartupSignal;
7903
7904        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7905        let port = listener.local_addr().unwrap().port();
7906        drop(listener);
7907
7908        let consumer_cfg = HttpServerConfig {
7909            scheme: "http".to_string(),
7910            host: "127.0.0.1".to_string(),
7911            port,
7912            path: "/claim".to_string(),
7913            max_request_body: 2 * 1024 * 1024,
7914            max_response_body: 10 * 1024 * 1024,
7915            max_inflight_requests: 1024,
7916            method: None,
7917            tls_config: None,
7918        };
7919        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7920
7921        let counter = std::sync::Arc::new(AtomicU64::new(0));
7922        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7923        let token = tokio_util::sync::CancellationToken::new();
7924        let (signal, startup_rx) = StartupSignal::pair();
7925        let ctx = ConsumerContext::new(tx, token.clone(), "http-claim-route".to_string())
7926            .with_startup(signal)
7927            .with_in_flight_counter(std::sync::Arc::clone(&counter));
7928
7929        tokio::spawn(async move {
7930            consumer.start(ctx).await.unwrap();
7931        });
7932
7933        // Deterministic readiness: the consumer marks ready only after the
7934        // listener is bound and the path registered.
7935        tokio::time::timeout(std::time::Duration::from_secs(5), startup_rx.await_ready())
7936            .await
7937            .expect("startup must resolve within 5s")
7938            .expect("startup must be Ok");
7939
7940        let client = reqwest::Client::new();
7941        let (http_result, claim) = tokio::join!(
7942            client
7943                .post(format!("http://127.0.0.1:{port}/claim"))
7944                .body("hello")
7945                .send(),
7946            async {
7947                let mut envelope = rx.recv().await.expect("envelope must arrive");
7948                let claim = envelope
7949                    .in_flight_claim
7950                    .take()
7951                    .expect("raw dispatch must carry an acceptance-minted claim");
7952                assert_eq!(
7953                    counter.load(Ordering::Acquire),
7954                    1,
7955                    "exact total: the acceptance mint is the only live claim"
7956                );
7957                // Materialized reply body: echoing the request's Stream body
7958                // back would tie the response to the request-body stream
7959                // (not what this test exercises).
7960                let reply_tx = envelope.reply_tx.take().expect("reply channel must be set");
7961                let reply_exchange = Exchange::new(camel_component_api::Message::new(
7962                    camel_component_api::Body::Text("done".to_string()),
7963                ));
7964                reply_tx
7965                    .send(Ok(reply_exchange))
7966                    .expect("reply must be taken");
7967                claim
7968            },
7969        );
7970
7971        let resp = http_result.expect("http roundtrip must complete");
7972        assert_eq!(resp.status().as_u16(), 200);
7973
7974        drop(claim);
7975        assert_eq!(
7976            counter.load(Ordering::Acquire),
7977            0,
7978            "release exactly once when the holder drops"
7979        );
7980        token.cancel();
7981    }
7982
7983    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
7984    /// dispatcher's inflight semaphore so the semaphore stays the single
7985    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
7986    #[test]
7987    fn test_envelope_channel_capacity_follows_max_inflight() {
7988        assert_eq!(envelope_channel_capacity(0), 1);
7989        assert_eq!(envelope_channel_capacity(1), 1);
7990        assert_eq!(envelope_channel_capacity(7), 7);
7991        assert_eq!(envelope_channel_capacity(64), 64);
7992        assert_eq!(envelope_channel_capacity(1024), 1024);
7993    }
7994
7995    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
7996    /// configuration. Consumer start must not panic on it (the channel guard)
7997    /// and every request must get 503 from the empty semaphore.
7998    #[tokio::test]
7999    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
8000        use camel_component_api::ConsumerContext;
8001
8002        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8003        let port = listener.local_addr().unwrap().port();
8004        drop(listener);
8005
8006        let consumer_cfg = HttpServerConfig {
8007            scheme: "http".to_string(),
8008            host: "127.0.0.1".to_string(),
8009            port,
8010            path: "/ping".to_string(),
8011            max_request_body: 2 * 1024 * 1024,
8012            max_response_body: 10 * 1024 * 1024,
8013            max_inflight_requests: 0,
8014            method: None,
8015            tls_config: None,
8016        };
8017        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8018
8019        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8020        let token = tokio_util::sync::CancellationToken::new();
8021        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8022
8023        let start_handle = tokio::spawn(async move {
8024            consumer.start(ctx).await.unwrap();
8025        });
8026
8027        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8028
8029        let client = reqwest::Client::new();
8030        let resp = client
8031            .post(format!("http://127.0.0.1:{port}/ping"))
8032            .body("hello world")
8033            .send()
8034            .await
8035            .unwrap();
8036        assert_eq!(resp.status().as_u16(), 503);
8037
8038        token.cancel();
8039        let _ = start_handle.await;
8040    }
8041
8042    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
8043    /// waits for the listener bind before publishing RouteStarted.
8044    #[test]
8045    fn test_http_consumer_startup_mode_is_explicit() {
8046        use camel_component_api::ConsumerStartupMode;
8047        let consumer_cfg = HttpServerConfig {
8048            scheme: "http".to_string(),
8049            host: "127.0.0.1".to_string(),
8050            port: 0,
8051            path: "/x".to_string(),
8052            max_request_body: 2 * 1024 * 1024,
8053            max_response_body: 10 * 1024 * 1024,
8054            max_inflight_requests: 1024,
8055            method: None,
8056            tls_config: None,
8057        };
8058        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
8059        assert_eq!(
8060            consumer.startup_mode(),
8061            ConsumerStartupMode::Explicit,
8062            "HttpConsumer must opt into Explicit startup"
8063        );
8064    }
8065
8066    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
8067    /// + route registration. The StartupSignal resolves Ok only when that
8068    /// happens. Verified here by injecting our own signal pair into the
8069    /// ConsumerContext and asserting the receiver resolves within a bounded
8070    /// window even before any HTTP request is made.
8071    #[allow(clippy::await_holding_lock)]
8072    #[tokio::test]
8073    async fn test_http_consumer_emits_mark_ready_after_bind() {
8074        use camel_component_api::{ConsumerContext, StartupSignal};
8075
8076        let _guard = lock_registry_test_mutex();
8077
8078        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8079        let port = listener.local_addr().unwrap().port();
8080        drop(listener);
8081
8082        let consumer_cfg = HttpServerConfig {
8083            scheme: "http".to_string(),
8084            host: "127.0.0.1".to_string(),
8085            port,
8086            path: "/ready-probe".to_string(),
8087            max_request_body: 2 * 1024 * 1024,
8088            max_response_body: 10 * 1024 * 1024,
8089            max_inflight_requests: 1024,
8090            method: None,
8091            tls_config: None,
8092        };
8093        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8094
8095        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8096        let token = tokio_util::sync::CancellationToken::new();
8097        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
8098
8099        // Inject our own startup signal so we can observe mark_ready.
8100        let (signal, startup_rx) = StartupSignal::pair();
8101        let ctx = ctx.with_startup(signal);
8102
8103        // Spawn start() — it MUST call mark_ready once the listener is bound
8104        // and the path is registered.
8105        tokio::spawn(async move {
8106            let _ = consumer.start(ctx).await;
8107        });
8108
8109        // The receiver MUST resolve Ok within a bounded window — proving
8110        // mark_ready was called by start(). A short timeout catches the
8111        // regression where mark_ready is never called (the old behaviour
8112        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
8113        let result =
8114            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
8115                .await
8116                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
8117        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
8118
8119        // Cancellation tears down the spawned start() loop.
8120        token.cancel();
8121    }
8122
8123    // -----------------------------------------------------------------------
8124    // Shared-server death supervision (rc-szmob / ADR-0007)
8125    // -----------------------------------------------------------------------
8126
8127    /// RuntimeObservability stub that records every `increment_errors`
8128    /// `(route_id, label)` pair so tests can assert error counters.
8129    #[derive(Default, Clone)]
8130    struct ErrorRecordingRuntime {
8131        errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
8132    }
8133
8134    impl camel_api::MetricsCollector for ErrorRecordingRuntime {
8135        fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
8136        fn increment_errors(&self, route_id: &str, error_type: &str) {
8137            self.errors
8138                .lock()
8139                .expect("error recorder lock")
8140                .push((route_id.to_string(), error_type.to_string()));
8141        }
8142        fn increment_exchanges(&self, _route_id: &str) {}
8143        fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
8144        fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
8145    }
8146
8147    impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
8148        fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
8149    }
8150
8151    impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
8152        fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
8153            std::sync::Arc::new(self.clone())
8154        }
8155        fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
8156            std::sync::Arc::new(self.clone())
8157        }
8158    }
8159
8160    /// rc-szmob (ADR-0007 parity): when the shared Axum server task for a
8161    /// host:port dies, EVERY HttpConsumer hosted on that port must fail its
8162    /// `start()` with an Err — that Err is the signal camel-core's consumer
8163    /// watcher turns into a per-route CrashNotification → FailRoute →
8164    /// supervision backoff restart. Before the fix the consumers hung in
8165    /// `Running` forever (zombie routes): neither `ctx.cancelled()` nor
8166    /// `env_rx.recv()` fires when the server task dies, because the envelope
8167    /// senders live in the (still-alive) registry, not in the dead task.
8168    ///
8169    /// Deterministic by construction: readiness is awaited via the injected
8170    /// StartupSignal (no sleeps), the server is killed via its AbortHandle
8171    /// (real JoinError → monitor's unexpected-exit branch), and consumer
8172    /// resolution is bounded by a timeout — on unmodified behavior the
8173    /// timeout trips, which is exactly the zombie this test pins down.
8174    #[allow(clippy::await_holding_lock)]
8175    #[tokio::test]
8176    async fn shared_server_death_fails_every_hosted_consumer() {
8177        use camel_component_api::{ConsumerContext, StartupSignal};
8178
8179        let _guard = lock_registry_test_mutex();
8180        ServerRegistry::reset();
8181
8182        // Reserve a port, release it, let get_or_spawn bind it.
8183        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8184        let port = listener.local_addr().unwrap().port();
8185        drop(listener);
8186
8187        let rt = ErrorRecordingRuntime::default();
8188
8189        let make_consumer = |path: &str| {
8190            HttpConsumer::new(
8191                HttpServerConfig {
8192                    scheme: "http".to_string(),
8193                    host: "127.0.0.1".to_string(),
8194                    port,
8195                    path: path.to_string(),
8196                    max_request_body: 2 * 1024 * 1024,
8197                    max_response_body: 10 * 1024 * 1024,
8198                    max_inflight_requests: 16,
8199                    method: None,
8200                    tls_config: None,
8201                },
8202                std::sync::Arc::new(rt.clone()),
8203            )
8204        };
8205
8206        let spawn_consumer = |path: &str, route_id: &str| {
8207            let mut consumer = make_consumer(path);
8208            let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8209            let token = tokio_util::sync::CancellationToken::new();
8210            let ctx = ConsumerContext::new(tx, token, route_id.to_string());
8211            let (signal, startup_rx) = StartupSignal::pair();
8212            let ctx = ctx.with_startup(signal);
8213            let task = tokio::spawn(async move { consumer.start(ctx).await });
8214            (task, startup_rx)
8215        };
8216
8217        // Two routes hosted on the SAME shared server (same host:port).
8218        let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
8219        let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
8220
8221        // Both consumers registered and the server is up (bounded, no sleeps).
8222        for (name, ready) in [("a", ready_a), ("b", ready_b)] {
8223            let result =
8224                tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
8225                    .await
8226                    .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
8227            assert!(
8228                result.is_ok(),
8229                "consumer {name} readiness must resolve Ok (bind + registration complete)"
8230            );
8231        }
8232
8233        // Kill the shared server task: abort → JoinError → the monitor's
8234        // unexpected-exit branch. This is the real crash path (no mock).
8235        {
8236            let registry = ServerRegistry::global();
8237            let guard = registry.inner.lock().expect("ServerRegistry lock");
8238            let cell = guard
8239                .entries
8240                .get(&("127.0.0.1".to_string(), port))
8241                .expect("shared server entry must exist");
8242            let handle = cell.get().expect("server handle must be initialized");
8243            handle.server_abort.abort();
8244        }
8245
8246        // THE assertion: both hosted consumers must fail (bounded). On the
8247        // zombie bug they never resolve and this timeout trips.
8248        let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
8249            .await
8250            .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
8251        let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
8252            .await
8253            .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
8254
8255        let err_a = outcome_a
8256            .expect("consumer-a task must join")
8257            .expect_err("consumer-a start() must return Err when the shared server dies");
8258        let err_b = outcome_b
8259            .expect("consumer-b task must join")
8260            .expect_err("consumer-b start() must return Err when the shared server dies");
8261
8262        // The error must identify the dead shared transport (it flows into the
8263        // CrashNotification message camel-core records against the route).
8264        for (name, err) in [("a", &err_a), ("b", &err_b)] {
8265            assert!(
8266                err.to_string().contains("127.0.0.1")
8267                    && err.to_string().contains(&port.to_string()),
8268                "consumer-{name} error must name the dead shared server, got: {err}"
8269            );
8270        }
8271
8272        // Error counter regression guard: the monitor still records
8273        // `e:http:server-task-exited` for the route that spawned the server.
8274        let recorded = rt.errors.lock().expect("error recorder lock").clone();
8275        assert!(
8276            recorded
8277                .iter()
8278                .any(|(route, label)| label == "e:http:server-task-exited"
8279                    && route == "zombie-route-a"),
8280            "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
8281        );
8282    }
8283
8284    #[tokio::test]
8285    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
8286        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8287
8288        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8289        let port = listener.local_addr().unwrap().port();
8290        drop(listener);
8291
8292        let consumer_cfg = HttpServerConfig {
8293            scheme: "http".to_string(),
8294            host: "127.0.0.1".to_string(),
8295            port,
8296            path: "/saturation".to_string(),
8297            max_request_body: 2 * 1024 * 1024,
8298            max_response_body: 10 * 1024 * 1024,
8299            max_inflight_requests: 1,
8300            method: None,
8301            tls_config: None,
8302        };
8303        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8304
8305        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8306        let token = tokio_util::sync::CancellationToken::new();
8307        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8308        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8309        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8310
8311        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
8312        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
8313
8314        tokio::spawn(async move {
8315            let mut first_seen_tx = Some(first_seen_tx);
8316            let mut unblock_first_rx = Some(unblock_first_rx);
8317
8318            while let Some(envelope) = rx.recv().await {
8319                if let Some(tx) = first_seen_tx.take() {
8320                    let _ = tx.send(());
8321                    if let Some(rx_unblock) = unblock_first_rx.take() {
8322                        let _ = rx_unblock.await;
8323                    }
8324                }
8325
8326                if let Some(reply_tx) = envelope.reply_tx {
8327                    let _ = reply_tx.send(Ok(envelope.exchange));
8328                }
8329            }
8330        });
8331
8332        let client = reqwest::Client::new();
8333        let first_req = {
8334            let client = client.clone();
8335            async move {
8336                client
8337                    .get(format!("http://127.0.0.1:{port}/saturation"))
8338                    .send()
8339                    .await
8340                    .unwrap()
8341            }
8342        };
8343
8344        let first_handle = tokio::spawn(first_req);
8345        first_seen_rx.await.unwrap();
8346
8347        let second_resp = client
8348            .get(format!("http://127.0.0.1:{port}/saturation"))
8349            .send()
8350            .await
8351            .unwrap();
8352
8353        assert_eq!(second_resp.status().as_u16(), 503);
8354
8355        let _ = unblock_first_tx.send(());
8356        let first_resp = first_handle.await.unwrap();
8357        assert_eq!(first_resp.status().as_u16(), 200);
8358
8359        token.cancel();
8360    }
8361
8362    /// Audit 2026-08-31, F2-1: a chunked (no Content-Length) request body must
8363    /// still be capped — the byte limit travels with the stream, so any
8364    /// downstream materialization fails closed past `max_request_body`.
8365    #[tokio::test]
8366    async fn test_http_consumer_chunked_body_is_capped() {
8367        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8368
8369        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8370        let port = listener.local_addr().unwrap().port();
8371        drop(listener);
8372
8373        let consumer_cfg = HttpServerConfig {
8374            scheme: "http".to_string(),
8375            host: "127.0.0.1".to_string(),
8376            port,
8377            path: "/chunked-cap".to_string(),
8378            max_request_body: 1024, // tiny cap for the test
8379            max_response_body: 10 * 1024 * 1024,
8380            max_inflight_requests: 16,
8381            method: None,
8382            tls_config: None,
8383        };
8384        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8385
8386        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8387        let token = tokio_util::sync::CancellationToken::new();
8388        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8389        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8390        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8391
8392        // Chunked body: reqwest streams it without Content-Length.
8393        let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
8394            .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
8395            .collect();
8396        let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
8397
8398        let client = reqwest::Client::new();
8399        let send_fut = client
8400            .post(format!("http://127.0.0.1:{port}/chunked-cap"))
8401            .body(stream_body)
8402            .send();
8403
8404        let (http_result, _) = tokio::join!(send_fut, async {
8405            if let Some(mut envelope) = rx.recv().await {
8406                // The route materializes the body — the cap must fire.
8407                let materialized = envelope
8408                    .exchange
8409                    .input
8410                    .body
8411                    .clone()
8412                    .into_bytes(64 * 1024)
8413                    .await;
8414                assert!(
8415                    materialized.is_err(),
8416                    "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
8417                );
8418                let err = materialized.unwrap_err().to_string();
8419                assert!(
8420                    err.contains("limit") || err.contains("exceeds"),
8421                    "error should mention the limit: {err}"
8422                );
8423                if let Some(reply_tx) = envelope.reply_tx {
8424                    envelope.exchange.input.body =
8425                        camel_component_api::Body::Text("handled".to_string());
8426                    let _ = reply_tx.send(Ok(envelope.exchange));
8427                }
8428            }
8429        });
8430
8431        let resp = http_result.unwrap();
8432        assert_eq!(resp.status().as_u16(), 200);
8433
8434        token.cancel();
8435    }
8436
8437    #[tokio::test]
8438    #[allow(clippy::await_holding_lock)]
8439    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
8440        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8441
8442        let _guard = lock_registry_test_mutex();
8443
8444        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8445        let port = listener.local_addr().unwrap().port();
8446        drop(listener);
8447
8448        let consumer_cfg = HttpServerConfig {
8449            scheme: "http".to_string(),
8450            host: "127.0.0.1".to_string(),
8451            port,
8452            path: "/limit-bytes".to_string(),
8453            max_request_body: 2 * 1024 * 1024,
8454            max_response_body: 16,
8455            max_inflight_requests: 1024,
8456            method: None,
8457            tls_config: None,
8458        };
8459        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8460
8461        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8462        let token = tokio_util::sync::CancellationToken::new();
8463        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8464        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8465        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8466
8467        let client = reqwest::Client::new();
8468        let send_fut = client
8469            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
8470            .send();
8471
8472        let (http_result, _) = tokio::join!(send_fut, async {
8473            if let Some(mut envelope) = rx.recv().await {
8474                envelope.exchange.input.body =
8475                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
8476                if let Some(reply_tx) = envelope.reply_tx {
8477                    let _ = reply_tx.send(Ok(envelope.exchange));
8478                }
8479            }
8480        });
8481
8482        let resp = http_result.unwrap();
8483        assert_eq!(resp.status().as_u16(), 500);
8484        let body = resp.text().await.unwrap();
8485        assert_eq!(body, "Response body exceeds configured limit");
8486        token.cancel();
8487    }
8488
8489    #[tokio::test]
8490    #[allow(clippy::await_holding_lock)]
8491    async fn test_http_consumer_enforces_max_response_body_for_json() {
8492        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8493
8494        let _guard = lock_registry_test_mutex();
8495
8496        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8497        let port = listener.local_addr().unwrap().port();
8498        drop(listener);
8499
8500        let consumer_cfg = HttpServerConfig {
8501            scheme: "http".to_string(),
8502            host: "127.0.0.1".to_string(),
8503            port,
8504            path: "/limit-json".to_string(),
8505            max_request_body: 2 * 1024 * 1024,
8506            max_response_body: 16,
8507            max_inflight_requests: 1024,
8508            method: None,
8509            tls_config: None,
8510        };
8511        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8512
8513        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8514        let token = tokio_util::sync::CancellationToken::new();
8515        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8516        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8517        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8518
8519        let client = reqwest::Client::new();
8520        let send_fut = client
8521            .get(format!("http://127.0.0.1:{port}/limit-json"))
8522            .send();
8523
8524        let (http_result, _) = tokio::join!(send_fut, async {
8525            if let Some(mut envelope) = rx.recv().await {
8526                envelope.exchange.input.body = camel_component_api::Body::Json(
8527                    serde_json::json!({"message":"this response is bigger than sixteen"}),
8528                );
8529                if let Some(reply_tx) = envelope.reply_tx {
8530                    let _ = reply_tx.send(Ok(envelope.exchange));
8531                }
8532            }
8533        });
8534
8535        let resp = http_result.unwrap();
8536        assert_eq!(resp.status().as_u16(), 500);
8537        let body = resp.text().await.unwrap();
8538        assert_eq!(body, "Response body exceeds configured limit");
8539        token.cancel();
8540    }
8541
8542    #[tokio::test]
8543    #[allow(clippy::await_holding_lock)]
8544    async fn test_http_consumer_enforces_max_response_body_for_xml() {
8545        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8546
8547        let _guard = lock_registry_test_mutex();
8548
8549        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8550        let port = listener.local_addr().unwrap().port();
8551        drop(listener);
8552
8553        let consumer_cfg = HttpServerConfig {
8554            scheme: "http".to_string(),
8555            host: "127.0.0.1".to_string(),
8556            port,
8557            path: "/limit-xml".to_string(),
8558            max_request_body: 2 * 1024 * 1024,
8559            max_response_body: 16,
8560            max_inflight_requests: 1024,
8561            method: None,
8562            tls_config: None,
8563        };
8564        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8565
8566        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8567        let token = tokio_util::sync::CancellationToken::new();
8568        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8569        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8570        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8571
8572        let client = reqwest::Client::new();
8573        let send_fut = client
8574            .get(format!("http://127.0.0.1:{port}/limit-xml"))
8575            .send();
8576
8577        let (http_result, _) = tokio::join!(send_fut, async {
8578            if let Some(mut envelope) = rx.recv().await {
8579                envelope.exchange.input.body = camel_component_api::Body::Xml(
8580                    "<root><value>way-too-large</value></root>".into(),
8581                );
8582                if let Some(reply_tx) = envelope.reply_tx {
8583                    let _ = reply_tx.send(Ok(envelope.exchange));
8584                }
8585            }
8586        });
8587
8588        let resp = http_result.unwrap();
8589        assert_eq!(resp.status().as_u16(), 500);
8590        let body = resp.text().await.unwrap();
8591        assert_eq!(body, "Response body exceeds configured limit");
8592        token.cancel();
8593    }
8594
8595    #[tokio::test]
8596    #[allow(clippy::await_holding_lock)]
8597    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
8598        use camel_component_api::{
8599            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
8600        };
8601        use futures::stream;
8602
8603        let _guard = lock_registry_test_mutex();
8604
8605        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
8606        let port = listener.local_addr().unwrap().port();
8607        drop(listener);
8608
8609        let consumer_cfg = HttpServerConfig {
8610            scheme: "http".to_string(),
8611            host: "0.0.0.0".to_string(),
8612            port,
8613            path: "/limit-stream".to_string(),
8614            max_request_body: 2 * 1024 * 1024,
8615            max_response_body: 16,
8616            max_inflight_requests: 1024,
8617            method: None,
8618            tls_config: None,
8619        };
8620        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8621
8622        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8623        let token = tokio_util::sync::CancellationToken::new();
8624        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8625        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8626        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8627
8628        let client = reqwest::Client::new();
8629        let send_fut = client
8630            .get(format!("http://127.0.0.1:{port}/limit-stream"))
8631            .send();
8632
8633        let (http_result, _) = tokio::join!(send_fut, async {
8634            if let Some(mut envelope) = rx.recv().await {
8635                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
8636                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
8637                let stream = Box::pin(stream::iter(chunks));
8638                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
8639                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
8640                    metadata: StreamMetadata {
8641                        size_hint: Some(32),
8642                        content_type: Some("application/octet-stream".into()),
8643                        origin: None,
8644                    },
8645                });
8646                if let Some(reply_tx) = envelope.reply_tx {
8647                    let _ = reply_tx.send(Ok(envelope.exchange));
8648                }
8649            }
8650        });
8651
8652        let resp = http_result.unwrap();
8653        assert_eq!(resp.status().as_u16(), 200);
8654        let body = resp.bytes().await.unwrap();
8655        assert_eq!(body.len(), 32);
8656        token.cancel();
8657    }
8658
8659    // -----------------------------------------------------------------------
8660    // Integration tests
8661    // -----------------------------------------------------------------------
8662
8663    #[tokio::test]
8664    #[allow(clippy::await_holding_lock)]
8665    async fn test_integration_single_consumer_round_trip() {
8666        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8667
8668        // Spawns an HTTP consumer on the global ServerRegistry
8669        // (HttpConsumer::start → get_or_spawn). Serialize against the other
8670        // registry tests so parallel runs do not race on shared global state.
8671        let _guard = lock_registry_test_mutex();
8672
8673        // Get an OS-assigned free port (ephemeral)
8674        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8675        let port = listener.local_addr().unwrap().port();
8676        drop(listener); // Release — ServerRegistry will rebind
8677
8678        let component = HttpComponent::new();
8679        let endpoint_ctx = NoOpComponentContext;
8680        let endpoint = component
8681            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
8682            .unwrap();
8683        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8684
8685        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8686        let token = tokio_util::sync::CancellationToken::new();
8687        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8688
8689        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8690        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8691
8692        let client = reqwest::Client::new();
8693        let send_fut = client
8694            .post(format!("http://127.0.0.1:{port}/echo"))
8695            .header("Content-Type", "text/plain")
8696            .body("ping")
8697            .send();
8698
8699        let (http_result, _) = tokio::join!(send_fut, async {
8700            if let Some(mut envelope) = rx.recv().await {
8701                assert_eq!(
8702                    envelope.exchange.input.header("CamelHttpMethod"),
8703                    Some(&serde_json::Value::String("POST".into()))
8704                );
8705                assert_eq!(
8706                    envelope.exchange.input.header("CamelHttpPath"),
8707                    Some(&serde_json::Value::String("/echo".into()))
8708                );
8709                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
8710                if let Some(reply_tx) = envelope.reply_tx {
8711                    let _ = reply_tx.send(Ok(envelope.exchange));
8712                }
8713            }
8714        });
8715
8716        let resp = http_result.unwrap();
8717        assert_eq!(resp.status().as_u16(), 200);
8718        let body = resp.text().await.unwrap();
8719        assert_eq!(body, "pong");
8720
8721        token.cancel();
8722    }
8723
8724    #[tokio::test]
8725    #[allow(clippy::await_holding_lock)]
8726    async fn test_integration_two_consumers_shared_port() {
8727        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8728
8729        let _guard = lock_registry_test_mutex();
8730
8731        // Get an OS-assigned free port (ephemeral)
8732        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8733        let port = listener.local_addr().unwrap().port();
8734        drop(listener);
8735
8736        let component = HttpComponent::new();
8737        let endpoint_ctx = NoOpComponentContext;
8738
8739        // Consumer A: /hello
8740        let endpoint_a = component
8741            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
8742            .unwrap();
8743        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
8744
8745        // Consumer B: /world
8746        let endpoint_b = component
8747            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
8748            .unwrap();
8749        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
8750
8751        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8752        let token_a = tokio_util::sync::CancellationToken::new();
8753        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
8754
8755        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8756        let token_b = tokio_util::sync::CancellationToken::new();
8757        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
8758
8759        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
8760        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
8761        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8762
8763        let client = reqwest::Client::new();
8764
8765        // Request to /hello
8766        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
8767        let (resp_hello, _) = tokio::join!(fut_hello, async {
8768            if let Some(mut envelope) = rx_a.recv().await {
8769                envelope.exchange.input.body =
8770                    camel_component_api::Body::Text("hello-response".to_string());
8771                if let Some(reply_tx) = envelope.reply_tx {
8772                    let _ = reply_tx.send(Ok(envelope.exchange));
8773                }
8774            }
8775        });
8776
8777        // Request to /world
8778        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
8779        let (resp_world, _) = tokio::join!(fut_world, async {
8780            if let Some(mut envelope) = rx_b.recv().await {
8781                envelope.exchange.input.body =
8782                    camel_component_api::Body::Text("world-response".to_string());
8783                if let Some(reply_tx) = envelope.reply_tx {
8784                    let _ = reply_tx.send(Ok(envelope.exchange));
8785                }
8786            }
8787        });
8788
8789        let body_a = resp_hello.unwrap().text().await.unwrap();
8790        let body_b = resp_world.unwrap().text().await.unwrap();
8791
8792        assert_eq!(body_a, "hello-response");
8793        assert_eq!(body_b, "world-response");
8794
8795        token_a.cancel();
8796        token_b.cancel();
8797    }
8798
8799    #[tokio::test]
8800    #[allow(clippy::await_holding_lock)]
8801    async fn test_integration_unregistered_path_returns_404() {
8802        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8803
8804        let _guard = lock_registry_test_mutex();
8805
8806        // Get an OS-assigned free port (ephemeral)
8807        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8808        let port = listener.local_addr().unwrap().port();
8809        drop(listener);
8810
8811        let component = HttpComponent::new();
8812        let endpoint_ctx = NoOpComponentContext;
8813        let endpoint = component
8814            .create_endpoint(
8815                &format!("http://127.0.0.1:{port}/registered"),
8816                &endpoint_ctx,
8817            )
8818            .unwrap();
8819        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8820
8821        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8822        let token = tokio_util::sync::CancellationToken::new();
8823        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8824
8825        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8826
8827        // Wait until the server is actually accepting connections (CI runners can be slow).
8828        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8829        loop {
8830            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
8831                .await
8832                .is_ok()
8833            {
8834                break;
8835            }
8836            if std::time::Instant::now() >= deadline {
8837                panic!("HTTP server did not start within 5s on port {port}");
8838            }
8839            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8840        }
8841
8842        let client = reqwest::Client::new();
8843        let resp = client
8844            .get(format!("http://127.0.0.1:{port}/not-there"))
8845            .send()
8846            .await
8847            .unwrap();
8848        assert_eq!(resp.status().as_u16(), 404);
8849
8850        token.cancel();
8851    }
8852
8853    #[test]
8854    fn test_http_consumer_declares_concurrent() {
8855        use camel_component_api::ConcurrencyModel;
8856
8857        let config = HttpServerConfig {
8858            scheme: "http".to_string(),
8859            host: "127.0.0.1".to_string(),
8860            port: 19999,
8861            path: "/test".to_string(),
8862            max_request_body: 2 * 1024 * 1024,
8863            max_response_body: 10 * 1024 * 1024,
8864            max_inflight_requests: 1024,
8865            method: None,
8866            tls_config: None,
8867        };
8868        let consumer = HttpConsumer::new(config, test_rt());
8869        assert_eq!(
8870            consumer.concurrency_model(),
8871            ConcurrencyModel::Concurrent { max: None }
8872        );
8873    }
8874
8875    #[test]
8876    fn server_config_parses_tls_cert_and_key() {
8877        let cfg = HttpServerConfig::from_uri(
8878            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
8879        )
8880        .unwrap();
8881        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
8882        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
8883    }
8884
8885    #[test]
8886    fn server_config_no_tls_when_params_absent() {
8887        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
8888        assert!(cfg.tls_config.is_none());
8889    }
8890
8891    // -----------------------------------------------------------------------
8892    // HttpReplyBody streaming tests
8893    // -----------------------------------------------------------------------
8894
8895    #[tokio::test]
8896    async fn test_http_reply_body_stream_variant_exists() {
8897        use bytes::Bytes;
8898        use camel_component_api::CamelError;
8899        use futures::stream;
8900
8901        let chunks: Vec<Result<Bytes, CamelError>> =
8902            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
8903        let stream = Box::pin(stream::iter(chunks));
8904        let reply_body = HttpReplyBody::Stream(stream);
8905        // Si compila y el match funciona, el test pasa
8906        match reply_body {
8907            HttpReplyBody::Stream(_) => {}
8908            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
8909        }
8910    }
8911
8912    // -----------------------------------------------------------------------
8913    // OpenTelemetry propagation tests (only compiled with "otel" feature)
8914    // -----------------------------------------------------------------------
8915
8916    #[cfg(feature = "otel")]
8917    mod otel_tests {
8918        use super::*;
8919        use camel_component_api::Message;
8920        use tower::ServiceExt;
8921
8922        #[tokio::test]
8923        async fn test_producer_injects_traceparent_header() {
8924            let (url, _handle) = start_test_server_with_header_capture().await;
8925            let ctx = test_producer_ctx();
8926
8927            let component = HttpComponent::new();
8928            let endpoint_ctx = NoOpComponentContext;
8929            let endpoint = component
8930                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8931                .unwrap();
8932            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8933
8934            // Create exchange with an OTel context by extracting from a traceparent header
8935            let mut exchange = Exchange::new(Message::default());
8936            let mut headers = std::collections::HashMap::new();
8937            headers.insert(
8938                "traceparent".to_string(),
8939                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
8940            );
8941            camel_otel::extract_into_exchange(&mut exchange, &headers);
8942
8943            let result = producer.oneshot(exchange).await.unwrap();
8944
8945            // Verify request succeeded
8946            let status = result
8947                .input
8948                .header("CamelHttpResponseCode")
8949                .and_then(|v| v.as_u64())
8950                .unwrap();
8951            assert_eq!(status, 200);
8952
8953            // The test server echoes back the received traceparent header
8954            let traceparent = result.input.header("X-Received-Traceparent");
8955            assert!(
8956                traceparent.is_some(),
8957                "traceparent header should have been sent"
8958            );
8959
8960            let traceparent_str = traceparent.unwrap().as_str().unwrap();
8961            // Verify format: version-traceid-spanid-flags
8962            let parts: Vec<&str> = traceparent_str.split('-').collect();
8963            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8964            assert_eq!(parts[0], "00", "version should be 00");
8965            assert_eq!(
8966                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8967                "trace-id should match"
8968            );
8969            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
8970            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
8971        }
8972
8973        #[tokio::test]
8974        async fn test_consumer_extracts_traceparent_header() {
8975            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8976
8977            // Get an OS-assigned free port
8978            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8979            let port = listener.local_addr().unwrap().port();
8980            drop(listener);
8981
8982            let component = HttpComponent::new();
8983            let endpoint_ctx = NoOpComponentContext;
8984            let endpoint = component
8985                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8986                .unwrap();
8987            let mut consumer = endpoint.create_consumer(rt()).unwrap();
8988
8989            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8990            let token = tokio_util::sync::CancellationToken::new();
8991            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8992
8993            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8994            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8995
8996            // Send request with traceparent header
8997            let client = reqwest::Client::new();
8998            let send_fut = client
8999                .post(format!("http://127.0.0.1:{port}/trace"))
9000                .header(
9001                    "traceparent",
9002                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9003                )
9004                .body("test")
9005                .send();
9006
9007            let (http_result, _) = tokio::join!(send_fut, async {
9008                if let Some(envelope) = rx.recv().await {
9009                    // Verify the exchange has a valid OTel context by re-injecting it
9010                    // and checking the traceparent matches
9011                    let mut injected_headers = std::collections::HashMap::new();
9012                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9013
9014                    assert!(
9015                        injected_headers.contains_key("traceparent"),
9016                        "Exchange should have traceparent after extraction"
9017                    );
9018
9019                    let traceparent = injected_headers.get("traceparent").unwrap();
9020                    let parts: Vec<&str> = traceparent.split('-').collect();
9021                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9022                    assert_eq!(
9023                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9024                        "Trace ID should match the original traceparent header"
9025                    );
9026
9027                    if let Some(reply_tx) = envelope.reply_tx {
9028                        let _ = reply_tx.send(Ok(envelope.exchange));
9029                    }
9030                }
9031            });
9032
9033            let resp = http_result.unwrap();
9034            assert_eq!(resp.status().as_u16(), 200);
9035
9036            token.cancel();
9037        }
9038
9039        #[tokio::test]
9040        async fn test_consumer_extracts_mixed_case_traceparent_header() {
9041            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9042
9043            // Get an OS-assigned free port
9044            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9045            let port = listener.local_addr().unwrap().port();
9046            drop(listener);
9047
9048            let component = HttpComponent::new();
9049            let endpoint_ctx = NoOpComponentContext;
9050            let endpoint = component
9051                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
9052                .unwrap();
9053            let mut consumer = endpoint.create_consumer(rt()).unwrap();
9054
9055            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9056            let token = tokio_util::sync::CancellationToken::new();
9057            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9058
9059            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9060            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9061
9062            // Send request with MIXED-CASE TraceParent header (not lowercase)
9063            let client = reqwest::Client::new();
9064            let send_fut = client
9065                .post(format!("http://127.0.0.1:{port}/trace"))
9066                .header(
9067                    "TraceParent",
9068                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9069                )
9070                .body("test")
9071                .send();
9072
9073            let (http_result, _) = tokio::join!(send_fut, async {
9074                if let Some(envelope) = rx.recv().await {
9075                    // Verify the exchange has a valid OTel context by re-injecting it
9076                    // and checking the traceparent matches
9077                    let mut injected_headers = HashMap::new();
9078                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9079
9080                    assert!(
9081                        injected_headers.contains_key("traceparent"),
9082                        "Exchange should have traceparent after extraction from mixed-case header"
9083                    );
9084
9085                    let traceparent = injected_headers.get("traceparent").unwrap();
9086                    let parts: Vec<&str> = traceparent.split('-').collect();
9087                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9088                    assert_eq!(
9089                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9090                        "Trace ID should match the original mixed-case TraceParent header"
9091                    );
9092
9093                    if let Some(reply_tx) = envelope.reply_tx {
9094                        let _ = reply_tx.send(Ok(envelope.exchange));
9095                    }
9096                }
9097            });
9098
9099            let resp = http_result.unwrap();
9100            assert_eq!(resp.status().as_u16(), 200);
9101
9102            token.cancel();
9103        }
9104
9105        #[tokio::test]
9106        async fn test_producer_no_trace_context_no_crash() {
9107            let (url, _handle) = start_test_server().await;
9108            let ctx = test_producer_ctx();
9109
9110            let component = HttpComponent::new();
9111            let endpoint_ctx = NoOpComponentContext;
9112            let endpoint = component
9113                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
9114                .unwrap();
9115            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9116
9117            // Create exchange with default (empty) otel_context - no trace context
9118            let exchange = Exchange::new(Message::default());
9119
9120            // Should succeed without panic
9121            let result = producer.oneshot(exchange).await.unwrap();
9122
9123            // Verify request succeeded
9124            let status = result
9125                .input
9126                .header("CamelHttpResponseCode")
9127                .and_then(|v| v.as_u64())
9128                .unwrap();
9129            assert_eq!(status, 200);
9130        }
9131
9132        /// Test server that captures and echoes back the traceparent header
9133        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
9134            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9135            let addr = listener.local_addr().unwrap();
9136            let url = format!("http://127.0.0.1:{}", addr.port());
9137
9138            let handle = tokio::spawn(async move {
9139                loop {
9140                    if let Ok((mut stream, _)) = listener.accept().await {
9141                        tokio::spawn(async move {
9142                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
9143                            let mut buf = vec![0u8; 8192];
9144                            let n = stream.read(&mut buf).await.unwrap_or(0);
9145                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
9146
9147                            // Extract traceparent header from request
9148                            let traceparent = request
9149                                .lines()
9150                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
9151                                .map(|line| {
9152                                    line.split(':')
9153                                        .nth(1)
9154                                        .map(|s| s.trim().to_string())
9155                                        .unwrap_or_default()
9156                                })
9157                                .unwrap_or_default();
9158
9159                            let body =
9160                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
9161                            let response = format!(
9162                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
9163                                body.len(),
9164                                traceparent,
9165                                body
9166                            );
9167                            let _ = stream.write_all(response.as_bytes()).await;
9168                        });
9169                    }
9170                }
9171            });
9172
9173            (url, handle)
9174        }
9175    }
9176
9177    // -----------------------------------------------------------------------
9178    // Response streaming tests (Eje A - Task 2)
9179    // -----------------------------------------------------------------------
9180
9181    // -----------------------------------------------------------------------
9182    // Request streaming tests (Eje B - Task 3)
9183    // -----------------------------------------------------------------------
9184
9185    #[tokio::test]
9186    async fn test_request_body_arrives_as_stream() {
9187        use camel_component_api::Body;
9188        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9189
9190        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9191        let port = listener.local_addr().unwrap().port();
9192        drop(listener);
9193
9194        let component = HttpComponent::new();
9195        let endpoint_ctx = NoOpComponentContext;
9196        let endpoint = component
9197            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
9198            .unwrap();
9199        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9200
9201        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9202        let token = tokio_util::sync::CancellationToken::new();
9203        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9204
9205        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9206        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9207
9208        let client = reqwest::Client::new();
9209        let send_fut = client
9210            .post(format!("http://127.0.0.1:{port}/upload"))
9211            .body("hello streaming world")
9212            .send();
9213
9214        let (http_result, _) = tokio::join!(send_fut, async {
9215            if let Some(mut envelope) = rx.recv().await {
9216                // Body must be Body::Stream, not Body::Text or Body::Bytes
9217                assert!(
9218                    matches!(envelope.exchange.input.body, Body::Stream(_)),
9219                    "expected Body::Stream, got discriminant {:?}",
9220                    std::mem::discriminant(&envelope.exchange.input.body)
9221                );
9222                // Materialize to verify content
9223                let bytes = envelope
9224                    .exchange
9225                    .input
9226                    .body
9227                    .into_bytes(1024 * 1024)
9228                    .await
9229                    .unwrap();
9230                assert_eq!(&bytes[..], b"hello streaming world");
9231
9232                envelope.exchange.input.body = camel_component_api::Body::Empty;
9233                if let Some(reply_tx) = envelope.reply_tx {
9234                    let _ = reply_tx.send(Ok(envelope.exchange));
9235                }
9236            }
9237        });
9238
9239        let resp = http_result.unwrap();
9240        assert_eq!(resp.status().as_u16(), 200);
9241
9242        token.cancel();
9243    }
9244
9245    // -----------------------------------------------------------------------
9246    // Response streaming tests (Eje A - Task 2)
9247    // -----------------------------------------------------------------------
9248
9249    #[tokio::test]
9250    async fn test_streaming_response_chunked() {
9251        use bytes::Bytes;
9252        use camel_component_api::Body;
9253        use camel_component_api::CamelError;
9254        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9255        use camel_component_api::{StreamBody, StreamMetadata};
9256        use futures::stream;
9257        use std::sync::Arc;
9258        use tokio::sync::Mutex;
9259
9260        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9261        let port = listener.local_addr().unwrap().port();
9262        drop(listener);
9263
9264        let component = HttpComponent::new();
9265        let endpoint_ctx = NoOpComponentContext;
9266        let endpoint = component
9267            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
9268            .unwrap();
9269        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9270
9271        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9272        let token = tokio_util::sync::CancellationToken::new();
9273        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9274
9275        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9276        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9277
9278        let client = reqwest::Client::new();
9279        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
9280
9281        let (http_result, _) = tokio::join!(send_fut, async {
9282            if let Some(mut envelope) = rx.recv().await {
9283                // Respond with Body::Stream
9284                let chunks: Vec<Result<Bytes, CamelError>> =
9285                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
9286                let stream = Box::pin(stream::iter(chunks));
9287                envelope.exchange.input.body = Body::Stream(StreamBody {
9288                    stream: Arc::new(Mutex::new(Some(stream))),
9289                    metadata: StreamMetadata::default(),
9290                });
9291                if let Some(reply_tx) = envelope.reply_tx {
9292                    let _ = reply_tx.send(Ok(envelope.exchange));
9293                }
9294            }
9295        });
9296
9297        let resp = http_result.unwrap();
9298        assert_eq!(resp.status().as_u16(), 200);
9299        let body = resp.text().await.unwrap();
9300        assert_eq!(body, "chunk1chunk2");
9301
9302        token.cancel();
9303    }
9304
9305    // -----------------------------------------------------------------------
9306    // 413 Content-Length limit test (Task 4)
9307    // -----------------------------------------------------------------------
9308
9309    #[tokio::test]
9310    async fn test_413_when_content_length_exceeds_limit() {
9311        use camel_component_api::ConsumerContext;
9312
9313        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9314        let port = listener.local_addr().unwrap().port();
9315        drop(listener);
9316
9317        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
9318        let component = HttpComponent::new();
9319        let endpoint_ctx = NoOpComponentContext;
9320        let endpoint = component
9321            .create_endpoint(
9322                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
9323                &endpoint_ctx,
9324            )
9325            .unwrap();
9326        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9327
9328        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9329        let token = tokio_util::sync::CancellationToken::new();
9330        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9331
9332        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9333        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9334
9335        let client = reqwest::Client::new();
9336        let resp = client
9337            .post(format!("http://127.0.0.1:{port}/upload"))
9338            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
9339            .body("x".repeat(1000))
9340            .send()
9341            .await
9342            .unwrap();
9343
9344        assert_eq!(resp.status().as_u16(), 413);
9345
9346        token.cancel();
9347    }
9348
9349    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
9350    /// The spec says: "If there is no Content-Length, the limit does not apply at the
9351    /// consumer level — the route is responsible."
9352    #[tokio::test]
9353    async fn test_chunked_upload_without_content_length_bypasses_limit() {
9354        use bytes::Bytes;
9355        use camel_component_api::Body;
9356        use camel_component_api::ConsumerContext;
9357        use futures::stream;
9358
9359        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9360        let port = listener.local_addr().unwrap().port();
9361        drop(listener);
9362
9363        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
9364        let component = HttpComponent::new();
9365        let endpoint_ctx = NoOpComponentContext;
9366        let endpoint = component
9367            .create_endpoint(
9368                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
9369                &endpoint_ctx,
9370            )
9371            .unwrap();
9372        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9373
9374        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9375        let token = tokio_util::sync::CancellationToken::new();
9376        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9377
9378        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9379        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9380
9381        let client = reqwest::Client::new();
9382
9383        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
9384        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
9385        // but since there's no Content-Length the 413 check must NOT fire.
9386        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
9387            Ok(Bytes::from("y".repeat(50))),
9388            Ok(Bytes::from("y".repeat(50))),
9389        ];
9390        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
9391        let send_fut = client
9392            .post(format!("http://127.0.0.1:{port}/upload"))
9393            .body(stream_body)
9394            .send();
9395
9396        let consumer_fut = async {
9397            // Use timeout to avoid deadlock if the handler rejects before enqueueing
9398            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
9399                Ok(Some(mut envelope)) => {
9400                    assert!(
9401                        matches!(envelope.exchange.input.body, Body::Stream(_)),
9402                        "expected Body::Stream"
9403                    );
9404                    envelope.exchange.input.body = camel_component_api::Body::Empty;
9405                    if let Some(reply_tx) = envelope.reply_tx {
9406                        let _ = reply_tx.send(Ok(envelope.exchange));
9407                    }
9408                }
9409                Ok(None) => panic!("consumer channel closed unexpectedly"),
9410                Err(_) => {
9411                    // Timeout: the request was rejected before reaching the consumer.
9412                    // The HTTP response will carry the real status code (we check below).
9413                }
9414            }
9415        };
9416
9417        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
9418
9419        let resp = http_result.unwrap();
9420        // Audit 2026-08-31 (F2-1): the request is no longer rejected at the door
9421        // (no Content-Length to pre-check), but the byte cap now travels with the
9422        // stream: ANY materialization past maxRequestBody fails closed. This test
9423        // does not consume the body, so the request still completes with 200 —
9424        // enforcement happens at consumption time (see
9425        // test_http_consumer_chunked_body_is_capped).
9426        assert_ne!(
9427            resp.status().as_u16(),
9428            413,
9429            "chunked upload has no Content-Length to pre-check"
9430        );
9431        assert_eq!(resp.status().as_u16(), 200);
9432
9433        token.cancel();
9434    }
9435
9436    #[test]
9437    fn test_is_private_ip_ranges() {
9438        use camel_api::is_ssrf_blocked_ip;
9439        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
9440        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
9441        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
9442        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
9443        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
9444        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
9445
9446        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
9447        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
9448        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
9449        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
9450        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
9451        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
9452        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
9453        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
9454
9455        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
9456        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
9457        assert!(!is_ssrf_blocked_ip(
9458            &"2001:4860:4860::8888".parse().unwrap()
9459        )); // allow-unwrap
9460    }
9461
9462    #[test]
9463    fn test_title_case_header() {
9464        assert_eq!(title_case_header("content-type"), "Content-Type");
9465        assert_eq!(title_case_header("authorization"), "Authorization");
9466        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
9467        assert_eq!(title_case_header("host"), "Host");
9468        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
9469        assert_eq!(title_case_header("single"), "Single");
9470        assert_eq!(title_case_header(""), "");
9471    }
9472
9473    #[test]
9474    fn test_resolve_url_combines_path_and_query_sources() {
9475        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
9476        let mut exchange = Exchange::new(Message::default());
9477        exchange.input.set_header(
9478            "CamelHttpPath",
9479            serde_json::Value::String("next".to_string()),
9480        );
9481        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9482        assert!(url.starts_with("http://example.com/base/next?"));
9483        assert!(url.contains("foo=bar"));
9484
9485        exchange.input.set_header(
9486            "CamelHttpUri",
9487            serde_json::Value::String("http://other.test/root".to_string()),
9488        );
9489        exchange.input.set_header(
9490            "CamelHttpQuery",
9491            serde_json::Value::String("a=1&b=2".to_string()),
9492        );
9493
9494        let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9495        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
9496    }
9497
9498    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
9499        let mut exchange = Exchange::new(Message::default());
9500        exchange
9501            .input
9502            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
9503        exchange.input.set_header(
9504            "CamelHttpQuery",
9505            serde_json::Value::String(query.to_string()),
9506        );
9507        exchange
9508    }
9509
9510    #[test]
9511    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
9512        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9513        cfg.bridge_endpoint = true;
9514        cfg.query_params
9515            .push(("token".to_string(), "secret".to_string()));
9516        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9517        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9518        // Verbatim assembly: the old round-trip normalized the empty base
9519        // path to `/` (`http://x/?token=secret`); authored bytes end-to-end
9520        // no longer insert it.
9521        assert_eq!(url, "http://x?token=secret");
9522        assert!(!url.contains("/foo"));
9523        assert!(!url.contains("dropme"));
9524    }
9525
9526    #[test]
9527    fn resolve_url_bridge_endpoint_false_merges_path() {
9528        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9529        cfg.bridge_endpoint = false;
9530        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9531        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9532        assert!(url.contains("/foo"), "url should contain /foo: {url}");
9533        assert!(
9534            url.contains("dropme=1"),
9535            "url should contain dropme=1: {url}"
9536        );
9537    }
9538
9539    #[test]
9540    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
9541        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9542        cfg.bridge_endpoint = true;
9543        let mut exchange = Exchange::new(Message::default());
9544        exchange.input.set_header(
9545            "CamelHttpPath",
9546            serde_json::Value::String("/foo".to_string()),
9547        );
9548        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9549        assert_eq!(url, "http://x");
9550        assert!(!url.contains("/foo"));
9551    }
9552
9553    #[test]
9554    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
9555        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9556        cfg.bridge_endpoint = true;
9557        // query_params stays empty ([])
9558        let mut exchange = Exchange::new(Message::default());
9559        exchange.input.set_header(
9560            "CamelHttpUri",
9561            serde_json::Value::String("http://dest/explicit".to_string()),
9562        );
9563        exchange.input.set_header(
9564            "CamelHttpPath",
9565            serde_json::Value::String("/foo".to_string()),
9566        );
9567        exchange.input.set_header(
9568            "CamelHttpQuery",
9569            serde_json::Value::String("x=1".to_string()),
9570        );
9571        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9572        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
9573        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
9574        // wins verbatim.
9575        assert_eq!(url, "http://x");
9576    }
9577
9578    #[test]
9579    fn bridge_programmatic_params_use_percent20() {
9580        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9581        cfg.bridge_endpoint = true;
9582        cfg.query_params = vec![("b".to_string(), "x y".to_string())];
9583        let exchange = Exchange::new(Message::default());
9584
9585        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9586
9587        // `%20 never +` is global for programmatic values — the bridge arm
9588        // uses the same encoder as the non-bridge path. Bridging
9589        // semantics (what gets bridged, precedence) are unchanged.
9590        assert_eq!(url, "http://x?b=x%20y");
9591        assert!(!url.contains('+'));
9592    }
9593
9594    #[test]
9595    fn bridge_arm_carries_authored_raw_query() {
9596        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9597        // bridgeEndpoint is consumed as an endpoint option; a=1 is the
9598        // authored leftover riding raw_query.
9599        let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
9600
9601        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9602
9603        // Authored leftovers ride under bridging (Apache Camel semantics):
9604        // query is a=1 in authored bytes; exchange path/query stay ignored.
9605        assert_eq!(url, "http://h/p?a=1");
9606        assert!(!url.contains("dropme"), "exchange query leaked: {url}");
9607        assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
9608    }
9609
9610    // -----------------------------------------------------------------------
9611    // Bridge arm verbatim assembly (Papal Direction A): the bridged base is
9612    // never round-tripped through `url::Url` normalization — authored bytes
9613    // end-to-end, identical assembly to every other resolve_url arm.
9614    // -----------------------------------------------------------------------
9615
9616    #[test]
9617    fn resolve_url_bridge_preserves_dot_segments() {
9618        let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
9619        cfg.bridge_endpoint = true;
9620        cfg.query_params.push(("k".to_string(), "1".to_string()));
9621        let exchange = Exchange::new(Message::default());
9622
9623        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9624
9625        // Dot segments are authored bytes; the old round-trip collapsed
9626        // them (`/a/../b` → `/b`). Verbatim keeps them.
9627        assert_eq!(url, "http://h/a/../b?k=1");
9628    }
9629
9630    #[test]
9631    fn resolve_url_bridge_preserves_default_port() {
9632        let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
9633        cfg.bridge_endpoint = true;
9634        cfg.query_params.push(("k".to_string(), "1".to_string()));
9635        let exchange = Exchange::new(Message::default());
9636
9637        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9638
9639        // The old round-trip stripped the default port `:80`. Verbatim
9640        // keeps it.
9641        assert_eq!(url, "http://h:80/p?k=1");
9642    }
9643
9644    #[test]
9645    fn resolve_url_bridge_preserves_scheme_and_host_case() {
9646        let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
9647        cfg.bridge_endpoint = true;
9648        cfg.query_params.push(("k".to_string(), "1".to_string()));
9649        // `from_uri`'s scheme validation is case-sensitive, so the scheme
9650        // case is applied on the stored base directly — the resolve path
9651        // must carry whatever bytes the operator authored.
9652        cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
9653        let exchange = Exchange::new(Message::default());
9654
9655        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9656
9657        // The old round-trip lowercased scheme and host. Verbatim keeps
9658        // both authored.
9659        assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
9660    }
9661
9662    #[test]
9663    fn resolve_url_bridge_no_query_emits_base_verbatim() {
9664        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9665        cfg.bridge_endpoint = true;
9666        let exchange = Exchange::new(Message::default());
9667
9668        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9669
9670        // No resolved query: exactly the authored base — no synthetic `/`,
9671        // no dangling `?`.
9672        assert_eq!(url, "http://h/p");
9673    }
9674
9675    #[test]
9676    fn resolve_url_bridge_and_non_bridge_byte_identical() {
9677        // (a) Bridged arm: the effective query comes from programmatic
9678        // query_params.
9679        let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9680        bridged.bridge_endpoint = true;
9681        bridged
9682            .query_params
9683            .push(("k".to_string(), "1".to_string()));
9684        let bridge_url =
9685            HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
9686
9687        // (b) Non-bridge CamelHttpQuery composition path: same effective
9688        // query riding the exchange header.
9689        let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9690        let mut exchange = Exchange::new(Message::default());
9691        exchange.input.set_header(
9692            "CamelHttpQuery",
9693            serde_json::Value::String("k=1".to_string()),
9694        );
9695        let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
9696
9697        assert_eq!(bridge_url, plain_url);
9698        assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
9699    }
9700
9701    #[test]
9702    fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
9703        let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
9704        cfg.bridge_endpoint = true;
9705        cfg.query_params.push(("k".to_string(), "1".to_string()));
9706        let exchange = Exchange::new(Message::default());
9707
9708        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9709
9710        assert_eq!(url, "http://[::1]:8080/p?k=1");
9711    }
9712
9713    #[test]
9714    fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
9715        let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
9716        let exchange = Exchange::new(Message::default());
9717
9718        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9719
9720        // Authored query on an empty base path: the old round-trip
9721        // inserted a synthetic `/` (`http://h/?x=1`); verbatim does not.
9722        assert_eq!(url, "http://h?x=1");
9723    }
9724
9725    // -----------------------------------------------------------------------
9726    // Raw-preserving outbound query serialization (http-query-wire-fidelity)
9727    // -----------------------------------------------------------------------
9728
9729    #[test]
9730    fn resolve_url_preserves_authored_query_order_and_bytes() {
9731        let config =
9732            HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
9733        let exchange = Exchange::new(Message::default());
9734
9735        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9736
9737        // Authored order, authored separators, no %2C/%3A re-encoding,
9738        // consumed option (connectTimeout) removed.
9739        assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
9740    }
9741
9742    #[test]
9743    fn resolve_url_consumes_encoded_option_key() {
9744        let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
9745        let exchange = Exchange::new(Message::default());
9746
9747        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9748
9749        // The raw filter matches the decoded key, not the encoded bytes.
9750        assert_eq!(url, "http://h/p?a=1");
9751    }
9752
9753    #[test]
9754    fn resolve_url_all_options_consumed_drops_query() {
9755        let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
9756        let exchange = Exchange::new(Message::default());
9757
9758        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9759
9760        // A non-empty query whose every pair was consumed drops the query
9761        // component entirely — no dangling `?`.
9762        assert_eq!(url, "http://h/p");
9763        assert!(!url.contains('?'));
9764    }
9765
9766    #[test]
9767    fn resolve_url_preserves_empty_query_marker() {
9768        let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
9769        let exchange = Exchange::new(Message::default());
9770
9771        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9772
9773        // A bare `?` marker is preserved distinctly, never conflated with
9774        // an all-consumed query.
9775        assert_eq!(url, "http://h/p?");
9776    }
9777
9778    #[test]
9779    fn resolve_url_raw_wrapper_not_re_encoded() {
9780        let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
9781        let exchange = Exchange::new(Message::default());
9782
9783        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9784
9785        // RAW(...) wrapper bytes survive exactly as authored (rc-g4isv).
9786        assert_eq!(url, "http://h/p?token=RAW(abc)");
9787        assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
9788    }
9789
9790    #[test]
9791    fn resolve_url_camel_http_query_composes_verbatim_span() {
9792        let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
9793        let mut exchange = Exchange::new(Message::default());
9794        exchange.input.set_header(
9795            "CamelHttpQuery",
9796            serde_json::Value::String("userFilter=a%2Cb".to_string()),
9797        );
9798
9799        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9800
9801        // Policy change (ADR-0071): the header no longer replaces the
9802        // endpoint query — it composes, the endpoint winning collisions.
9803        // The header span bytes still ride verbatim: `a%2Cb` is carried
9804        // as-authored, never re-encoded (no %252C).
9805        assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
9806        assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
9807    }
9808
9809    // -----------------------------------------------------------------------
9810    // Outbound query composition (http-contract-surface, ADR-0071)
9811    // -----------------------------------------------------------------------
9812
9813    #[test]
9814    fn header_composes_with_endpoint_query() {
9815        let config =
9816            HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
9817        let mut exchange = Exchange::new(Message::default());
9818        exchange.input.set_header(
9819            "CamelHttpQuery",
9820            serde_json::Value::String("lang=es&page=2".to_string()),
9821        );
9822
9823        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9824
9825        // Higher precedence (endpoint) wins collisions: `lang` stays `en`;
9826        // the header appends only its absent keys.
9827        assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
9828    }
9829
9830    #[test]
9831    fn header_alone_still_rides() {
9832        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9833        let mut exchange = Exchange::new(Message::default());
9834        exchange.input.set_header(
9835            "CamelHttpQuery",
9836            serde_json::Value::String("page=2".to_string()),
9837        );
9838
9839        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9840
9841        // No endpoint query: the header pairs are the whole query.
9842        assert_eq!(url, "http://upstream/api?page=2");
9843    }
9844
9845    #[test]
9846    fn empty_reflected_query_leaves_endpoint_query_intact() {
9847        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9848        let mut exchange = Exchange::new(Message::default());
9849        // The consumer installs an empty CamelHttpQuery on requests that
9850        // arrived without a query string.
9851        exchange
9852            .input
9853            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9854
9855        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9856
9857        // No second `?` marker, no dropped endpoint pair.
9858        assert_eq!(url, "http://upstream/api?apiKey=secret");
9859        assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
9860    }
9861
9862    #[test]
9863    fn forbidden_byte_in_header_query_errors() {
9864        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9865        let mut exchange = Exchange::new(Message::default());
9866        exchange.input.set_header(
9867            "CamelHttpQuery",
9868            serde_json::Value::String("q=ab<cd".to_string()),
9869        );
9870
9871        let err = HttpProducer::resolve_url(&exchange, &config)
9872            .unwrap_err()
9873            .to_string();
9874
9875        // Fail loud naming the forbidden byte (`<` = 0x3C); a resolve
9876        // error means no URL is emitted, never a re-encoded one.
9877        assert!(err.contains("0x3C"), "error must name the byte: {err}");
9878    }
9879
9880    #[test]
9881    fn override_uri_with_query_plus_header_query() {
9882        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9883        let mut exchange = Exchange::new(Message::default());
9884        exchange.input.set_header(
9885            "CamelHttpUri",
9886            serde_json::Value::String("http://host/api?a=1".to_string()),
9887        );
9888        exchange.input.set_header(
9889            "CamelHttpQuery",
9890            serde_json::Value::String("a=2&b=3".to_string()),
9891        );
9892
9893        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9894
9895        // Pair-level merge with a single `?`: the override's `a=1` wins
9896        // the collision, the header appends `b=3` — no `?a=1?a=2` concat.
9897        assert_eq!(url, "http://host/api?a=1&b=3");
9898    }
9899
9900    #[test]
9901    fn path_applies_before_query_composition() {
9902        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9903        let mut exchange = Exchange::new(Message::default());
9904        exchange.input.set_header(
9905            "CamelHttpUri",
9906            serde_json::Value::String("http://host/api?a=1".to_string()),
9907        );
9908        exchange.input.set_header(
9909            "CamelHttpPath",
9910            serde_json::Value::String("/extra".to_string()),
9911        );
9912        exchange.input.set_header(
9913            "CamelHttpQuery",
9914            serde_json::Value::String("b=2".to_string()),
9915        );
9916
9917        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9918
9919        // CamelHttpPath applies to the override base without its query,
9920        // then the query composes.
9921        assert_eq!(url, "http://host/api/extra?a=1&b=2");
9922    }
9923
9924    #[test]
9925    fn plain_proxy_reflection_composes() {
9926        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9927        // Headers as the consumer installs them from the wire.
9928        let exchange = exchange_with_path_and_query("/in/extra", "page=2");
9929
9930        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9931
9932        // Reflection rides by default and composes: the operator pair is
9933        // not replaced (rc-k3pir parity).
9934        assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
9935    }
9936
9937    #[test]
9938    fn bridge_endpoint_ignores_url_headers() {
9939        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9940        let mut exchange = Exchange::new(Message::default());
9941        exchange.input.set_header(
9942            "CamelHttpUri",
9943            serde_json::Value::String("http://evil.test/x".to_string()),
9944        );
9945        exchange.input.set_header(
9946            "CamelHttpPath",
9947            serde_json::Value::String("/foo".to_string()),
9948        );
9949        exchange.input.set_header(
9950            "CamelHttpQuery",
9951            serde_json::Value::String("z=9".to_string()),
9952        );
9953
9954        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9955
9956        // All three URL headers ignored; the endpoint base plus its own
9957        // (consumed-option-filtered) query is sent, exactly as before.
9958        assert_eq!(url, "http://h/p?a=1");
9959        assert!(!url.contains("evil"), "override leaked: {url}");
9960        assert!(!url.contains("z=9"), "header query leaked: {url}");
9961        assert!(!url.contains("/foo"), "header path leaked: {url}");
9962    }
9963
9964    #[test]
9965    fn resolve_url_programmatic_params_use_percent20_deterministic() {
9966        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9967        config.query_params = vec![
9968            ("b".to_string(), "x y".to_string()),
9969            ("a".to_string(), "1".to_string()),
9970        ];
9971        let exchange = Exchange::new(Message::default());
9972
9973        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9974
9975        // Declaration order (not lexical), minimal RFC-3986 encoding,
9976        // `%20` — never `+` — for spaces.
9977        assert_eq!(url, "http://h/p?b=x%20y&a=1");
9978        assert!(!url.contains('+'));
9979    }
9980
9981    #[test]
9982    fn resolve_url_authored_and_programmatic_merge() {
9983        let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
9984        config.query_params = vec![
9985            ("b".to_string(), "2".to_string()),
9986            ("a".to_string(), "9".to_string()),
9987        ];
9988        let exchange = Exchange::new(Message::default());
9989
9990        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9991
9992        // Programmatic `b` appended (absent from raw); programmatic `a=9`
9993        // ignored (authored key wins); no duplication.
9994        assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
9995    }
9996
9997    #[test]
9998    fn from_uri_no_longer_fills_query_params_from_uri() {
9999        let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
10000
10001        // Authored pairs live in raw_query ONLY (provenance pin).
10002        assert!(
10003            config.query_params.is_empty(),
10004            "query_params is programmatic-only: {:?}",
10005            config.query_params
10006        );
10007        assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
10008    }
10009
10010    #[test]
10011    fn resolve_url_forbidden_raw_byte_errors() {
10012        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10013        config.raw_query = Some("a=x y".to_string());
10014        let exchange = Exchange::new(Message::default());
10015
10016        let err = HttpProducer::resolve_url(&exchange, &config)
10017            .expect_err("literal space in raw query must error");
10018
10019        // The error names the forbidden byte; no output string is produced.
10020        assert!(
10021            err.to_string().contains("0x20"),
10022            "error must name the forbidden byte: {err}"
10023        );
10024    }
10025
10026    /// rc-m4xk1: the override URI's own query is span-validated at resolve
10027    /// time — a forbidden byte in the override arm errors naming the byte,
10028    /// instead of riding verbatim to a reqwest send error.
10029    #[test]
10030    fn resolve_url_override_query_forbidden_byte_errors() {
10031        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10032        let mut exchange = Exchange::new(Message::default());
10033        exchange.input.set_header(
10034            "CamelHttpUri",
10035            serde_json::Value::String("http://h2/p?a=x y".to_string()),
10036        );
10037
10038        let err = HttpProducer::resolve_url(&exchange, &config)
10039            .expect_err("literal space in the override URI's query must error");
10040
10041        assert!(
10042            err.to_string().contains("0x20"),
10043            "error must name the forbidden byte from the override query: {err}"
10044        );
10045    }
10046
10047    /// rc-m4xk1 pin: decoded-key collision — a header pair whose key decodes
10048    /// to a key already present in the higher-precedence query (here
10049    /// `%61=2`, decoding to `a`) is dropped by the shared decoded-key
10050    /// matching; the higher-precedence authored span rides verbatim.
10051    #[test]
10052    fn merge_header_query_decoded_key_collision_drops_header_pair() {
10053        let merged = merge_header_query(Some("a=1"), "%61=2")
10054            .expect("decoded-key collision must not be a parse error");
10055        assert_eq!(
10056            merged.as_deref(),
10057            Some("a=1"),
10058            "the higher-precedence span wins and the colliding header pair is dropped"
10059        );
10060    }
10061
10062    /// rc-m4xk1 pin: duplicate keys within ONE header query are not
10063    /// deduplicated — both spans ride verbatim in authored order.
10064    #[test]
10065    fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
10066        let merged = merge_header_query(None, "k=1&k=2")
10067            .expect("duplicate header keys must not be a parse error");
10068        assert_eq!(
10069            merged.as_deref(),
10070            Some("k=1&k=2"),
10071            "intra-header duplicate keys ride verbatim"
10072        );
10073    }
10074
10075    /// rc-dhkeo: the Debug surface masks userinfo-style bytes in
10076    /// `base_url` and leaves a userinfo-free base untouched, byte-for-byte.
10077    /// rc-yvjp3 (ADR-0076 strictest-wins): `base_url` routes through the
10078    /// canonical `camel_api::redact::redact_url` — query and fragment bytes
10079    /// now drop behind their sentinels and later `//user:pass@` windows
10080    /// mask too, dimensions the former byte-preserving local variant kept.
10081    #[test]
10082    fn endpoint_config_debug_masks_base_url_userinfo() {
10083        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10084        config.base_url = "http://user:pass@h.example/p".to_string();
10085        let rendered = format!("{config:?}");
10086        assert!(
10087            rendered.contains("***@h.example"),
10088            "userinfo must render masked: {rendered}"
10089        );
10090        assert!(
10091            !rendered.contains("user:pass"),
10092            "no credentials in Debug output: {rendered}"
10093        );
10094
10095        let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10096        let rendered_plain = format!("{plain:?}");
10097        assert!(
10098            rendered_plain.contains("http://h.example/p"),
10099            "a base without userinfo renders unchanged: {rendered_plain}"
10100        );
10101    }
10102
10103    /// rc-yvjp3 convergence: an authored query and fragment on `base_url`
10104    /// render as sentinels, never as raw bytes (strictest-wins over the
10105    /// former byte-preserving variant), and the rendered value is
10106    /// byte-identical to the canonical helper.
10107    #[test]
10108    fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
10109        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10110
10111        config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
10112        let rendered = format!("{config:?}");
10113        assert!(
10114            rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
10115            "query and fragment must render as composed sentinels: {rendered}"
10116        );
10117        assert!(
10118            !rendered.contains("token=secret") && !rendered.contains("access_token"),
10119            "query/fragment credential bytes must not render: {rendered}"
10120        );
10121
10122        config.base_url = "http://h.example//u2:p2@evil/".to_string();
10123        let rendered = format!("{config:?}");
10124        assert!(
10125            rendered.contains("base_url: \"http://h.example//***@evil/\""),
10126            "later //window userinfo must mask (canonical window rule): {rendered}"
10127        );
10128        assert!(
10129            !rendered.contains("u2:p2"),
10130            "later-window credentials must not render: {rendered}"
10131        );
10132
10133        // Cross-surface identity: the Debug field is byte-identical to the
10134        // canonical helper output for the same input.
10135        config.base_url = "http://user:pass@h.example/p?token=x".to_string();
10136        let canonical = camel_api::redact::redact_url(&config.base_url);
10137        assert_eq!(canonical, "http://***@h.example/p?[redacted]");
10138        let rendered = format!("{config:?}");
10139        assert!(
10140            rendered.contains(&format!("base_url: \"{canonical}\"")),
10141            "Debug base_url must equal canonical redact_url output: {rendered}"
10142        );
10143    }
10144
10145    /// rc-nmupb: authored apostrophe (0x27) is RFC 3986 pchar-legal, but
10146    /// reqwest's WHATWG parser re-encodes it as `%27` in every http/https
10147    /// query — the raw byte can never ride the wire verbatim. Resolve
10148    /// rejects it naming the byte; the authored `%27` escape is the
10149    /// wire-faithful form and rides verbatim.
10150    #[test]
10151    fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
10152        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10153
10154        config.raw_query = Some("q=it's".to_string());
10155        let exchange = Exchange::new(Message::default());
10156        let err = HttpProducer::resolve_url(&exchange, &config)
10157            .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
10158        assert!(
10159            err.to_string().contains("0x27"),
10160            "error must name the apostrophe byte: {err}"
10161        );
10162
10163        config.raw_query = Some("q=it%27s".to_string());
10164        let url = HttpProducer::resolve_url(&exchange, &config)
10165            .expect("authored %27 escape is wire-legal");
10166        assert!(
10167            url.contains("q=it%27s"),
10168            "the authored escape must ride byte-for-byte: {url}"
10169        );
10170
10171        // The rest of reqwest's WHATWG special-query set shares the same
10172        // rationale and is rejected alongside (`"` and backtick are not
10173        // RFC 3986 query-legal bytes; `<`/`>` likewise).
10174        for &byte in b"\"`<>" {
10175            config.raw_query = Some(format!("k={}x", byte as char));
10176            let err = HttpProducer::resolve_url(&exchange, &config)
10177                .expect_err("WHATWG special-query byte must be rejected");
10178            assert!(
10179                err.to_string().contains(&format!("0x{byte:02X}")),
10180                "error must name byte 0x{byte:02X}: {err}"
10181            );
10182        }
10183    }
10184
10185    #[test]
10186    fn armed_fence_rejects_unknown_host_redacted() {
10187        let cfg = HttpEndpointConfig::from_uri(
10188            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10189        )
10190        .unwrap();
10191        let mut exchange = Exchange::new(Message::default());
10192        exchange.input.set_header(
10193            "CamelHttpUri",
10194            serde_json::Value::String(
10195                "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
10196            ),
10197        );
10198
10199        let err = HttpProducer::resolve_url(&exchange, &cfg)
10200            .expect_err("override host outside the fence must fail resolution");
10201
10202        let message = err.to_string();
10203        assert!(!message.contains("pass"), "userinfo leaked: {message}");
10204        assert!(!message.contains("s3cret"), "query leaked: {message}");
10205    }
10206
10207    #[test]
10208    fn armed_fence_rejects_unparseable_override_redacted() {
10209        let cfg = HttpEndpointConfig::from_uri(
10210            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10211        )
10212        .unwrap();
10213        let mut exchange = Exchange::new(Message::default());
10214        exchange.input.set_header(
10215            "CamelHttpUri",
10216            serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
10217        );
10218
10219        let err = HttpProducer::resolve_url(&exchange, &cfg)
10220            .expect_err("unparseable override outside the fence must fail resolution");
10221
10222        let message = err.to_string();
10223        assert!(
10224            message.contains("allowedUriHosts fence"),
10225            "fence must be named: {message}"
10226        );
10227        assert!(
10228            message.contains("[redacted]"),
10229            "suppression sentinel missing: {message}"
10230        );
10231        assert!(
10232            !message.contains("evil.example.com"),
10233            "host leaked: fail-closed arm must render only the sentinel: {message}"
10234        );
10235        assert!(
10236            !message.contains("fencesecret"),
10237            "password leaked: {message}"
10238        );
10239        assert!(!message.contains("u:"), "userinfo leaked: {message}");
10240    }
10241
10242    #[test]
10243    fn armed_fence_rejects_password_only_userinfo_redacted() {
10244        let cfg = HttpEndpointConfig::from_uri(
10245            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10246        )
10247        .unwrap();
10248        let mut exchange = Exchange::new(Message::default());
10249        exchange.input.set_header(
10250            "CamelHttpUri",
10251            serde_json::Value::String(
10252                "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
10253            ),
10254        );
10255
10256        let err = HttpProducer::resolve_url(&exchange, &cfg)
10257            .expect_err("password-only override outside the fence must fail resolution");
10258
10259        let message = err.to_string();
10260        assert!(
10261            !message.contains("passwordonly"),
10262            "password-only userinfo leaked: {message}"
10263        );
10264        assert!(!message.contains("querysecret"), "query leaked: {message}");
10265        assert!(
10266            message.contains("http://***@evil.example.com/x?[redacted]"),
10267            "masked shape missing: {message}"
10268        );
10269    }
10270
10271    #[test]
10272    fn armed_fence_allows_listed_host() {
10273        let cfg = HttpEndpointConfig::from_uri(
10274            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10275        )
10276        .unwrap();
10277        let mut exchange = Exchange::new(Message::default());
10278        exchange.input.set_header(
10279            "CamelHttpUri",
10280            serde_json::Value::String("http://cdn.example.com/x".to_string()),
10281        );
10282
10283        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10284        assert_eq!(url, "http://cdn.example.com/x");
10285    }
10286
10287    #[test]
10288    fn host_only_entry_permits_any_port() {
10289        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
10290        let mut exchange = Exchange::new(Message::default());
10291        exchange.input.set_header(
10292            "CamelHttpUri",
10293            serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
10294        );
10295
10296        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10297        assert_eq!(url, "http://cdn.example.com:9443/x");
10298    }
10299
10300    #[test]
10301    fn unarmed_endpoint_unchanged() {
10302        let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10303        let mut exchange = Exchange::new(Message::default());
10304        exchange.input.set_header(
10305            "CamelHttpUri",
10306            serde_json::Value::String("http://any.example.com/path".to_string()),
10307        );
10308
10309        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10310        assert_eq!(url, "http://any.example.com/path");
10311    }
10312
10313    #[test]
10314    fn empty_allowlist_fails_endpoint_creation() {
10315        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
10316    }
10317
10318    #[test]
10319    fn malformed_entry_fails_endpoint_creation() {
10320        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
10321    }
10322
10323    #[test]
10324    fn fence_entry_with_path_fails_creation() {
10325        // A trailing path is a typo'd entry: silently narrowing it to the
10326        // hostname would widen or skew the fence. Reject loudly.
10327        assert!(
10328            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
10329        );
10330    }
10331
10332    #[test]
10333    fn fence_entry_with_userinfo_fails_creation() {
10334        assert!(
10335            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
10336        );
10337    }
10338
10339    #[test]
10340    fn ipv6_fence_entry_allows_bracketed_host() {
10341        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
10342        // The textual host forms differ; both parse to the same bracketed
10343        // canonical host (`[::1]`) that the entry stores, so both ride.
10344        for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
10345            let mut exchange = Exchange::new(Message::default());
10346            exchange
10347                .input
10348                .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
10349            let url = HttpProducer::resolve_url(&exchange, &cfg)
10350                .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
10351            assert_eq!(url, uri, "bracketed IPv6 override not honored");
10352        }
10353    }
10354
10355    #[test]
10356    fn dns_case_insensitive_fence_match() {
10357        // The entry is stored ASCII-lowercased, so the mixed-case option
10358        // matches the lowercase override host.
10359        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
10360        let mut exchange = Exchange::new(Message::default());
10361        exchange.input.set_header(
10362            "CamelHttpUri",
10363            serde_json::Value::String("http://cdn.example.com/x".to_string()),
10364        );
10365        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10366        assert_eq!(url, "http://cdn.example.com/x");
10367    }
10368
10369    #[test]
10370    fn fence_allowed_override_query_merges_with_header() {
10371        // Fence pass plus full composition: the override URI query is the
10372        // higher-precedence source, the header pair appends.
10373        let cfg =
10374            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
10375        let mut exchange = Exchange::new(Message::default());
10376        exchange.input.set_header(
10377            "CamelHttpUri",
10378            serde_json::Value::String("http://host.example/api?a=1".to_string()),
10379        );
10380        exchange.input.set_header(
10381            "CamelHttpQuery",
10382            serde_json::Value::String("b=2".to_string()),
10383        );
10384
10385        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10386        assert_eq!(url, "http://host.example/api?a=1&b=2");
10387    }
10388
10389    #[test]
10390    fn empty_header_with_armed_fence_leaves_no_query() {
10391        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
10392        let mut exchange = Exchange::new(Message::default());
10393        exchange.input.set_header(
10394            "CamelHttpUri",
10395            serde_json::Value::String("http://host.example/api".to_string()),
10396        );
10397        exchange
10398            .input
10399            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10400
10401        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10402        assert_eq!(url, "http://host.example/api");
10403        assert!(!url.contains('?'), "query marker leaked: {url}");
10404    }
10405
10406    #[test]
10407    fn fence_option_is_consumed() {
10408        // A raw query on the base URI plus the fence option; no override
10409        // header. The option is consumed at parse time and must never
10410        // appear in the outbound query.
10411        let cfg =
10412            HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
10413        let exchange = Exchange::new(Message::default());
10414
10415        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10416        assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
10417        assert!(url.contains("x=1"), "authored query lost: {url}");
10418    }
10419
10420    #[tokio::test]
10421    async fn resolve_url_malformed_base_url_errors_no_panic() {
10422        use tower::ServiceExt;
10423
10424        let (url, _handle) = start_test_server().await;
10425        let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
10426        config.allow_internal = true; // test server binds 127.0.0.1
10427        let producer = HttpProducer {
10428            config: Arc::new(config),
10429            client: build_client(&HttpConfig::default(), None),
10430            pinned_cache: Arc::new(PinnedClientCache::new(
10431                PINNED_CLIENT_TTL,
10432                PINNED_CLIENT_MAX_ENTRIES,
10433            )),
10434            http_config: Arc::new(HttpConfig::default()),
10435            runtime: rt(),
10436        };
10437
10438        // First call: malformed base URL propagates as an error through the
10439        // real producer path — no panic, no poisoned state (rc-ph7z2).
10440        let first = producer
10441            .clone()
10442            .oneshot(Exchange::new(Message::default()))
10443            .await;
10444        let err = first.expect_err("malformed base URL must error, not panic");
10445        assert!(
10446            err.to_string().to_lowercase().contains("url"),
10447            "error must name the malformed URL: {err}"
10448        );
10449
10450        // Second call through the SAME producer succeeds — the failure
10451        // left no poisoned state.
10452        let mut exchange = Exchange::new(Message::default());
10453        exchange.input.set_header(
10454            "CamelHttpUri",
10455            serde_json::Value::String(format!("{url}/api")),
10456        );
10457        let response = producer
10458            .oneshot(exchange)
10459            .await
10460            .expect("valid request through same producer must succeed");
10461        let status = response
10462            .input
10463            .header("CamelHttpResponseCode")
10464            .and_then(|v| v.as_u64())
10465            .unwrap();
10466        assert_eq!(status, 200);
10467    }
10468
10469    #[test]
10470    fn resolve_url_bridge_malformed_base_errors_no_panic() {
10471        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10472        cfg.bridge_endpoint = true;
10473        cfg.query_params.push(("k".to_string(), "1".to_string()));
10474        // `from_uri` rejects the malformed authority, so the base is set on
10475        // the stored config directly (same build shape as the scheme-case
10476        // test). The bridge arm's validation-only parse (rc-ph7z2) must
10477        // surface it as an error — no panic.
10478        cfg.base_url = "http://[::1:bad".to_string();
10479        let exchange = Exchange::new(Message::default());
10480
10481        let err = HttpProducer::resolve_url(&exchange, &cfg)
10482            .expect_err("malformed bridge base URL must error");
10483        assert!(
10484            err.to_string().contains("invalid base URL"),
10485            "error must name the invalid base URL: {err}"
10486        );
10487    }
10488
10489    #[test]
10490    fn test_http_producer_helpers_status_and_size_boundaries() {
10491        assert!(HttpProducer::is_ok_status(200, (200, 299)));
10492        assert!(HttpProducer::is_ok_status(299, (200, 299)));
10493        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
10494        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
10495
10496        assert!(!exceeds_max_response_body(10, 10));
10497        assert!(exceeds_max_response_body(11, 10));
10498    }
10499
10500    // -----------------------------------------------------------------------
10501    // Content-Type inference tests
10502    // -----------------------------------------------------------------------
10503
10504    #[allow(clippy::await_holding_lock)]
10505    async fn setup_consumer_on_free_port(
10506        path: &str,
10507    ) -> (
10508        u16,
10509        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
10510        tokio_util::sync::CancellationToken,
10511    ) {
10512        use camel_component_api::ConsumerContext;
10513
10514        // ADR-0070 staged-listener law: bind, KEEP the socket, and stage it
10515        // in the ServerRegistry; the consumer's `get_or_spawn` consumes the
10516        // staged listener, so the port never returns to the ephemeral pool
10517        // between probe and serve (no bind-read-drop race).
10518        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10519        let port = listener.local_addr().unwrap().port();
10520
10521        // Hold the registry test mutex across the whole stage→spawn→ready
10522        // window so a concurrent `ServerRegistry::reset()` cannot evict the
10523        // staged listener between staging and readiness. The guard covers
10524        // stage_listener, the consumer spawn, the readiness poll and the
10525        // tail-yield loop; it releases when this helper returns.
10526        // Poison-recovering acquire: a failed sibling test must not
10527        // cascade — the mutex guards test serialization only, no
10528        // structural invariant, so recovery via into_inner is safe.
10529        let _registry_guard = lock_registry_test_mutex();
10530
10531        ServerRegistry::global()
10532            .stage_listener(listener)
10533            .await
10534            .expect("stage consumer test listener");
10535
10536        let consumer_cfg = HttpServerConfig {
10537            scheme: "http".to_string(),
10538            host: "127.0.0.1".to_string(),
10539            port,
10540            path: path.to_string(),
10541            max_request_body: 2 * 1024 * 1024,
10542            max_response_body: 10 * 1024 * 1024,
10543            max_inflight_requests: 1024,
10544            method: None,
10545            tls_config: None,
10546        };
10547        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
10548
10549        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10550        let token = tokio_util::sync::CancellationToken::new();
10551        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10552
10553        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10554
10555        // Readiness without a fixed wall-clock sleep: poll the registry
10556        // entry live (1ms doubling backoff, 10s deadline), then yield so
10557        // the spawned `start()` completes route registration (that tail
10558        // path has no pending timers — only the registry lock — so
10559        // scheduler yields order it deterministically behind this loop).
10560        wait_for_registry_ready("127.0.0.1", port).await;
10561        for _ in 0..8 {
10562            tokio::task::yield_now().await;
10563        }
10564
10565        (port, rx, token)
10566    }
10567
10568    /// Poll `ServerRegistry::bound_addr(host, port)` until the entry
10569    /// appears: 1ms backoff doubling per iteration, capped at 64ms, with
10570    /// a 10s deadline. Panics with a hint naming the likely causes when
10571    /// the deadline fires.
10572    async fn wait_for_registry_ready(host: &str, port: u16) {
10573        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
10574        let mut backoff = std::time::Duration::from_millis(1);
10575        while ServerRegistry::global().bound_addr(host, port).is_none() {
10576            assert!(
10577                tokio::time::Instant::now() < deadline,
10578                "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
10579            );
10580            tokio::time::sleep(backoff).await;
10581            backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
10582        }
10583    }
10584
10585    #[tokio::test(start_paused = true)]
10586    #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
10587    async fn readiness_deadline_fires_loud_with_hint() {
10588        // start_paused: the backoff sleeps and the 10s deadline run on
10589        // the mocked clock (tokio test-util dev-feature), so the loud
10590        // path costs no wall time.
10591        // Poll a key no writer can produce. Registry keys come from
10592        // either the listener's resolved IP string (staged path) or the
10593        // caller-provided host verbatim (legacy get_or_spawn path), so a
10594        // synthetic host literal that no test passes is unreachable on
10595        // BOTH paths. Binding and HOLDING the listener (never dropped,
10596        // never staged) additionally keeps its port out of the ephemeral
10597        // pool, so no concurrent test can register that port either.
10598        // (Earlier drafts polled 127.0.0.2 — rejected: macOS exposes only
10599        // 127.0.0.1 and the bind fails there, rc-dwmd; and "localhost" —
10600        // rejected: the legacy host-verbatim path could produce it.)
10601        let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10602        let port = held.local_addr().unwrap().port();
10603        wait_for_registry_ready("httpflake-unreachable-host", port).await;
10604    }
10605
10606    // -----------------------------------------------------------------------
10607    // Readiness vs concurrent registry reset (httpflake, regression RED)
10608    // -----------------------------------------------------------------------
10609
10610    #[tokio::test]
10611    async fn readiness_survives_concurrent_registry_reset() {
10612        let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10613        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
10614
10615        // Hammer thread: loop legal resets, counting a contention only
10616        // when its try-lock on the registry test mutex reports WouldBlock
10617        // (someone else held it). Poison is a sibling's panic, not
10618        // contention: recover through the poison-recovering helper
10619        // without counting it. The guard is dropped at each iteration
10620        // end.
10621        let contended_hammer = std::sync::Arc::clone(&contended);
10622        let stop_hammer = std::sync::Arc::clone(&stop);
10623        let handle = std::thread::spawn(move || {
10624            while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
10625                let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
10626                    Err(std::sync::TryLockError::WouldBlock) => {
10627                        contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10628                        lock_registry_test_mutex()
10629                    }
10630                    Err(std::sync::TryLockError::Poisoned(_)) => lock_registry_test_mutex(),
10631                    Ok(guard) => guard,
10632                };
10633                ServerRegistry::reset();
10634            }
10635        });
10636
10637        // Drop guard: even if a setup panics, stop the hammer and join it so
10638        // the thread never outlives the test.
10639        struct StopHammerOnDrop {
10640            handle: Option<std::thread::JoinHandle<()>>,
10641            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
10642        }
10643        impl Drop for StopHammerOnDrop {
10644            fn drop(&mut self) {
10645                self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
10646                if let Some(handle) = self.handle.take() {
10647                    let _ = handle.join();
10648                }
10649            }
10650        }
10651        let _hammer_guard = StopHammerOnDrop {
10652            handle: Some(handle),
10653            stop,
10654        };
10655
10656        // Always at least 25 setups on fresh ephemeral ports; continue past
10657        // 25 only until one contended reset is observed; hard cap 50.
10658        let mut setups = 0;
10659        loop {
10660            setups += 1;
10661            let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
10662            drop(rx);
10663            token.cancel();
10664            if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
10665                || setups >= 50
10666            {
10667                break;
10668            }
10669        }
10670
10671        let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
10672        assert!(
10673            contended_hits >= 1,
10674            "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
10675        );
10676    }
10677
10678    #[tokio::test]
10679    async fn test_content_type_inferred_for_json_body() {
10680        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
10681
10682        let client = reqwest::Client::new();
10683        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
10684
10685        let (http_result, _) = tokio::join!(send_fut, async {
10686            if let Some(mut envelope) = rx.recv().await {
10687                envelope.exchange.input.body =
10688                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
10689                if let Some(reply_tx) = envelope.reply_tx {
10690                    let _ = reply_tx.send(Ok(envelope.exchange));
10691                }
10692            }
10693        });
10694
10695        let resp = http_result.unwrap();
10696        assert_eq!(resp.status().as_u16(), 200);
10697        let ct = resp
10698            .headers()
10699            .get("content-type")
10700            .expect("Content-Type header should be present");
10701        assert_eq!(ct, "application/json");
10702        let body = resp.text().await.unwrap();
10703        assert_eq!(body, r#"{"message":"hello"}"#);
10704
10705        token.cancel();
10706    }
10707
10708    #[tokio::test]
10709    async fn test_content_type_inferred_for_text_body() {
10710        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
10711
10712        let client = reqwest::Client::new();
10713        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
10714
10715        let (http_result, _) = tokio::join!(send_fut, async {
10716            if let Some(mut envelope) = rx.recv().await {
10717                envelope.exchange.input.body =
10718                    camel_component_api::Body::Text("plain text response".to_string());
10719                if let Some(reply_tx) = envelope.reply_tx {
10720                    let _ = reply_tx.send(Ok(envelope.exchange));
10721                }
10722            }
10723        });
10724
10725        let resp = http_result.unwrap();
10726        assert_eq!(resp.status().as_u16(), 200);
10727        let ct = resp
10728            .headers()
10729            .get("content-type")
10730            .expect("Content-Type header should be present");
10731        assert_eq!(ct, "text/plain; charset=utf-8");
10732        let body = resp.text().await.unwrap();
10733        assert_eq!(body, "plain text response");
10734
10735        token.cancel();
10736    }
10737
10738    #[tokio::test]
10739    async fn test_content_type_inferred_for_xml_body() {
10740        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
10741
10742        let client = reqwest::Client::new();
10743        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
10744
10745        let (http_result, _) = tokio::join!(send_fut, async {
10746            if let Some(mut envelope) = rx.recv().await {
10747                envelope.exchange.input.body =
10748                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
10749                if let Some(reply_tx) = envelope.reply_tx {
10750                    let _ = reply_tx.send(Ok(envelope.exchange));
10751                }
10752            }
10753        });
10754
10755        let resp = http_result.unwrap();
10756        assert_eq!(resp.status().as_u16(), 200);
10757        let ct = resp
10758            .headers()
10759            .get("content-type")
10760            .expect("Content-Type header should be present");
10761        assert_eq!(ct, "application/xml");
10762        let body = resp.text().await.unwrap();
10763        assert_eq!(body, "<root><item>value</item></root>");
10764
10765        token.cancel();
10766    }
10767
10768    #[tokio::test]
10769    async fn test_no_content_type_for_empty_body() {
10770        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
10771
10772        let client = reqwest::Client::new();
10773        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
10774
10775        let (http_result, _) = tokio::join!(send_fut, async {
10776            if let Some(mut envelope) = rx.recv().await {
10777                envelope.exchange.input.body = camel_component_api::Body::Empty;
10778                if let Some(reply_tx) = envelope.reply_tx {
10779                    let _ = reply_tx.send(Ok(envelope.exchange));
10780                }
10781            }
10782        });
10783
10784        let resp = http_result.unwrap();
10785        assert_eq!(resp.status().as_u16(), 200);
10786        assert!(
10787            resp.headers().get("content-type").is_none(),
10788            "Empty body should not set Content-Type"
10789        );
10790
10791        token.cancel();
10792    }
10793
10794    #[tokio::test]
10795    async fn test_no_content_type_for_raw_bytes_body() {
10796        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
10797
10798        let client = reqwest::Client::new();
10799        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
10800
10801        let (http_result, _) = tokio::join!(send_fut, async {
10802            if let Some(mut envelope) = rx.recv().await {
10803                envelope.exchange.input.body =
10804                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
10805                if let Some(reply_tx) = envelope.reply_tx {
10806                    let _ = reply_tx.send(Ok(envelope.exchange));
10807                }
10808            }
10809        });
10810
10811        let resp = http_result.unwrap();
10812        assert_eq!(resp.status().as_u16(), 200);
10813        assert!(
10814            resp.headers().get("content-type").is_none(),
10815            "Raw Bytes body should not set Content-Type"
10816        );
10817
10818        token.cancel();
10819    }
10820
10821    #[tokio::test]
10822    async fn test_content_type_from_stream_metadata() {
10823        use camel_component_api::{StreamBody, StreamMetadata};
10824        use futures::stream;
10825
10826        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
10827
10828        let client = reqwest::Client::new();
10829        let send_fut = client
10830            .get(format!("http://127.0.0.1:{port}/stream-ct"))
10831            .send();
10832
10833        let (http_result, _) = tokio::join!(send_fut, async {
10834            if let Some(mut envelope) = rx.recv().await {
10835                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
10836                    vec![Ok(bytes::Bytes::from("audio data"))];
10837                let stream = Box::pin(stream::iter(chunks));
10838                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
10839                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
10840                    metadata: StreamMetadata {
10841                        size_hint: None,
10842                        content_type: Some("audio/mpeg".to_string()),
10843                        origin: None,
10844                    },
10845                });
10846                if let Some(reply_tx) = envelope.reply_tx {
10847                    let _ = reply_tx.send(Ok(envelope.exchange));
10848                }
10849            }
10850        });
10851
10852        let resp = http_result.unwrap();
10853        assert_eq!(resp.status().as_u16(), 200);
10854        let ct = resp
10855            .headers()
10856            .get("content-type")
10857            .expect("Content-Type header should be present");
10858        assert_eq!(ct, "audio/mpeg");
10859        let body = resp.text().await.unwrap();
10860        assert_eq!(body, "audio data");
10861
10862        token.cancel();
10863    }
10864
10865    #[tokio::test]
10866    async fn test_user_content_type_overrides_inferred() {
10867        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
10868
10869        let client = reqwest::Client::new();
10870        let send_fut = client
10871            .get(format!("http://127.0.0.1:{port}/override-ct"))
10872            .send();
10873
10874        let (http_result, _) = tokio::join!(send_fut, async {
10875            if let Some(mut envelope) = rx.recv().await {
10876                envelope.exchange.input.body =
10877                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
10878                envelope.exchange.input.set_header(
10879                    "Content-Type",
10880                    serde_json::Value::String("text/html".to_string()),
10881                );
10882                if let Some(reply_tx) = envelope.reply_tx {
10883                    let _ = reply_tx.send(Ok(envelope.exchange));
10884                }
10885            }
10886        });
10887
10888        let resp = http_result.unwrap();
10889        assert_eq!(resp.status().as_u16(), 200);
10890        let ct = resp
10891            .headers()
10892            .get("content-type")
10893            .expect("Content-Type header should be present");
10894        assert_eq!(
10895            ct, "text/html",
10896            "User-set Content-Type should take precedence over inferred type"
10897        );
10898
10899        token.cancel();
10900    }
10901
10902    #[tokio::test]
10903    async fn test_user_content_type_with_bytes_body() {
10904        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
10905
10906        let client = reqwest::Client::new();
10907        let send_fut = client
10908            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
10909            .send();
10910
10911        let (http_result, _) = tokio::join!(send_fut, async {
10912            if let Some(mut envelope) = rx.recv().await {
10913                envelope.exchange.input.body =
10914                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
10915                envelope.exchange.input.set_header(
10916                    "Content-Type",
10917                    serde_json::Value::String("application/json".to_string()),
10918                );
10919                if let Some(reply_tx) = envelope.reply_tx {
10920                    let _ = reply_tx.send(Ok(envelope.exchange));
10921                }
10922            }
10923        });
10924
10925        let resp = http_result.unwrap();
10926        assert_eq!(resp.status().as_u16(), 200);
10927        let ct = resp
10928            .headers()
10929            .get("content-type")
10930            .expect("Content-Type header should be present for Bytes body with user header");
10931        assert_eq!(
10932            ct, "application/json",
10933            "User Content-Type should be sent for Bytes body"
10934        );
10935
10936        token.cancel();
10937    }
10938
10939    // -----------------------------------------------------------------------
10940    // Server monitor tests (GRL-005)
10941    // -----------------------------------------------------------------------
10942
10943    #[tokio::test]
10944    async fn monitor_task_silent_on_clean_exit() {
10945        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
10946        let server_exited = tokio_util::sync::CancellationToken::new();
10947        // Clean exit should complete without panicking or logging errors
10948        monitor_axum_task(
10949            handle,
10950            "127.0.0.1:0".to_string(),
10951            noop_rt(),
10952            "test-monitor".into(),
10953            server_exited.clone(),
10954        )
10955        .await;
10956        // rc-szmob: a clean exit must NOT fail hosted consumers — route
10957        // stops own their termination (no CrashNotification storm on
10958        // graceful process shutdown).
10959        assert!(
10960            !server_exited.is_cancelled(),
10961            "clean server exit must not cancel server_exited"
10962        );
10963    }
10964
10965    #[tokio::test]
10966    async fn monitor_task_handles_panicked_task() {
10967        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
10968            panic!("simulated server crash");
10969        });
10970        let server_exited = tokio_util::sync::CancellationToken::new();
10971        // Should complete without panicking even though the inner task panicked
10972        monitor_axum_task(
10973            handle,
10974            "127.0.0.1:9999".to_string(),
10975            noop_rt(),
10976            "test-monitor".into(),
10977            server_exited.clone(),
10978        )
10979        .await;
10980        // rc-szmob: unexpected exit must cancel the token so every hosted
10981        // consumer fails and supervision engages (ADR-0007).
10982        assert!(
10983            server_exited.is_cancelled(),
10984            "crashed server must cancel server_exited"
10985        );
10986    }
10987
10988    // -----------------------------------------------------------------------
10989    // Credential redaction tests
10990    // -----------------------------------------------------------------------
10991
10992    #[test]
10993    fn http_auth_basic_debug_redacts_password() {
10994        let auth = HttpAuth::Basic {
10995            username: "admin".to_string(),
10996            password: "hunter2".to_string(),
10997        };
10998        let debug = format!("{:?}", auth);
10999        assert!(
11000            !debug.contains("hunter2"),
11001            "password must be redacted: {debug}"
11002        );
11003        assert!(debug.contains("admin"), "username should appear: {debug}");
11004    }
11005
11006    #[test]
11007    fn http_auth_bearer_debug_redacts_token() {
11008        let auth = HttpAuth::Bearer {
11009            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
11010        };
11011        let debug = format!("{:?}", auth);
11012        assert!(
11013            !debug.contains("eyJhbGci"),
11014            "token must be redacted: {debug}"
11015        );
11016    }
11017
11018    #[test]
11019    fn http_auth_none_debug_shows_variant() {
11020        let debug = format!("{:?}", HttpAuth::None);
11021        assert!(
11022            debug.contains("None"),
11023            "None variant should appear: {debug}"
11024        );
11025    }
11026
11027    #[test]
11028    fn http_endpoint_config_debug_redacts_auth_credentials() {
11029        let config = HttpEndpointConfig::from_uri(
11030            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
11031        )
11032        .unwrap();
11033        let debug = format!("{:?}", config);
11034        assert!(
11035            !debug.contains("secret123"),
11036            "password must be redacted in HttpEndpointConfig debug: {debug}"
11037        );
11038    }
11039
11040    #[test]
11041    fn debug_lists_all_public_fields() {
11042        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
11043        let debug = format!("{:?}", config);
11044        for field in [
11045            "base_url",
11046            "http_method",
11047            "throw_exception_on_failure",
11048            "ok_status_code_range",
11049            "response_timeout",
11050            "query_params",
11051            "raw_query",
11052            "allow_internal",
11053            "allow_cleartext",
11054            "blocked_hosts",
11055            "max_body_size",
11056            "read_timeout_ms",
11057            "max_response_bytes",
11058            "auth",
11059            "token_provider",
11060            "user_agent",
11061            "bridge_endpoint",
11062            "connection_close",
11063            "skip_request_headers",
11064            "skip_response_headers",
11065            "follow_redirects",
11066            "max_redirects",
11067        ] {
11068            assert!(
11069                debug.contains(field),
11070                "Debug output missing field '{field}': {debug}"
11071            );
11072        }
11073    }
11074
11075    // -----------------------------------------------------------------------
11076    // Static file serving tests (Task 5)
11077    // -----------------------------------------------------------------------
11078
11079    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
11080    use tower_http::services::ServeDir;
11081
11082    fn make_test_registry() -> HttpRouteRegistry {
11083        HttpRouteRegistry::new()
11084    }
11085
11086    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
11087        AppState {
11088            registry,
11089            max_request_body: 2 * 1024 * 1024,
11090            max_response_body: 10 * 1024 * 1024,
11091            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
11092        }
11093    }
11094
11095    #[allow(clippy::await_holding_lock)]
11096    #[tokio::test]
11097    async fn test_static_file_serving_serves_file_contents() {
11098        let _guard = lock_registry_test_mutex();
11099        ServerRegistry::reset();
11100
11101        // Create temp dir with test files
11102        let temp_dir =
11103            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
11104        std::fs::create_dir_all(&temp_dir).unwrap();
11105        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
11106        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
11107
11108        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11109
11110        let registry = make_test_registry();
11111        let serve_dir = ServeDir::new(&canonical_dir)
11112            .precompressed_gzip()
11113            .precompressed_br()
11114            .append_index_html_on_directories(true);
11115
11116        let mount = StaticMount {
11117            mount_path: "/".to_string(),
11118            mode: MountMode::Static,
11119            dir: canonical_dir.clone(),
11120            cache_control: "public, max-age=3600".to_string(),
11121            error_pages: std::collections::HashMap::new(),
11122            serve_dir,
11123        };
11124        registry.register_static_mount(mount).await.unwrap();
11125
11126        let state = make_test_state(registry);
11127
11128        // Test serving hello.txt
11129        let req = Request::builder()
11130            .uri("/hello.txt")
11131            .body(AxumBody::empty())
11132            .unwrap();
11133        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
11134        assert_eq!(resp.status(), StatusCode::OK);
11135        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11136            .await
11137            .unwrap();
11138        assert_eq!(&body[..], b"Hello, static world!");
11139
11140        // Test serving style.css
11141        let req = Request::builder()
11142            .uri("/style.css")
11143            .body(AxumBody::empty())
11144            .unwrap();
11145        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11146        assert_eq!(resp.status(), StatusCode::OK);
11147        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11148            .await
11149            .unwrap();
11150        assert_eq!(&body[..], b"body { color: red; }");
11151
11152        // Test 404 for non-existent file
11153        let req = Request::builder()
11154            .uri("/missing.txt")
11155            .body(AxumBody::empty())
11156            .unwrap();
11157        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
11158        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11159
11160        // Cleanup
11161        std::fs::remove_dir_all(&temp_dir).ok();
11162    }
11163
11164    #[allow(clippy::await_holding_lock)]
11165    #[tokio::test]
11166    async fn test_spa_fallback_serves_index_for_unknown_paths() {
11167        let _guard = lock_registry_test_mutex();
11168        ServerRegistry::reset();
11169
11170        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
11171        std::fs::create_dir_all(&temp_dir).unwrap();
11172        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
11173        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
11174
11175        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11176
11177        let registry = make_test_registry();
11178        let serve_dir = ServeDir::new(&canonical_dir)
11179            .precompressed_gzip()
11180            .precompressed_br()
11181            .append_index_html_on_directories(true);
11182
11183        let mount = StaticMount {
11184            mount_path: "/".to_string(),
11185            mode: MountMode::Spa,
11186            dir: canonical_dir.clone(),
11187            cache_control: "public, max-age=0".to_string(),
11188            error_pages: std::collections::HashMap::new(),
11189            serve_dir,
11190        };
11191        // Register as SPA mount
11192        registry.register_static_mount(mount).await.unwrap();
11193
11194        let state = make_test_state(registry);
11195
11196        // SPA fallback: GET /dashboard with Accept: text/html → index.html
11197        let req = Request::builder()
11198            .method("GET")
11199            .uri("/dashboard")
11200            .header("Accept", "text/html")
11201            .body(AxumBody::empty())
11202            .unwrap();
11203        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
11204        assert_eq!(resp.status(), StatusCode::OK);
11205        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11206            .await
11207            .unwrap();
11208        assert_eq!(&body[..], b"<h1>SPA App</h1>");
11209
11210        // Static file still works: GET /app.js
11211        let req = Request::builder()
11212            .method("GET")
11213            .uri("/app.js")
11214            .body(AxumBody::empty())
11215            .unwrap();
11216        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
11217        assert_eq!(resp.status(), StatusCode::OK);
11218        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11219            .await
11220            .unwrap();
11221        assert_eq!(&body[..], b"console.log('app')");
11222
11223        // No SPA fallback for JSON accept → 404
11224        let req = Request::builder()
11225            .method("GET")
11226            .uri("/api/data")
11227            .header("Accept", "application/json")
11228            .body(AxumBody::empty())
11229            .unwrap();
11230        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
11231        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11232
11233        // No SPA fallback for file extensions → 404
11234        let req = Request::builder()
11235            .method("GET")
11236            .uri("/style.css")
11237            .header("Accept", "text/html")
11238            .body(AxumBody::empty())
11239            .unwrap();
11240        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11241        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11242
11243        // Cleanup
11244        std::fs::remove_dir_all(&temp_dir).ok();
11245    }
11246
11247    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
11248    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
11249    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
11250    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
11251    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
11252    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
11253    #[allow(clippy::await_holding_lock)]
11254    async fn run_conditional_get_returns_304(mode: MountMode) {
11255        let _guard = lock_registry_test_mutex();
11256        ServerRegistry::reset();
11257
11258        let temp_dir = std::env::temp_dir().join(format!(
11259            "http_cond_get_{}_{}",
11260            if mode == MountMode::Spa {
11261                "spa"
11262            } else {
11263                "static"
11264            },
11265            std::process::id()
11266        ));
11267        std::fs::create_dir_all(&temp_dir).unwrap();
11268        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11269
11270        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11271
11272        let registry = make_test_registry();
11273        let serve_dir = ServeDir::new(&canonical_dir)
11274            .precompressed_gzip()
11275            .precompressed_br()
11276            .append_index_html_on_directories(true);
11277
11278        let mount = StaticMount {
11279            mount_path: "/".to_string(),
11280            mode,
11281            dir: canonical_dir.clone(),
11282            cache_control: "public, max-age=3600".to_string(),
11283            error_pages: std::collections::HashMap::new(),
11284            serve_dir,
11285        };
11286        registry.register_static_mount(mount).await.unwrap();
11287
11288        let state = make_test_state(registry);
11289
11290        // 1st request: normal GET → 200, capture validators.
11291        let req = Request::builder()
11292            .method("GET")
11293            .uri("/index.html")
11294            .body(AxumBody::empty())
11295            .unwrap();
11296        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11297        assert_eq!(
11298            resp.status(),
11299            StatusCode::OK,
11300            "first GET should return 200, got {}",
11301            resp.status()
11302        );
11303        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
11304        assert!(
11305            resp.headers().contains_key(http::header::CACHE_CONTROL),
11306            "200 response missing Cache-Control"
11307        );
11308        let etag = resp
11309            .headers()
11310            .get(http::header::ETAG)
11311            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
11312            .clone();
11313        let last_modified = resp
11314            .headers()
11315            .get(http::header::LAST_MODIFIED)
11316            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
11317            .clone();
11318        // Consume the body so the response is fully drained.
11319        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
11320            .await
11321            .unwrap();
11322
11323        // 2nd request: If-None-Match with the captured ETag → 304.
11324        // Unconditional: ETag presence is required (asserted above) so this
11325        // sub-test cannot silently skip on a ServeDir etag_method change.
11326        let req = Request::builder()
11327            .method("GET")
11328            .uri("/index.html")
11329            .header(http::header::IF_NONE_MATCH, etag.clone())
11330            .body(AxumBody::empty())
11331            .unwrap();
11332        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11333        assert_eq!(
11334            resp.status(),
11335            StatusCode::NOT_MODIFIED,
11336            "If-None-Match with matching ETag should return 304, got {}",
11337            resp.status()
11338        );
11339        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
11340        assert!(
11341            resp.headers().contains_key(http::header::CACHE_CONTROL),
11342            "304 (If-None-Match) missing Cache-Control"
11343        );
11344        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
11345        // response parts rebuild in serve_via_serve_dir preserves them.
11346        assert_eq!(
11347            resp.headers().get(http::header::ETAG),
11348            Some(&etag),
11349            "304 (If-None-Match) must echo the ETag validator"
11350        );
11351        assert_eq!(
11352            resp.headers().get(http::header::LAST_MODIFIED),
11353            Some(&last_modified),
11354            "304 (If-None-Match) must carry Last-Modified"
11355        );
11356
11357        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
11358        let req = Request::builder()
11359            .method("GET")
11360            .uri("/index.html")
11361            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
11362            .body(AxumBody::empty())
11363            .unwrap();
11364        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11365        assert_eq!(
11366            resp.status(),
11367            StatusCode::NOT_MODIFIED,
11368            "If-Modified-Since with matching timestamp should return 304, got {}",
11369            resp.status()
11370        );
11371        assert!(
11372            resp.headers().contains_key(http::header::CACHE_CONTROL),
11373            "304 (If-Modified-Since) missing Cache-Control"
11374        );
11375        assert_eq!(
11376            resp.headers().get(http::header::ETAG),
11377            Some(&etag),
11378            "304 (If-Modified-Since) must carry the ETag validator"
11379        );
11380        assert_eq!(
11381            resp.headers().get(http::header::LAST_MODIFIED),
11382            Some(&last_modified),
11383            "304 (If-Modified-Since) must echo Last-Modified"
11384        );
11385
11386        // Negative control: a PAST If-Modified-Since (before the file's mtime)
11387        // MUST return 200 — proving the 304 path is validator-aware, not a
11388        // blanket "always 304" regression. A future date would correctly yield
11389        // 304 since the file's mtime precedes it; that is RFC-correct 304
11390        // behaviour, not a negative control.
11391        let req = Request::builder()
11392            .method("GET")
11393            .uri("/index.html")
11394            .header(
11395                http::header::IF_MODIFIED_SINCE,
11396                "Wed, 21 Oct 2000 07:28:00 GMT",
11397            )
11398            .body(AxumBody::empty())
11399            .unwrap();
11400        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11401        assert_eq!(
11402            resp.status(),
11403            StatusCode::OK,
11404            "past If-Modified-Since should return 200 (file modified after it), got {}",
11405            resp.status()
11406        );
11407
11408        // Cleanup
11409        std::fs::remove_dir_all(&temp_dir).ok();
11410    }
11411
11412    #[tokio::test]
11413    async fn test_conditional_get_returns_304_static_mode() {
11414        run_conditional_get_returns_304(MountMode::Static).await;
11415    }
11416
11417    #[tokio::test]
11418    async fn test_conditional_get_returns_304_spa_mode() {
11419        run_conditional_get_returns_304(MountMode::Spa).await;
11420    }
11421
11422    #[allow(clippy::await_holding_lock)]
11423    #[tokio::test]
11424    async fn test_error_page_mapping_serves_custom_404() {
11425        let _guard = lock_registry_test_mutex();
11426        ServerRegistry::reset();
11427
11428        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
11429        let errors_dir = temp_dir.join("errors");
11430        std::fs::create_dir_all(&errors_dir).unwrap();
11431        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11432        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
11433
11434        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11435        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
11436
11437        let registry = make_test_registry();
11438        let serve_dir = ServeDir::new(&canonical_dir)
11439            .precompressed_gzip()
11440            .precompressed_br()
11441            .append_index_html_on_directories(true);
11442
11443        let mut error_pages = std::collections::HashMap::new();
11444        error_pages.insert(404, canonical_404);
11445
11446        let mount = StaticMount {
11447            mount_path: "/".to_string(),
11448            mode: MountMode::Static,
11449            dir: canonical_dir.clone(),
11450            cache_control: "public, max-age=0".to_string(),
11451            error_pages,
11452            serve_dir,
11453        };
11454        registry.register_static_mount(mount).await.unwrap();
11455
11456        let state = make_test_state(registry);
11457
11458        // Request non-existent file → custom 404 page
11459        let req = Request::builder()
11460            .method("GET")
11461            .uri("/missing.html")
11462            .body(AxumBody::empty())
11463            .unwrap();
11464        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
11465        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11466        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11467            .await
11468            .unwrap();
11469        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
11470
11471        // Existing file still works
11472        let req = Request::builder()
11473            .method("GET")
11474            .uri("/index.html")
11475            .body(AxumBody::empty())
11476            .unwrap();
11477        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11478        assert_eq!(resp.status(), StatusCode::OK);
11479        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11480            .await
11481            .unwrap();
11482        assert_eq!(&body[..], b"<h1>Home</h1>");
11483
11484        // Cleanup
11485        std::fs::remove_dir_all(&temp_dir).ok();
11486    }
11487
11488    #[tokio::test]
11489    async fn http_consumer_returns_body_and_code_on_stop() {
11490        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
11491        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11492        use tower::ServiceExt;
11493
11494        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
11495        let set_body_step = CompiledStep::Process {
11496            kind_hint: camel_api::SpanKindHint::Internal,
11497            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11498                ex.input.body = Body::Text("nope".into());
11499                Box::pin(async move { Ok(ex) })
11500            }),
11501            body_contract: None,
11502            lifecycle: None,
11503            label: None,
11504            to_uri: None,
11505        };
11506        let set_status_step = CompiledStep::Process {
11507            kind_hint: camel_api::SpanKindHint::Internal,
11508            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11509                ex.input.set_header(
11510                    "CamelHttpResponseCode",
11511                    serde_json::Value::Number(409.into()),
11512                );
11513                Box::pin(async move { Ok(ex) })
11514            }),
11515            body_contract: None,
11516            lifecycle: None,
11517            label: None,
11518            to_uri: None,
11519        };
11520        let pipeline = compose_pipeline_with_handler(
11521            vec![set_body_step, set_status_step, CompiledStep::Stop],
11522            None,
11523            PipelineRuntimeCtx::compile_time(),
11524        );
11525
11526        let ex = Exchange::new(Message::default());
11527        let result = pipeline.oneshot(ex).await;
11528        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
11529        let returned = result.unwrap();
11530        assert_eq!(returned.input.body.as_text(), Some("nope"));
11531        assert_eq!(
11532            returned
11533                .input
11534                .header("CamelHttpResponseCode")
11535                .and_then(|v| v.as_u64()),
11536            Some(409)
11537        );
11538    }
11539
11540    #[tokio::test]
11541    async fn http_consumer_returns_200_when_body_empty_on_stop() {
11542        // After ADR-0024: Stop with no body + no status header produces 200 (same as
11543        // a normal completion with no body). The 204 default is gone — users who
11544        // want 204 set CamelHttpResponseCode=204 explicitly.
11545        //
11546        // This test stays at the pipeline level (consistent with the test above).
11547        // E2E coverage of the full HTTP dispatch path is in
11548        // crates/camel-test/tests/integration_test.rs.
11549        use camel_api::{Exchange, Message};
11550        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11551        use tower::ServiceExt;
11552
11553        let pipeline = compose_pipeline_with_handler(
11554            vec![CompiledStep::Stop],
11555            None,
11556            PipelineRuntimeCtx::compile_time(),
11557        );
11558        let ex = Exchange::new(Message::default());
11559        let result = pipeline.oneshot(ex).await;
11560        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
11561        // Body is default (empty); no CamelHttpResponseCode header was set.
11562        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
11563    }
11564
11565    // -----------------------------------------------------------------------
11566    // Task 5: Method-aware REST dispatch tests
11567    // -----------------------------------------------------------------------
11568
11569    /// Spins up an axum server on a free port with a fresh registry.
11570    /// Returns the port plus the registry so the caller can register
11571    /// REST endpoints directly.
11572    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
11573        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11574        let port = listener.local_addr().unwrap().port();
11575        let registry = HttpRouteRegistry::new();
11576        tokio::spawn(run_axum_server(
11577            listener,
11578            registry.clone(),
11579            2 * 1024 * 1024,
11580            10 * 1024 * 1024,
11581            Arc::new(tokio::sync::Semaphore::new(1024)),
11582            test_rt(),
11583            "test-route".into(),
11584        ));
11585        // Give the server a moment to start accepting.
11586        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
11587        (port, registry)
11588    }
11589
11590    /// Helper for REST integration tests: spawns a responder task that
11591    /// reads from `rx`, writes a fixed `(status, body)` back via the
11592    /// envelope's reply channel, and returns once the test request is
11593    /// satisfied.
11594    fn spawn_responder(
11595        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
11596        status: u16,
11597        body: String,
11598    ) -> tokio::task::JoinHandle<()> {
11599        tokio::spawn(async move {
11600            if let Some(envelope) = rx.recv().await {
11601                let _ = envelope.reply_tx.send(HttpReply {
11602                    status,
11603                    headers: vec![],
11604                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
11605                });
11606            }
11607        })
11608    }
11609
11610    #[tokio::test]
11611    async fn method_aware_dispatch_same_path_different_verbs() {
11612        let (port, registry) = spawn_test_server().await;
11613
11614        // Register two REST endpoints on the same path with different
11615        // methods. This is the core scenario REST DSL needs to support:
11616        // GET /users (list) and POST /users (create) must not overwrite
11617        // each other.
11618        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11619        registry
11620            .register_rest_endpoint(
11621                "GET".into(),
11622                vec![PathSegment::Literal("users".into())],
11623                get_tx,
11624            )
11625            .await;
11626
11627        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11628        registry
11629            .register_rest_endpoint(
11630                "POST".into(),
11631                vec![PathSegment::Literal("users".into())],
11632                post_tx,
11633            )
11634            .await;
11635
11636        let get_handle = spawn_responder(get_rx, 200, "list".into());
11637        let post_handle = spawn_responder(post_rx, 201, "create".into());
11638
11639        let client = reqwest::Client::new();
11640
11641        // GET /users → list route
11642        let resp = client
11643            .get(format!("http://127.0.0.1:{port}/users"))
11644            .send()
11645            .await
11646            .unwrap();
11647        assert_eq!(resp.status().as_u16(), 200);
11648        let body = resp.text().await.unwrap();
11649        assert_eq!(body, "list");
11650
11651        // POST /users → create route
11652        let resp = client
11653            .post(format!("http://127.0.0.1:{port}/users"))
11654            .send()
11655            .await
11656            .unwrap();
11657        assert_eq!(resp.status().as_u16(), 201);
11658        let body = resp.text().await.unwrap();
11659        assert_eq!(body, "create");
11660
11661        let _ = tokio::join!(get_handle, post_handle);
11662    }
11663
11664    #[tokio::test]
11665    async fn method_aware_dispatch_templated_path_extracts_params() {
11666        let (port, registry) = spawn_test_server().await;
11667
11668        // Register GET /users/{id} as a templated endpoint. The
11669        // dispatcher should match `/users/42` against the template and
11670        // attach `id=42` to the envelope's path_params.
11671        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11672        registry
11673            .register_rest_endpoint(
11674                "GET".into(),
11675                vec![
11676                    PathSegment::Literal("users".into()),
11677                    PathSegment::Param("id".into()),
11678                ],
11679                tx,
11680            )
11681            .await;
11682
11683        // Spawn a responder that echoes the captured id back in the body
11684        // so the test can verify the param was set.
11685        let handle = tokio::spawn(async move {
11686            if let Some(envelope) = rx.recv().await {
11687                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
11688                let _ = envelope.reply_tx.send(HttpReply {
11689                    status: 200,
11690                    headers: vec![],
11691                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
11692                });
11693            }
11694        });
11695
11696        let client = reqwest::Client::new();
11697        let resp = client
11698            .get(format!("http://127.0.0.1:{port}/users/42"))
11699            .send()
11700            .await
11701            .unwrap();
11702        assert_eq!(resp.status().as_u16(), 200);
11703        let body = resp.text().await.unwrap();
11704        assert_eq!(body, "id=42");
11705
11706        let _ = handle.await;
11707    }
11708
11709    #[tokio::test]
11710    async fn method_aware_dispatch_unmatched_method_falls_through() {
11711        // If no REST endpoint matches the method, dispatch must fall
11712        // through to the legacy api_routes lookup or static mounts. With
11713        // nothing else registered, the request gets 404 from static
11714        // dispatch.
11715        let (port, _registry) = spawn_test_server().await;
11716
11717        // Register only GET /users; a DELETE /users request has no match.
11718        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11719        _registry
11720            .register_rest_endpoint(
11721                "GET".into(),
11722                vec![PathSegment::Literal("users".into())],
11723                get_tx,
11724            )
11725            .await;
11726
11727        // Drain the GET channel in the background so the consumer side
11728        // doesn't block (we don't expect any envelopes here).
11729        let drain = tokio::spawn(async move {
11730            let mut get_rx = get_rx;
11731            while get_rx.recv().await.is_some() {}
11732        });
11733
11734        let client = reqwest::Client::new();
11735        let resp = client
11736            .delete(format!("http://127.0.0.1:{port}/users"))
11737            .send()
11738            .await
11739            .unwrap();
11740        assert_eq!(resp.status().as_u16(), 404);
11741
11742        drop(drain);
11743    }
11744
11745    #[tokio::test]
11746    async fn regression_legacy_exact_api_route_still_works() {
11747        // A `http:` route registered without an `httpMethod=` URI param
11748        // lands in the legacy api_routes registry. The dispatcher must
11749        // still find it via exact path lookup. This guards against
11750        // regressions introduced by the new REST-aware dispatch.
11751        let (port, registry) = spawn_test_server().await;
11752
11753        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11754        registry.register_api_route("/legacy/path".into(), tx).await;
11755
11756        let handle = tokio::spawn(async move {
11757            if let Some(envelope) = rx.recv().await {
11758                let _ = envelope.reply_tx.send(HttpReply {
11759                    status: 200,
11760                    headers: vec![],
11761                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
11762                });
11763            }
11764        });
11765
11766        let client = reqwest::Client::new();
11767        let resp = client
11768            .get(format!("http://127.0.0.1:{port}/legacy/path"))
11769            .send()
11770            .await
11771            .unwrap();
11772        assert_eq!(resp.status().as_u16(), 200);
11773        let body = resp.text().await.unwrap();
11774        assert_eq!(body, "legacy ok");
11775
11776        let _ = handle.await;
11777    }
11778
11779    #[allow(clippy::await_holding_lock)]
11780    #[tokio::test]
11781    async fn regression_static_mount_still_works() {
11782        // Verify that static file serving still works after the
11783        // dispatch refactor. We register a temp-dir mount and request
11784        // a file from it; the static dispatcher should serve it.
11785        let _guard = lock_registry_test_mutex();
11786        ServerRegistry::reset();
11787
11788        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
11789        std::fs::create_dir_all(&temp_dir).unwrap();
11790        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
11791        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11792
11793        let registry = make_test_registry();
11794        let serve_dir = ServeDir::new(&canonical_dir)
11795            .precompressed_gzip()
11796            .precompressed_br()
11797            .append_index_html_on_directories(true);
11798        let mount = StaticMount {
11799            mount_path: "/".to_string(),
11800            mode: MountMode::Static,
11801            dir: canonical_dir.clone(),
11802            cache_control: "public, max-age=3600".to_string(),
11803            error_pages: std::collections::HashMap::new(),
11804            serve_dir,
11805        };
11806        registry.register_static_mount(mount).await.unwrap();
11807
11808        let state = make_test_state(registry);
11809        let req = Request::builder()
11810            .uri("/regress.txt")
11811            .body(AxumBody::empty())
11812            .unwrap();
11813        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
11814        assert_eq!(resp.status(), StatusCode::OK);
11815        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11816            .await
11817            .unwrap();
11818        assert_eq!(&body[..], b"static works");
11819
11820        std::fs::remove_dir_all(&temp_dir).ok();
11821    }
11822
11823    // -----------------------------------------------------------------------
11824    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
11825    // templated from-URI round-trip. These exercise the real axum dispatch
11826    // path (register → HTTP request → reply) so a regression in any of the
11827    // three critical fixes surfaces as a test failure rather than a silent
11828    // production 404/500.
11829    // -----------------------------------------------------------------------
11830
11831    #[tokio::test]
11832    async fn deregister_one_method_keeps_sibling_verbs() {
11833        // Review C1: stopping the GET /users consumer must NOT tear down the
11834        // live POST /users endpoint. Register both, deregister GET only,
11835        // then verify POST still dispatches.
11836        let (port, registry) = spawn_test_server().await;
11837
11838        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11839        registry
11840            .register_rest_endpoint(
11841                "GET".into(),
11842                vec![PathSegment::Literal("users".into())],
11843                get_tx,
11844            )
11845            .await;
11846
11847        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11848        registry
11849            .register_rest_endpoint(
11850                "POST".into(),
11851                vec![PathSegment::Literal("users".into())],
11852                post_tx,
11853            )
11854            .await;
11855
11856        // Drain GET in the background (no requests expected after deregister).
11857        let drain = tokio::spawn(async move {
11858            let mut get_rx = get_rx;
11859            while get_rx.recv().await.is_some() {}
11860        });
11861
11862        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
11863        registry.unregister_rest_endpoint("GET", "/users").await;
11864        drop(drain);
11865
11866        let post_handle = spawn_responder(post_rx, 201, "create".into());
11867
11868        let client = reqwest::Client::new();
11869        // POST /users must still reach its consumer after GET was removed.
11870        let resp = client
11871            .post(format!("http://127.0.0.1:{port}/users"))
11872            .send()
11873            .await
11874            .unwrap();
11875        assert_eq!(resp.status().as_u16(), 201);
11876        assert_eq!(resp.text().await.unwrap(), "create");
11877
11878        let _ = post_handle.await;
11879    }
11880
11881    #[tokio::test]
11882    async fn dispatch_exact_legacy_beats_rest_template() {
11883        // Review C2: an exact legacy API route (`GET /api/users`, no
11884        // httpMethod) must win over a templated REST route
11885        // (`GET /api/{resource}`) for the request `/api/users`, per spec
11886        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
11887        let (port, registry) = spawn_test_server().await;
11888
11889        // Exact legacy route.
11890        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11891        registry
11892            .register_api_route("/api/users".into(), exact_tx)
11893            .await;
11894        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
11895
11896        // Templated REST route that would ALSO match /api/users.
11897        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11898        registry
11899            .register_rest_endpoint(
11900                "GET".into(),
11901                vec![
11902                    PathSegment::Literal("api".into()),
11903                    PathSegment::Param("resource".into()),
11904                ],
11905                tpl_tx,
11906            )
11907            .await;
11908        // The templated handler must NOT receive the /api/users request. If
11909        // it does, it replies "template-leak" so a future assertion could
11910        // catch it. We do NOT await this task: the exact-match branch wins
11911        // and the templated channel never receives, so awaiting would block
11912        // until the test runtime tears down.
11913        let _tpl_drain = tokio::spawn(async move {
11914            let mut tpl_rx = tpl_rx;
11915            if let Some(env) = tpl_rx.recv().await {
11916                let _ = env.reply_tx.send(HttpReply {
11917                    status: 200,
11918                    headers: vec![],
11919                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
11920                });
11921            }
11922        });
11923
11924        let client = reqwest::Client::new();
11925        let resp = client
11926            .get(format!("http://127.0.0.1:{port}/api/users"))
11927            .send()
11928            .await
11929            .unwrap();
11930        assert_eq!(resp.status().as_u16(), 200);
11931        // Exact-match handler answered — not the templated one.
11932        assert_eq!(resp.text().await.unwrap(), "exact");
11933
11934        let _ = exact_handle.await;
11935    }
11936
11937    #[tokio::test]
11938    async fn ambiguous_rest_templates_return_500_not_silent_404() {
11939        // Review C3: two equal-specificity templates that both match one
11940        // request are an ambiguous registration. At runtime this must
11941        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
11942        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
11943        let (port, registry) = spawn_test_server().await;
11944
11945        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11946        registry
11947            .register_rest_endpoint(
11948                "GET".into(),
11949                vec![
11950                    PathSegment::Literal("users".into()),
11951                    PathSegment::Param("id".into()),
11952                ],
11953                a_tx,
11954            )
11955            .await;
11956
11957        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11958        registry
11959            .register_rest_endpoint(
11960                "GET".into(),
11961                vec![
11962                    PathSegment::Literal("users".into()),
11963                    PathSegment::Param("name".into()),
11964                ],
11965                b_tx,
11966            )
11967            .await;
11968
11969        let client = reqwest::Client::new();
11970        let resp = client
11971            .get(format!("http://127.0.0.1:{port}/users/42"))
11972            .send()
11973            .await
11974            .unwrap();
11975        // Ambiguous → 500 (previously a silent 404).
11976        assert_eq!(resp.status().as_u16(), 500);
11977    }
11978
11979    #[test]
11980    fn from_uri_round_trips_templated_path_with_http_method() {
11981        // Review I4: a REST-lowered from-URI like
11982        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
11983        // through HttpServerConfig::from_uri, preserving the templated path
11984        // and the (uppercased) method. This is the binding the DSL lowering
11985        // emits and the consumer reads; it was previously unasserted.
11986        use crate::UriConfig;
11987        let cfg =
11988            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
11989        assert_eq!(cfg.host, "0.0.0.0");
11990        assert_eq!(cfg.port, 8080);
11991        assert_eq!(cfg.path, "/users/{id}");
11992        assert_eq!(cfg.method.as_deref(), Some("GET"));
11993
11994        // Lower-case httpMethod is uppercased (review I5).
11995        let cfg_lc =
11996            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
11997        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
11998        assert_eq!(cfg_lc.path, "/orders");
11999    }
12000
12001    // -----------------------------------------------------------------------
12002    // rc-1dk4: TypeConversionFailed → 400 Bad Request
12003    // -----------------------------------------------------------------------
12004
12005    #[test]
12006    fn type_conversion_failed_maps_to_400() {
12007        let reply = pipeline_error_to_reply(
12008            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
12009            "/api/users",
12010        );
12011        assert_eq!(reply.status, 400);
12012        // Exactly one Content-Type header, application/json
12013        let json_ct = reply
12014            .headers
12015            .iter()
12016            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12017            .count();
12018        assert_eq!(json_ct, 1);
12019        // Body must be structured error JSON with the expected fields
12020        let body = match &reply.body {
12021            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12022            _ => panic!("expected bytes body"),
12023        };
12024        let parsed: serde_json::Value =
12025            serde_json::from_str(&body).expect("body must be valid JSON");
12026        assert_eq!(parsed["error"], "bad_request");
12027        assert_eq!(parsed["message"], "invalid JSON at line 1");
12028    }
12029
12030    #[test]
12031    fn other_error_still_maps_to_500() {
12032        let reply =
12033            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
12034        assert_eq!(reply.status, 500);
12035    }
12036
12037    #[test]
12038    fn unauthenticated_maps_to_401() {
12039        let reply = pipeline_error_to_reply(
12040            CamelError::Unauthenticated("no token".to_string()),
12041            "/api/users",
12042        );
12043        assert_eq!(reply.status, 401);
12044    }
12045
12046    #[test]
12047    fn unauthorized_maps_to_403() {
12048        let reply = pipeline_error_to_reply(
12049            CamelError::Unauthorized("forbidden".to_string()),
12050            "/api/users",
12051        );
12052        assert_eq!(reply.status, 403);
12053    }
12054
12055    #[test]
12056    fn validation_error_maps_to_400() {
12057        let reply = pipeline_error_to_reply(
12058            CamelError::ValidationError("body does not match schema".to_string()),
12059            "/api/users",
12060        );
12061        assert_eq!(reply.status, 400);
12062        let json_ct = reply
12063            .headers
12064            .iter()
12065            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12066            .count();
12067        assert_eq!(json_ct, 1);
12068        let body = match &reply.body {
12069            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12070            _ => panic!("expected bytes body"),
12071        };
12072        let parsed: serde_json::Value =
12073            serde_json::from_str(&body).expect("body must be valid JSON");
12074        assert_eq!(parsed["error"], "validation_error");
12075        assert_eq!(parsed["message"], "body does not match schema");
12076    }
12077
12078    // -----------------------------------------------------------------------
12079    // rc-hlb1q: media negotiation errors → 415 / 406
12080    // -----------------------------------------------------------------------
12081
12082    #[test]
12083    fn finalizer_maps_unsupported_media_type() {
12084        let reply = pipeline_error_to_reply(
12085            CamelError::UnsupportedMediaType {
12086                consumed: "text/plain".to_string(),
12087                declared: "application/json".to_string(),
12088            },
12089            "/x",
12090        );
12091        assert_eq!(reply.status, 415);
12092        let json_ct = reply
12093            .headers
12094            .iter()
12095            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12096            .count();
12097        assert_eq!(json_ct, 1);
12098        let body = match &reply.body {
12099            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12100            _ => panic!("expected bytes body"),
12101        };
12102        let parsed: serde_json::Value =
12103            serde_json::from_str(&body).expect("body must be valid JSON");
12104        assert_eq!(parsed["error"], "unsupported_media_type");
12105        assert_eq!(
12106            parsed["message"],
12107            "consumed text/plain, declared application/json"
12108        );
12109    }
12110
12111    #[test]
12112    fn finalizer_maps_not_acceptable() {
12113        let reply = pipeline_error_to_reply(
12114            CamelError::NotAcceptable {
12115                accept: "application/xml".to_string(),
12116                produced: "application/json".to_string(),
12117            },
12118            "/x",
12119        );
12120        assert_eq!(reply.status, 406);
12121        let json_ct = reply
12122            .headers
12123            .iter()
12124            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12125            .count();
12126        assert_eq!(json_ct, 1);
12127        let body = match &reply.body {
12128            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12129            _ => panic!("expected bytes body"),
12130        };
12131        let parsed: serde_json::Value =
12132            serde_json::from_str(&body).expect("body must be valid JSON");
12133        assert_eq!(parsed["error"], "not_acceptable");
12134        assert_eq!(
12135            parsed["message"],
12136            "accept application/xml, produced application/json"
12137        );
12138    }
12139
12140    #[test]
12141    fn json_error_reply_preserves_empty_message() {
12142        let reply = json_error_reply(400, "bad_request", "".to_string());
12143        assert_eq!(reply.status, 400);
12144        let json_ct = reply
12145            .headers
12146            .iter()
12147            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12148            .count();
12149        assert_eq!(json_ct, 1);
12150        let body = match &reply.body {
12151            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12152            _ => panic!("expected bytes body"),
12153        };
12154        let parsed: serde_json::Value =
12155            serde_json::from_str(&body).expect("body must be valid JSON");
12156        assert_eq!(parsed["error"], "bad_request");
12157        assert_eq!(parsed["message"], "");
12158    }
12159
12160    #[test]
12161    fn https_consumer_without_tls_cert_errors() {
12162        let endpoint = HttpEndpoint {
12163            uri: "https://0.0.0.0:8443/api".to_string(),
12164            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12165            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12166            client: reqwest::Client::new(),
12167            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12168                PINNED_CLIENT_TTL,
12169                PINNED_CLIENT_MAX_ENTRIES,
12170            )),
12171            http_config: HttpConfig::default(),
12172        };
12173        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12174        let result = endpoint.create_consumer(rt);
12175        assert!(result.is_err(), "expected error for https without tls cert");
12176        if let Err(e) = result {
12177            let msg = e.to_string();
12178            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
12179        }
12180    }
12181
12182    #[test]
12183    fn http_consumer_with_tls_config_errors() {
12184        let endpoint = HttpEndpoint {
12185            uri: "http://0.0.0.0:8080/api".to_string(),
12186            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
12187            server_config: HttpServerConfig::from_uri(
12188                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
12189            )
12190            .unwrap(),
12191            client: reqwest::Client::new(),
12192            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12193                PINNED_CLIENT_TTL,
12194                PINNED_CLIENT_MAX_ENTRIES,
12195            )),
12196            http_config: HttpConfig::default(),
12197        };
12198        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12199        let result = endpoint.create_consumer(rt);
12200        assert!(result.is_err(), "expected error for http with tls config");
12201        if let Err(e) = result {
12202            let msg = e.to_string();
12203            assert!(msg.contains("https"), "error must mention https: {msg}");
12204        }
12205    }
12206
12207    #[test]
12208    fn https_consumer_with_partial_tls_cert_only_errors() {
12209        // tlsCert without tlsKey → tls_config is None at parse time
12210        // → create_consumer sees https:// + no TLS → must error
12211        let server_config =
12212            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12213        assert!(
12214            server_config.tls_config.is_none(),
12215            "partial tlsCert must not create ServerTlsConfig"
12216        );
12217        let endpoint = HttpEndpoint {
12218            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
12219            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
12220                .unwrap(),
12221            server_config,
12222            client: reqwest::Client::new(),
12223            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12224                PINNED_CLIENT_TTL,
12225                PINNED_CLIENT_MAX_ENTRIES,
12226            )),
12227            http_config: HttpConfig::default(),
12228        };
12229        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12230        let result = endpoint.create_consumer(rt);
12231        assert!(
12232            result.is_err(),
12233            "must error: https:// requires both tlsCert and tlsKey"
12234        );
12235    }
12236
12237    #[test]
12238    fn load_tls_config_parses_valid_pem() {
12239        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
12240        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12241        use camel_component_api::test_support::tls;
12242        let (_, cert_pem, key_pem) = tls::gen_server_cert();
12243        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
12244        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
12245
12246        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
12247        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
12248    }
12249
12250    #[tokio::test(flavor = "multi_thread")]
12251    #[allow(clippy::await_holding_lock)]
12252    async fn consumer_tls_handshake_roundtrip() {
12253        use camel_component_api::test_support::tls;
12254        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12255
12256        // Install rustls crypto provider (aws-lc-rs)
12257        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12258
12259        // Serialize against global ServerRegistry singleton
12260        let _guard = lock_registry_test_mutex();
12261
12262        // Generate CA + server cert
12263        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
12264        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
12265        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
12266        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
12267
12268        // Get ephemeral port
12269        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12270        let port = probe.local_addr().unwrap().port();
12271        drop(probe);
12272
12273        ServerRegistry::reset();
12274
12275        // Create real HttpComponent + endpoint with TLS URI
12276        let component = HttpComponent::new();
12277        let endpoint_ctx = NoOpComponentContext;
12278        let uri = format!(
12279            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12280            cert_path.to_string_lossy(),
12281            key_path.to_string_lossy(),
12282        );
12283        let endpoint = component
12284            .create_endpoint(&uri, &endpoint_ctx)
12285            .expect("create TLS endpoint");
12286        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
12287
12288        // Start consumer — this calls get_or_spawn with tls_config
12289        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12290        let token = tokio_util::sync::CancellationToken::new();
12291        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
12292        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12293
12294        // Give server time to start
12295        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
12296
12297        // Client with CA cert — REAL verification (no danger_accept_invalid)
12298        let ca_bytes = std::fs::read(&ca_path).unwrap();
12299        let client = reqwest::Client::builder()
12300            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
12301            .build()
12302            .unwrap();
12303
12304        let send_fut = client
12305            .post(format!("https://localhost:{port}/test"))
12306            .body("ping")
12307            .send();
12308
12309        // Handler: receive envelope, reply 200 with "pong" body
12310        let (http_result, _) = tokio::join!(send_fut, async {
12311            if let Some(mut envelope) = rx.recv().await {
12312                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
12313                if let Some(reply_tx) = envelope.reply_tx {
12314                    let _ = reply_tx.send(Ok(envelope.exchange));
12315                }
12316            }
12317        });
12318
12319        let resp = http_result.expect("TLS handshake + request must succeed");
12320
12321        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
12322        let body = resp.text().await.unwrap();
12323        assert_eq!(body, "pong");
12324
12325        token.cancel();
12326    }
12327
12328    #[tokio::test(flavor = "multi_thread")]
12329    #[allow(clippy::await_holding_lock)]
12330    async fn consumer_tls_rejects_client_without_ca() {
12331        use camel_component_api::test_support::tls;
12332        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12333
12334        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12335
12336        // Serialize against global ServerRegistry singleton
12337        let _guard = lock_registry_test_mutex();
12338
12339        let (_, cert_pem, key_pem) = tls::gen_server_cert();
12340        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
12341        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
12342
12343        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12344        let port = probe.local_addr().unwrap().port();
12345        drop(probe);
12346
12347        ServerRegistry::reset();
12348
12349        // Spawn TLS server via real HttpComponent path
12350        let component = HttpComponent::new();
12351        let endpoint_ctx = NoOpComponentContext;
12352        let uri = format!(
12353            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12354            cert_path.to_string_lossy(),
12355            key_path.to_string_lossy(),
12356        );
12357        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
12358        let mut consumer = endpoint.create_consumer(rt()).unwrap();
12359        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12360        let token = tokio_util::sync::CancellationToken::new();
12361        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
12362        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12363
12364        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
12365
12366        // Client WITHOUT CA cert — must fail TLS verification
12367        let client = reqwest::Client::builder().build().unwrap();
12368
12369        let result = client
12370            .get(format!("https://localhost:{port}/test"))
12371            .send()
12372            .await;
12373
12374        assert!(
12375            result.is_err(),
12376            "must reject without CA — proves real verification"
12377        );
12378
12379        token.cancel();
12380    }
12381
12382    #[test]
12383    fn server_config_partial_tls_cert_without_key() {
12384        // Parse URI with only tlsCert (no tlsKey)
12385        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12386        // Partial params → tls_config must be None
12387        assert!(cfg.tls_config.is_none());
12388    }
12389
12390    #[test]
12391    fn endpoint_uri_options_count_parity() {
12392        // Mirror struct must stay in sync with bespoke from_components parser.
12393        assert_eq!(
12394            HttpEndpointConfig::uri_options().len(),
12395            23,
12396            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
12397        );
12398    }
12399
12400    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
12401        pairs
12402            .iter()
12403            .map(|(k, v)| {
12404                (
12405                    (*k).to_string(),
12406                    serde_json::Value::String((*v).to_string()),
12407                )
12408            })
12409            .collect()
12410    }
12411
12412    #[test]
12413    fn response_emits_cache_control_via_pragma_warning() {
12414        let headers = make_headers(&[
12415            ("Cache-Control", "public, max-age=3600"),
12416            ("Via", "1.1 myproxy"),
12417            ("Pragma", "no-cache"),
12418            ("Warning", "199 misc"),
12419        ]);
12420        let selected = select_response_headers(&headers, None, None);
12421        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12422        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
12423            assert!(
12424                names.contains(&expected),
12425                "{expected} should pass through to the response"
12426            );
12427        }
12428    }
12429
12430    #[test]
12431    fn response_excludes_request_only_and_server_owned() {
12432        let headers = make_headers(&[
12433            ("User-Agent", "x"),
12434            ("Accept", "*/*"),
12435            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
12436        ]);
12437        let selected = select_response_headers(&headers, None, None);
12438        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12439        for excluded in ["User-Agent", "Accept", "Date"] {
12440            assert!(
12441                !names.contains(&excluded),
12442                "{excluded} should NOT appear in the response"
12443            );
12444        }
12445    }
12446
12447    #[test]
12448    fn response_re_derives_content_type() {
12449        let headers = make_headers(&[("Content-Type", "text/plain")]);
12450        let selected = select_response_headers(&headers, Some("application/json".into()), None);
12451        let ct_entries: Vec<&str> = selected
12452            .iter()
12453            .filter(|(k, _)| k == "Content-Type")
12454            .map(|(_, v)| v.as_str())
12455            .collect();
12456        assert_eq!(
12457            ct_entries,
12458            ["application/json"],
12459            "exactly one Content-Type entry, re-derived from user_content_type"
12460        );
12461    }
12462
12463    #[test]
12464    fn response_excludes_camel_headers() {
12465        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
12466        let selected = select_response_headers(&headers, None, None);
12467        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12468        assert!(
12469            !names.contains(&"CamelHttpPath"),
12470            "Camel-namespace headers must be excluded"
12471        );
12472        assert!(
12473            names.contains(&"Cache-Control"),
12474            "Cache-Control must pass through"
12475        );
12476    }
12477
12478    #[test]
12479    fn response_stringifies_scalar_header_values() {
12480        let mut headers = make_headers(&[("X-Label", "keep")]);
12481        headers.insert("X-Retries".to_string(), serde_json::json!(3));
12482        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12483        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12484        let selected = select_response_headers(&headers, None, None);
12485        let get = |name: &str| -> Option<&str> {
12486            selected
12487                .iter()
12488                .find(|(k, _)| k == name)
12489                .map(|(_, v)| v.as_str())
12490        };
12491        assert_eq!(
12492            get("X-Retries"),
12493            Some("3"),
12494            "integer header must be stringified"
12495        );
12496        assert_eq!(
12497            get("X-Ratio"),
12498            Some("3.5"),
12499            "float header must be stringified"
12500        );
12501        assert_eq!(
12502            get("X-Enabled"),
12503            Some("true"),
12504            "bool header must be stringified"
12505        );
12506        assert_eq!(
12507            get("X-Label"),
12508            Some("keep"),
12509            "string header must pass through"
12510        );
12511    }
12512
12513    #[test]
12514    fn response_drops_null_and_structured_header_values() {
12515        let mut headers = make_headers(&[("X-Keep", "yes")]);
12516        headers.insert("X-Null".to_string(), serde_json::Value::Null);
12517        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12518        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12519        let selected = select_response_headers(&headers, None, None);
12520        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12521        for dropped in ["X-Null", "X-Obj", "X-Arr"] {
12522            assert!(
12523                !names.contains(&dropped),
12524                "{dropped} must not be emitted: no single-value form"
12525            );
12526        }
12527        assert!(names.contains(&"X-Keep"), "scalar headers must survive");
12528    }
12529
12530    #[test]
12531    fn response_stringifies_scalars_despite_excluded_names() {
12532        // Excluded names stay excluded regardless of value type: the policy
12533        // filter runs before stringification, so numeric values cannot smuggle
12534        // content-length or server-owned headers into the reply.
12535        let mut headers = HashMap::new();
12536        headers.insert("Content-Length".to_string(), serde_json::json!(999));
12537        headers.insert("Date".to_string(), serde_json::json!(12345));
12538        let selected = select_response_headers(&headers, None, None);
12539        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12540        assert!(
12541            !names.contains(&"Content-Length"),
12542            "content-length is re-derived by the server"
12543        );
12544        assert!(!names.contains(&"Date"), "date is server-owned");
12545    }
12546
12547    #[test]
12548    fn outbound_stringifies_scalar_header_values() {
12549        let mut headers = make_headers(&[("X-Label", "keep")]);
12550        headers.insert("X-Retries".to_string(), serde_json::json!(3));
12551        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12552        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12553        let outbound = select_outbound_headers(&headers, &[], &[]);
12554        // HeaderName construction lowercases; lookups compare case-blind.
12555        let get = |name: &str| -> Option<String> {
12556            outbound
12557                .accepted
12558                .iter()
12559                .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12560                .map(|(_, v)| v.to_str().unwrap().to_string())
12561        };
12562        assert_eq!(
12563            get("X-Retries").as_deref(),
12564            Some("3"),
12565            "integer header must be stringified"
12566        );
12567        assert_eq!(
12568            get("X-Ratio").as_deref(),
12569            Some("3.5"),
12570            "float header must be stringified"
12571        );
12572        assert_eq!(
12573            get("X-Enabled").as_deref(),
12574            Some("true"),
12575            "bool header must be stringified"
12576        );
12577        assert_eq!(
12578            get("X-Label").as_deref(),
12579            Some("keep"),
12580            "string header must pass through"
12581        );
12582        assert!(outbound.drops.is_empty(), "scalar headers must not drop");
12583    }
12584
12585    #[test]
12586    fn outbound_drops_null_and_structured_header_values() {
12587        let mut headers = make_headers(&[("X-Keep", "yes")]);
12588        headers.insert("X-Null".to_string(), serde_json::Value::Null);
12589        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12590        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12591        let outbound = select_outbound_headers(&headers, &[], &[]);
12592        let has = |name: &str| {
12593            outbound
12594                .accepted
12595                .iter()
12596                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12597        };
12598        assert!(has("X-Keep"), "scalar headers must survive");
12599        for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
12600            let dropped = outbound
12601                .drops
12602                .iter()
12603                .find(|d| d.name == name)
12604                .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
12605            assert_eq!(
12606                dropped.reason, "no scalar string form",
12607                "{name} drop reason must name the value kind absence"
12608            );
12609            assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
12610        }
12611    }
12612
12613    #[test]
12614    fn outbound_stringifies_scalars_despite_excluded_names() {
12615        // Excluded names stay excluded regardless of value type: the policy
12616        // filter runs before stringification, so numeric values cannot smuggle
12617        // hop-by-hop or client-derived headers onto the wire.
12618        let mut headers = HashMap::new();
12619        headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
12620        headers.insert("Host".to_string(), serde_json::json!(12345));
12621        headers.insert("X-Ok".to_string(), serde_json::json!(7));
12622        let outbound = select_outbound_headers(&headers, &[], &[]);
12623        let has = |name: &str| {
12624            outbound
12625                .accepted
12626                .iter()
12627                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12628        };
12629        assert!(
12630            !has("Transfer-Encoding"),
12631            "hop-by-hop header must stay excluded"
12632        );
12633        assert!(!has("Host"), "host is destination-derived");
12634        assert!(has("X-Ok"), "non-excluded scalar must be stringified");
12635        assert!(
12636            outbound
12637                .drops
12638                .iter()
12639                .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
12640            "policy drop must be recorded before coercion"
12641        );
12642    }
12643
12644    #[test]
12645    fn outbound_drops_invalid_names_values_and_skip_config() {
12646        let mut headers = make_headers(&[("X-Good", "fine")]);
12647        headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
12648        headers.insert(
12649            "X-Control-Value".to_string(),
12650            serde_json::json!("line1\nline2"),
12651        );
12652        headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
12653        headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
12654        let skip = vec!["x-secret".to_string()];
12655        let outbound = select_outbound_headers(&headers, &skip, &[]);
12656        let has = |name: &str| {
12657            outbound
12658                .accepted
12659                .iter()
12660                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12661        };
12662        assert!(has("X-Good"), "valid header must survive");
12663        assert!(!has("X Bad Name"), "invalid header name must drop");
12664        assert!(!has("X-Control-Value"), "control-char value must drop");
12665        assert!(!has("X-Secret"), "skipped header must drop");
12666        assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
12667        let reason = |n: &str| {
12668            outbound
12669                .drops
12670                .iter()
12671                .find(|d| d.name == n)
12672                .map(|d| d.reason)
12673        };
12674        assert_eq!(reason("X Bad Name"), Some("invalid header name"));
12675        assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
12676        assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
12677        assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
12678    }
12679
12680    #[test]
12681    fn constructed_header_invalid_value_returns_drop_record() {
12682        let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
12683        let Err(record) = result else {
12684            panic!("invalid value must produce a drop record");
12685        };
12686        assert_eq!(record.reason, "invalid header value");
12687        assert_eq!(record.name, "user-agent");
12688        assert!(record.value_kind.is_none());
12689        let debug = format!("{record:?}");
12690        assert!(
12691            !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
12692            "drop record debug must not leak the value"
12693        );
12694    }
12695
12696    #[test]
12697    fn constructed_header_invalid_name_returns_drop_record() {
12698        let result = constructed_header("bad name", "ok");
12699        let Err(record) = result else {
12700            panic!("invalid name must produce a drop record");
12701        };
12702        assert_eq!(record.reason, "invalid header name");
12703        assert_eq!(record.name, "bad name");
12704        let debug = format!("{record:?}");
12705        assert!(
12706            !debug.contains("ok"),
12707            "drop record debug must not leak the value"
12708        );
12709    }
12710
12711    #[test]
12712    fn constructed_header_valid_pair_roundtrip() {
12713        let result = constructed_header("authorization", "Bearer abc123");
12714        let Ok((name, val)) = result else {
12715            panic!("valid pair must construct");
12716        };
12717        assert_eq!(name.as_str(), "authorization");
12718        let Ok(roundtrip) = val.to_str() else {
12719            panic!("valid value must roundtrip to str");
12720        };
12721        assert_eq!(roundtrip, "Bearer abc123");
12722    }
12723
12724    // -----------------------------------------------------------------------
12725    // Bridge proxy end-to-end integration tests (Task 4.1)
12726    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
12727    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
12728    // -----------------------------------------------------------------------
12729
12730    /// Destination server that captures the outbound request line and the
12731    /// `Host:` header the producer actually sent on the wire. Returns
12732    /// `(host_value, request_line)` so a bridge-proxy test can assert that
12733    /// the producer derived `Host` from the destination (not the exchange)
12734    /// and honoured bridging semantics for the path.
12735    async fn start_host_capturing_destination() -> (
12736        String,
12737        Arc<std::sync::Mutex<Option<(String, String)>>>,
12738        tokio::task::JoinHandle<()>,
12739    ) {
12740        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12741        let port = listener.local_addr().unwrap().port();
12742        let url = format!("http://127.0.0.1:{port}");
12743        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
12744            Arc::new(std::sync::Mutex::new(None));
12745        let captured_clone = Arc::clone(&captured);
12746        let handle = tokio::spawn(async move {
12747            use tokio::io::{AsyncReadExt, AsyncWriteExt};
12748            if let Ok((mut stream, _)) = listener.accept().await {
12749                let mut buf = vec![0u8; 16384];
12750                let n = stream.read(&mut buf).await.unwrap_or(0);
12751                let request = String::from_utf8_lossy(&buf[..n]).to_string();
12752                if request.contains("\r\n\r\n") {
12753                    let request_line = request.lines().next().unwrap_or("").to_string();
12754                    let host_value = request
12755                        .lines()
12756                        .find(|l| l.to_lowercase().starts_with("host:"))
12757                        .and_then(|l| l.split_once(':'))
12758                        .map(|(_, v)| v.trim().to_string())
12759                        .unwrap_or_default();
12760                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
12761                }
12762                let body = r#"{"echo":"ok"}"#;
12763                let resp = format!(
12764                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
12765                    body.len(),
12766                    body
12767                );
12768                let _ = stream.write_all(resp.as_bytes()).await;
12769            }
12770        });
12771        (url, captured, handle)
12772    }
12773
12774    /// A bridging producer must derive `Host` from the destination URL and
12775    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
12776    /// semantics. The wire-level proof is the raw `Host:` header and request
12777    /// line captured at the destination TCP socket.
12778    #[tokio::test]
12779    async fn bridge_proxy_outbound_host_matches_destination() {
12780        use tower::ServiceExt;
12781
12782        let (url, captured, _handle) = start_host_capturing_destination().await;
12783        // The Host header reqwest derives for http://127.0.0.1:{port} is the
12784        // authority, scheme-stripped: "127.0.0.1:{port}".
12785        let expected_host = url.strip_prefix("http://").unwrap();
12786
12787        let ctx = test_producer_ctx();
12788        let component = HttpComponent::new();
12789        let endpoint_ctx = NoOpComponentContext;
12790        let endpoint = component
12791            .create_endpoint(
12792                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
12793                &endpoint_ctx,
12794            )
12795            .unwrap();
12796        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
12797
12798        // Exchange carries a stale Host and a CamelHttpPath that bridging
12799        // must drop.
12800        let mut exchange = Exchange::new(Message::default());
12801        exchange.input.set_header("Host", "localhost");
12802        exchange.input.set_header("CamelHttpPath", "/foo");
12803
12804        let result = producer.oneshot(exchange).await;
12805        assert!(result.is_ok(), "producer call failed: {:?}", result);
12806
12807        tokio::time::sleep(Duration::from_millis(100)).await;
12808        let (host_value, request_line) = captured
12809            .lock()
12810            .unwrap()
12811            .take()
12812            .expect("destination capture mutex empty — producer did not reach the destination");
12813
12814        assert_ne!(
12815            host_value, "localhost",
12816            "bridge producer must not forward the exchange Host: localhost"
12817        );
12818        assert_eq!(
12819            host_value, expected_host,
12820            "Host must be derived from the destination authority (no scheme)"
12821        );
12822        assert!(
12823            !request_line.contains("/foo"),
12824            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
12825        );
12826    }
12827
12828    /// A response header set by the route (`Cache-Control`) must survive to
12829    /// the wire. The assertion is on the reqwest HTTP response — not an
12830    /// in-process HttpReply struct — so it proves the consumer's reply
12831    /// finaliser emitted the header over the socket.
12832    #[tokio::test]
12833    async fn bridge_proxy_route_set_response_header_survives() {
12834        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12835
12836        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12837        let port = listener.local_addr().unwrap().port();
12838        drop(listener);
12839
12840        let component = HttpComponent::new();
12841        let endpoint_ctx = NoOpComponentContext;
12842        let endpoint = component
12843            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
12844            .unwrap();
12845        let mut consumer = endpoint.create_consumer(rt()).unwrap();
12846
12847        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12848        let token = tokio_util::sync::CancellationToken::new();
12849        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
12850
12851        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12852        tokio::time::sleep(Duration::from_millis(50)).await;
12853
12854        let client = reqwest::Client::new();
12855        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
12856
12857        // Route sets Cache-Control on the outbound reply (exchange.input is
12858        // the message the reply finaliser reads — see select_response_headers
12859        // at the dispatch site).
12860        let (http_result, _) = tokio::join!(send_fut, async {
12861            if let Some(mut envelope) = rx.recv().await {
12862                envelope
12863                    .exchange
12864                    .input
12865                    .set_header("Cache-Control", "public, max-age=3600");
12866                if let Some(reply_tx) = envelope.reply_tx {
12867                    let _ = reply_tx.send(Ok(envelope.exchange));
12868                }
12869            }
12870        });
12871
12872        let resp = http_result.unwrap();
12873        assert_eq!(resp.status().as_u16(), 200);
12874
12875        let cache_control = resp.headers().get("cache-control");
12876        assert!(
12877            cache_control.is_some(),
12878            "Cache-Control header must survive to the wire response"
12879        );
12880        assert_eq!(
12881            cache_control.unwrap().to_str().unwrap(),
12882            "public, max-age=3600"
12883        );
12884
12885        token.cancel();
12886    }
12887
12888    // -----------------------------------------------------------------------
12889    // credential-sources task 2.3: credential values stay out of diagnostics
12890    // -----------------------------------------------------------------------
12891    //
12892    // camel-http has no request access log (design.md "Redaction sinks",
12893    // ADR-0051). The only diagnostic sink on the failed-auth path is
12894    // `pipeline_error_to_reply`, which renders the (generic) error message and
12895    // the *configured* route path — never the request URI, query string, or
12896    // extracted credential. These tests pin that redact-by-construction
12897    // contract: a sentinel credential presented in a declared source must not
12898    // appear in the reply body nor in any tracing record emitted while the
12899    // request is handled.
12900    //
12901    // Capture scope: `#[traced_test]` installs a per-crate env filter
12902    // (`camel_component_http=trace`), so records from OTHER targets
12903    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
12904    // redaction contract for those crates is guarded by their own tests.
12905    // Revisit this capture scope if camel-auth ever logs on the auth path.
12906    use camel_api::security_policy::CredentialSource;
12907    use camel_auth::credential_source::extract_token_from_exchange;
12908    use camel_auth::native_auth::NativeCredentialStore;
12909    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
12910
12911    // Sentinel credential values — test fixtures only, not real secrets.
12912    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
12913    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
12914    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
12915
12916    /// Build the exchange the consumer would build for a request envelope:
12917    /// standard Camel HTTP headers plus title-cased forwarded request headers.
12918    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
12919        let mut msg = Message::default();
12920        msg.set_header(
12921            "CamelHttpMethod",
12922            serde_json::Value::String(envelope.method.clone()),
12923        );
12924        msg.set_header(
12925            "CamelHttpPath",
12926            serde_json::Value::String(envelope.path.clone()),
12927        );
12928        msg.set_header(
12929            "CamelHttpQuery",
12930            serde_json::Value::String(envelope.query.clone()),
12931        );
12932        for (k, v) in &envelope.headers {
12933            if let Ok(val_str) = v.to_str() {
12934                msg.set_header(
12935                    title_case_header(k.as_str()),
12936                    serde_json::Value::String(val_str.to_string()),
12937                );
12938            }
12939        }
12940        Exchange::new(msg)
12941    }
12942
12943    /// Register a route whose responder authenticates each request against an
12944    /// empty native store, so every presented credential fails lookup with
12945    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
12946    /// authentication step (extract per `sources` → authenticate → deny) so the
12947    /// credential-extraction redaction contract is exercised on a real
12948    /// authentication failure.
12949    async fn spawn_failing_auth_route(
12950        registry: &HttpRouteRegistry,
12951        path: &str,
12952        sources: Vec<CredentialSource>,
12953    ) {
12954        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
12955            NativeCredentialStore::try_new(vec![]).unwrap(),
12956        ));
12957        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12958        registry.register_api_route(path.to_string(), tx).await;
12959        let path_owned = path.to_string();
12960        tokio::spawn(async move {
12961            while let Some(envelope) = rx.recv().await {
12962                let exchange = envelope_to_exchange(&envelope);
12963                let reply_tx = envelope.reply_tx;
12964                let result: Result<(), CamelError> = async {
12965                    let token = extract_token_from_exchange(&exchange, &sources)
12966                        .map(|extracted| extracted.token)
12967                        .ok_or_else(|| {
12968                            CamelError::Unauthenticated("no credential in any source".into())
12969                        })?;
12970                    authenticator.authenticate_bearer(&token).await?;
12971                    Ok(())
12972                }
12973                .await;
12974                let reply = match result {
12975                    Ok(()) => HttpReply {
12976                        status: 200,
12977                        headers: vec![],
12978                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
12979                    },
12980                    Err(e) => pipeline_error_to_reply(e, &path_owned),
12981                };
12982                let _ = reply_tx.send(reply);
12983            }
12984        });
12985    }
12986
12987    /// Whether any tracing record captured so far (process-wide) contains
12988    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
12989    /// shared buffer, so logs from spawned request-handling tasks are included.
12990    fn captured_logs_contain(needle: &str) -> bool {
12991        let buf = tracing_test::internal::global_buf().lock().unwrap();
12992        String::from_utf8_lossy(&buf).contains(needle)
12993    }
12994
12995    #[tracing_test::traced_test]
12996    #[tokio::test]
12997    async fn error_context_redacts_query_sentinel() {
12998        let (port, registry) = spawn_test_server().await;
12999        spawn_failing_auth_route(
13000            &registry,
13001            "/secure-query",
13002            vec![CredentialSource::QueryParam {
13003                param: "token".to_string(),
13004            }],
13005        )
13006        .await;
13007
13008        let client = reqwest::Client::new();
13009        let resp = client
13010            // allow-secret: `token` is the declared query-source param name, not a credential
13011            .get(format!(
13012                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
13013            ))
13014            .send()
13015            .await
13016            .unwrap();
13017
13018        assert_eq!(resp.status().as_u16(), 401);
13019        let body = resp.text().await.unwrap();
13020        assert_eq!(body, "Unauthorized");
13021        assert!(
13022            !body.contains(SENTINEL_QRY_42),
13023            "reply body must not contain the query credential"
13024        );
13025        assert!(
13026            !captured_logs_contain(SENTINEL_QRY_42),
13027            "no tracing record during request handling may render the query credential"
13028        );
13029        // Permanent positive control: the failed-auth warn! must be captured.
13030        // If the per-crate env filter ever stops matching, this fails loudly
13031        // instead of letting the sentinel assertions pass vacuously.
13032        assert!(
13033            captured_logs_contain("Authentication failed"),
13034            "positive control: the failed-auth warn! must be captured by the test subscriber"
13035        );
13036    }
13037
13038    #[tracing_test::traced_test]
13039    #[tokio::test]
13040    async fn error_context_redacts_cookie_sentinel() {
13041        let (port, registry) = spawn_test_server().await;
13042        spawn_failing_auth_route(
13043            &registry,
13044            "/secure-cookie",
13045            vec![CredentialSource::Cookie {
13046                name: "session".to_string(),
13047            }],
13048        )
13049        .await;
13050
13051        let client = reqwest::Client::new();
13052        let resp = client
13053            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
13054            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
13055            .send()
13056            .await
13057            .unwrap();
13058
13059        assert_eq!(resp.status().as_u16(), 401);
13060        let body = resp.text().await.unwrap();
13061        assert_eq!(body, "Unauthorized");
13062        assert!(
13063            !body.contains(SENTINEL_CKY_7),
13064            "reply body must not contain the cookie credential"
13065        );
13066        assert!(
13067            !captured_logs_contain(SENTINEL_CKY_7),
13068            "no tracing record during request handling may render the cookie credential"
13069        );
13070    }
13071
13072    #[tracing_test::traced_test]
13073    #[tokio::test]
13074    async fn error_reply_no_credential_value() {
13075        let (port, registry) = spawn_test_server().await;
13076        spawn_failing_auth_route(
13077            &registry,
13078            "/secure-bad",
13079            vec![CredentialSource::Cookie {
13080                name: "session".to_string(),
13081            }],
13082        )
13083        .await;
13084
13085        let client = reqwest::Client::new();
13086        let resp = client
13087            .get(format!("http://127.0.0.1:{port}/secure-bad"))
13088            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
13089            .send()
13090            .await
13091            .unwrap();
13092
13093        assert_eq!(resp.status().as_u16(), 401);
13094        let body = resp.text().await.unwrap();
13095        assert_eq!(body, "Unauthorized");
13096        assert!(
13097            !body.contains(SENTINEL_BAD_1),
13098            "reply body must not contain the credential value"
13099        );
13100        assert!(
13101            !captured_logs_contain(SENTINEL_BAD_1),
13102            "error logs must not render the credential value"
13103        );
13104    }
13105
13106    // -----------------------------------------------------------------------
13107    // Pinned-client-cache producer-path behavioral tests
13108    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
13109    // the endpoint cache, hostname requests build one client while the entry
13110    // stays retrievable, IP-literal requests bypass the cache)
13111    // -----------------------------------------------------------------------
13112
13113    /// Local responder that accepts any number of HTTP/1.1 connections on an
13114    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
13115    /// Unlike [`start_host_capturing_destination`], which serves exactly one
13116    /// connection, this loop keeps accepting so cache-reuse tests can drive
13117    /// several requests through one destination. Returns
13118    /// `(base_url, JoinHandle)`.
13119    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13120        use tokio::io::AsyncWriteExt;
13121
13122        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13123            .await
13124            .expect("bind ephemeral 127.0.0.1 listener");
13125        let port = listener.local_addr().expect("local addr").port();
13126        let base_url = format!("http://localhost:{port}");
13127        let handle = tokio::spawn(async move {
13128            while let Ok((mut conn, _)) = listener.accept().await {
13129                let _ = conn
13130                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13131                    .await;
13132                let _ = conn.shutdown().await;
13133            }
13134        });
13135        (base_url, handle)
13136    }
13137
13138    /// rc-0li3: local HTTPS responder — the TLS twin of
13139    /// [`spawn_multi_accept_200`]. Accepts any number of TLS connections on
13140    /// an ephemeral 127.0.0.1 port and answers each with a fixed 200. The
13141    /// certificate comes from `camel_component_api::test_support`
13142    /// (SANs: localhost, 127.0.0.1, ::1); clients run with
13143    /// `tls.insecure = true`.
13144    async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13145        use tokio::io::AsyncWriteExt;
13146
13147        let (_ca_pem, cert_pem, key_pem) =
13148            camel_component_api::test_support::tls::gen_server_cert();
13149        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
13150            .collect::<Result<_, _>>()
13151            .expect("parse server cert pem");
13152        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
13153            .expect("parse server key pem")
13154            .expect("server key present");
13155        // Explicit provider: the process default is ambiguous when multiple
13156        // crates pull rustls feature sets; the graph enables aws-lc-rs.
13157        let provider =
13158            std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
13159        let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
13160            .with_safe_default_protocol_versions()
13161            .expect("safe default protocol versions")
13162            .with_no_client_auth()
13163            .with_single_cert(certs, key)
13164            .expect("build rustls server config");
13165        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
13166
13167        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13168            .await
13169            .expect("bind ephemeral 127.0.0.1 listener");
13170        let port = listener.local_addr().expect("local addr").port();
13171        let base_url = format!("https://localhost:{port}");
13172        let handle = tokio::spawn(async move {
13173            while let Ok((conn, _)) = listener.accept().await {
13174                let acceptor = acceptor.clone();
13175                tokio::spawn(async move {
13176                    if let Ok(mut tls) = acceptor.accept(conn).await {
13177                        let _ = tls
13178                            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13179                            .await;
13180                        let _ = tls.shutdown().await;
13181                    }
13182                });
13183            }
13184        });
13185        (base_url, handle)
13186    }
13187
13188    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
13189    /// target a different authority (the 127.0.0.1 literal) on the same
13190    /// listener.
13191    fn responder_port(base_url: &str) -> u16 {
13192        url::Url::parse(base_url)
13193            .expect("responder base URL parses")
13194            .port()
13195            .expect("responder base URL carries an explicit port")
13196    }
13197
13198    /// Build an endpoint literal whose outbound config points at
13199    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
13200    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
13201    /// build counts stay observable across producers.
13202    fn endpoint_with_shared_cache(
13203        base_url: &str,
13204        pinned_cache: &Arc<PinnedClientCache>,
13205    ) -> HttpEndpoint {
13206        let uri = format!("{base_url}?allowInternal=true");
13207        HttpEndpoint {
13208            uri: uri.clone(),
13209            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
13210            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
13211            client: reqwest::Client::new(),
13212            pinned_cache: Arc::clone(pinned_cache),
13213            http_config: HttpConfig::default(),
13214        }
13215    }
13216
13217    #[tokio::test]
13218    async fn producers_share_endpoint_cache() {
13219        use tower::ServiceExt;
13220
13221        let (base_url, _handle) = spawn_multi_accept_200().await;
13222        let pinned_cache = Arc::new(PinnedClientCache::new(
13223            PINNED_CLIENT_TTL,
13224            PINNED_CLIENT_MAX_ENTRIES,
13225        ));
13226
13227        let ctx = test_producer_ctx();
13228        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13229        let producer_a = endpoint.create_producer(rt(), &ctx);
13230        let producer_b = endpoint.create_producer(rt(), &ctx);
13231
13232        // Each producer sends one exchange whose resolved URL is the
13233        // endpoint's localhost base URL (a domain name → pinned-client path).
13234        for producer in [producer_a, producer_b] {
13235            let producer = producer.expect("create producer");
13236            let exchange = Exchange::new(Message::default());
13237            let reply = producer.oneshot(exchange).await;
13238            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13239        }
13240
13241        assert_eq!(
13242            pinned_cache.build_count(),
13243            1,
13244            "both producers must hit the same shared cache entry; a second \
13245             build means sharing is broken"
13246        );
13247    }
13248
13249    #[tokio::test]
13250    async fn producer_repeated_hostname_requests_build_one_client() {
13251        use tower::ServiceExt;
13252
13253        let (base_url, _handle) = spawn_multi_accept_200().await;
13254        let pinned_cache = Arc::new(PinnedClientCache::new(
13255            PINNED_CLIENT_TTL,
13256            PINNED_CLIENT_MAX_ENTRIES,
13257        ));
13258        let ctx = test_producer_ctx();
13259        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13260        let producer = endpoint
13261            .create_producer(rt(), &ctx)
13262            .expect("create producer");
13263
13264        // Two sequential hostname requests — the cached pinned client stays
13265        // retrievable between them, so no second build may happen.
13266        for i in 0..2 {
13267            let exchange = Exchange::new(Message::default());
13268            let reply = producer.clone().oneshot(exchange).await;
13269            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13270        }
13271
13272        assert_eq!(
13273            pinned_cache.build_count(),
13274            1,
13275            "repeated hostname requests must reuse the one pinned client; \
13276             0 builds means the producer bypassed the cache, more than 1 \
13277             means the entry was dropped"
13278        );
13279    }
13280
13281    #[tokio::test]
13282    async fn ip_literal_request_never_enters_cache() {
13283        use tower::ServiceExt;
13284
13285        let (base_url, _handle) = spawn_multi_accept_200().await;
13286        let pinned_cache = Arc::new(PinnedClientCache::new(
13287            PINNED_CLIENT_TTL,
13288            PINNED_CLIENT_MAX_ENTRIES,
13289        ));
13290
13291        let ctx = test_producer_ctx();
13292        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
13293        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
13294        let producer = endpoint
13295            .create_producer(rt(), &ctx)
13296            .expect("create producer");
13297
13298        let exchange = Exchange::new(Message::default());
13299        let reply = producer.oneshot(exchange).await;
13300        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13301
13302        assert_eq!(
13303            pinned_cache.build_count(),
13304            0,
13305            "an IP-literal URL must use the shared unpinned client and \
13306             never enter the pinned cache"
13307        );
13308    }
13309
13310    #[tokio::test]
13311    async fn test_component_endpoints_share_pinned_cache() {
13312        use tower::ServiceExt;
13313
13314        let component = HttpComponent::new();
13315        let (base_url, _handle) = spawn_multi_accept_200().await;
13316        let baseline = component.pinned_cache.build_count();
13317
13318        let ctx = test_producer_ctx();
13319        let endpoint_ctx = NoOpComponentContext;
13320        for uri in [
13321            format!("{base_url}/a?allowInternal=true&k=a"),
13322            format!("{base_url}/b?allowInternal=true&k=b"),
13323        ] {
13324            let endpoint = component
13325                .create_endpoint(&uri, &endpoint_ctx)
13326                .expect("create endpoint");
13327            let producer = endpoint
13328                .create_producer(rt(), &ctx)
13329                .expect("create producer");
13330            let exchange = Exchange::new(Message::default());
13331            let reply = producer.oneshot(exchange).await;
13332            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13333        }
13334
13335        assert_eq!(
13336            component.pinned_cache.build_count() - baseline,
13337            1,
13338            "endpoints created by one component must share its pinned cache; \
13339             0 builds means the endpoints bypassed it, more than 1 means \
13340             per-endpoint caches came back"
13341        );
13342    }
13343
13344    #[tokio::test]
13345    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
13346        use tower::ServiceExt;
13347
13348        let component = HttpComponent::new();
13349        let (base_url, _handle) = spawn_multi_accept_200().await;
13350        let baseline = component.pinned_cache.build_count();
13351
13352        let ctx = test_producer_ctx();
13353        let endpoint_ctx = NoOpComponentContext;
13354        for i in 0..3 {
13355            let endpoint = component
13356                .create_endpoint(
13357                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
13358                    &endpoint_ctx,
13359                )
13360                .expect("create endpoint");
13361            let producer = endpoint
13362                .create_producer(rt(), &ctx)
13363                .expect("create producer");
13364            let exchange = Exchange::new(Message::default());
13365            let reply = producer.oneshot(exchange).await;
13366            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13367        }
13368
13369        assert_eq!(
13370            component.pinned_cache.build_count() - baseline,
13371            1,
13372            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13373             must reuse the component's one pinned cache entry; 0 builds \
13374             means the endpoints bypassed it, more than 1 means \
13375             per-endpoint caches came back"
13376        );
13377    }
13378
13379    /// rc-0li3: BEHAVIORAL https sharing pin — two https endpoints created
13380    /// through one `HttpsComponent` drive real TLS requests through the
13381    /// component's single pinned cache. A regression that reintroduces
13382    /// per-endpoint `PinnedClientCache::new` inside
13383    /// `HttpsComponent::create_endpoint` leaves the component cache at
13384    /// delta 0 and fails this test (the structural ptr_eq test cannot see
13385    /// that).
13386    #[tokio::test]
13387    async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
13388        use tower::ServiceExt;
13389
13390        let http_config = HttpConfig {
13391            tls: Some(crate::config::TlsConfig {
13392                enabled: true,
13393                insecure: true,
13394                ..Default::default()
13395            }),
13396            ..Default::default()
13397        };
13398        let component = HttpsComponent::with_config(http_config);
13399        let (base_url, _handle) = spawn_tls_multi_accept_200().await;
13400        let baseline = component.pinned_cache.build_count();
13401
13402        let ctx = test_producer_ctx();
13403        let endpoint_ctx = NoOpComponentContext;
13404        for uri in [
13405            format!("{base_url}/a?allowInternal=true&k=a"),
13406            format!("{base_url}/b?allowInternal=true&k=b"),
13407        ] {
13408            let endpoint = component
13409                .create_endpoint(&uri, &endpoint_ctx)
13410                .expect("create https endpoint");
13411            let producer = endpoint
13412                .create_producer(rt(), &ctx)
13413                .expect("create producer");
13414            let exchange = Exchange::new(Message::default());
13415            let reply = producer.oneshot(exchange).await;
13416            assert!(reply.is_ok(), "https request failed: {reply:?}");
13417        }
13418
13419        assert_eq!(
13420            component.pinned_cache.build_count() - baseline,
13421            1,
13422            "endpoints of one HttpsComponent must share its pinned cache over \
13423             real https requests; 0 builds means the endpoints bypassed it \
13424             (per-endpoint cache regression), more than 1 means \
13425             per-endpoint caches came back"
13426        );
13427    }
13428
13429    #[test]
13430    fn test_https_component_owns_distinct_cache() {
13431        let http = HttpComponent::new();
13432        let https = HttpsComponent::new();
13433
13434        assert!(
13435            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
13436            "http and https components must each own their own pinned cache"
13437        );
13438
13439        let endpoint_ctx = NoOpComponentContext;
13440        let _ = http
13441            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
13442            .expect("http endpoint");
13443        let _ = https
13444            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
13445            .expect("https endpoint");
13446
13447        assert_eq!(
13448            http.pinned_cache.build_count(),
13449            0,
13450            "endpoint creation must not build a pinned client"
13451        );
13452        assert_eq!(
13453            https.pinned_cache.build_count(),
13454            0,
13455            "endpoint creation must not build a pinned client"
13456        );
13457    }
13458
13459    #[test]
13460    fn test_component_constructor_builds_one_unpinned_client() {
13461        let baseline = build_client_call_count();
13462
13463        let _http = HttpComponent::new();
13464        assert_eq!(
13465            build_client_call_count() - baseline,
13466            1,
13467            "HttpComponent::new() must build exactly one shared unpinned client"
13468        );
13469
13470        let _https = HttpsComponent::new();
13471        assert_eq!(
13472            build_client_call_count() - baseline,
13473            2,
13474            "HttpsComponent::new() must build exactly one more shared unpinned client"
13475        );
13476    }
13477
13478    #[test]
13479    fn test_component_endpoints_share_unpinned_client() {
13480        let component = HttpComponent::new();
13481        let baseline = build_client_call_count();
13482
13483        let endpoint_ctx = NoOpComponentContext;
13484        for uri in [
13485            "http://localhost:1/a?allowInternal=true",
13486            "http://localhost:1/b?allowInternal=true",
13487        ] {
13488            let _endpoint = component
13489                .create_endpoint(uri, &endpoint_ctx)
13490                .expect("create endpoint");
13491        }
13492
13493        assert_eq!(
13494            build_client_call_count() - baseline,
13495            0,
13496            "create_endpoint must clone the component's shared unpinned client, \
13497             never build a fresh one"
13498        );
13499    }
13500
13501    #[test]
13502    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
13503        let component = HttpComponent::new();
13504        let baseline = build_client_call_count();
13505
13506        let ctx = test_producer_ctx();
13507        let endpoint_ctx = NoOpComponentContext;
13508        for i in 0..3 {
13509            let endpoint = component
13510                .create_endpoint(
13511                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
13512                    &endpoint_ctx,
13513                )
13514                .expect("create endpoint");
13515            let _producer = endpoint
13516                .create_producer(rt(), &ctx)
13517                .expect("create producer");
13518        }
13519
13520        assert_eq!(
13521            build_client_call_count() - baseline,
13522            0,
13523            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13524             must reuse the component's shared unpinned client and build \
13525             no additional clients"
13526        );
13527    }
13528
13529    #[test]
13530    fn test_webpki_fallback_config_builds_client() {
13531        // The fallback backend must construct a client on ANY platform —
13532        // it reads no platform state. This is the exact TLS configuration
13533        // Termux executes when the primary (platform-verifier) build
13534        // fails (rc-3j4mq).
13535        let client = reqwest::Client::builder()
13536            .tls_backend_preconfigured(webpki_root_client_config())
13537            .build()
13538            .expect("webpki-rooted client must build"); // allow-unwrap(test)
13539        // A built client is usable (internal state initialized); assert
13540        // via the debug render rather than a network round-trip.
13541        let rendered = format!("{client:?}");
13542        assert!(
13543            !rendered.is_empty(),
13544            "client must render a debug representation"
13545        );
13546    }
13547
13548    #[test]
13549    fn test_build_client_falls_back_on_empty_platform_ca_store() {
13550        // Serialize against the primary-path test: the env window below
13551        // is process-visible and would force its build through the
13552        // fallback too.
13553        let _ca_guard = lock_ca_store_test_mutex();
13554        // Simulate a CA-less platform (Android/Termux, rc-3j4mq):
13555        // openssl-probe honors SSL_CERT_FILE/SSL_CERT_DIR only when the
13556        // paths EXIST, so an existing-but-empty file plus an
13557        // existing-but-empty directory make rustls-native-certs load zero
13558        // roots — the exact condition that made the platform verifier
13559        // (and pre-fix startup) fail on Termux. The env window is
13560        // process-visible: concurrent client builds in other tests also
13561        // take the fallback, which still builds a working client — no
13562        // assertion outside this test can distinguish the two paths
13563        // except via this thread's BUILD_CLIENT_FALLBACKS counter.
13564        let empty_ca_dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
13565        let empty_ca_file = empty_ca_dir.path().join("empty-ca-bundle.pem");
13566        std::fs::write(&empty_ca_file, b"").expect("write empty CA file"); // allow-unwrap(test)
13567
13568        // Safety: test-only mutation of process env vars. This test does
13569        // not spawn threads that read these vars outside the guarded
13570        // window below; the guard restores previous values before return.
13571        let (prev_file, prev_dir) = unsafe {
13572            let prev = (
13573                std::env::var_os("SSL_CERT_FILE"),
13574                std::env::var_os("SSL_CERT_DIR"),
13575            );
13576            std::env::set_var("SSL_CERT_FILE", &empty_ca_file);
13577            std::env::set_var("SSL_CERT_DIR", empty_ca_dir.path());
13578            prev
13579        };
13580
13581        let result = std::panic::catch_unwind(|| {
13582            let fallbacks_before = build_client_fallback_count();
13583            let _client = build_client(&HttpConfig::default(), None);
13584            build_client_fallback_count() - fallbacks_before
13585        });
13586
13587        // Restore FIRST so the guard holds even if assertions fail.
13588        // Safety: restoring the previously-captured values.
13589        unsafe {
13590            match prev_file {
13591                Some(v) => std::env::set_var("SSL_CERT_FILE", v),
13592                None => std::env::remove_var("SSL_CERT_FILE"),
13593            }
13594            match prev_dir {
13595                Some(v) => std::env::set_var("SSL_CERT_DIR", v),
13596                None => std::env::remove_var("SSL_CERT_DIR"),
13597            }
13598        }
13599
13600        let fallbacks_taken = result.expect("build_client must not panic on an empty CA store"); // allow-unwrap(test)
13601        assert_eq!(
13602            fallbacks_taken, 1,
13603            "an empty platform CA store must route exactly one build through \
13604             the webpki fallback (primary build failed, fallback succeeded)"
13605        );
13606    }
13607
13608    /// Throwaway self-signed root used ONLY to make the native-root
13609    /// store non-empty hermetically (tests need a parseable PEM
13610    /// CERTIFICATE section, nothing more — the private key was discarded
13611    /// at generation and the root signs/trusts nothing). Generated
13612    /// 2026-09-21, self-expiring 2126.
13613    const HERMETIC_TEST_ROOT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
13614        MIIDVzCCAj+gAwIBAgIUQ+hB0JPFtHpUKaNwcTB8ijGqEW0wDQYJKoZIhvcNAQEL\n\
13615        BQAwOjEdMBsGA1UEAwwUY2FtZWwtaHR0cCB0ZXN0IHJvb3QxGTAXBgNVBAoMEGNh\n\
13616        bWVsLWh0dHAgdGVzdHMwIBcNMjYwOTIxMTEyMzUwWhgPMjEyNjA4MjgxMTIzNTBa\n\
13617        MDoxHTAbBgNVBAMMFGNhbWVsLWh0dHAgdGVzdCByb290MRkwFwYDVQQKDBBjYW1l\n\
13618        bC1odHRwIHRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAolnF\n\
13619        SiT/bK8pMl9n50HTQj4oxPZGXK34Q/OSq8TMUEcmhUFzBjzwZKoFqvDIt/e0mSi/\n\
13620        h8WQlnrHAvYOnsRlpDsfs4yder+lpEYE84OooJjQvj/kREj7ncK6WQKDY1NTGikx\n\
13621        lF8kIaCrNHNIlUgoSWMR4E6UshLFwSu5lKtcWCeN6FNzGVQ9jxYJSFsmVk+wFyHI\n\
13622        edvpPbnFkD3M1/GKNVuxCCR50sO+cQeB7w9FCyFdHvoTWWuwPV4Qq2LBM2n4eedG\n\
13623        pCXkqmuARtFuOKjpYIRryGybic9u9ZW59FbTbgHSj6rcOXNtBUVV/KnWRDRWo7ZC\n\
13624        cR5SmO2YwtfpISjbmwIDAQABo1MwUTAdBgNVHQ4EFgQUkhwU0Q5JYNLrs7yOqbXE\n\
13625        szzn2/cwHwYDVR0jBBgwFoAUkhwU0Q5JYNLrs7yOqbXEszzn2/cwDwYDVR0TAQH/\n\
13626        BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAXLqN7poLyxlpxN9vY1jISs403P2I\n\
13627        edwgX3eWyJgXQfc/+dhnig8Pi7SkJKNM9PM27qain3+e2/HeetNYGBD8xnzQo5ga\n\
13628        1ScPvm7u2DWIWc5y0bpm5JuCPqXoDl/7kBcaelA2lZRrPJzQuu6uPnH1ubjUPFJj\n\
13629        9SxWhR+QgJW3cFc17C179WSVSJSPLozxX1ODABSyxNXnz14Rq+MOJi2tEsDggyMm\n\
13630        txh6GwgVbiSey9c9bvu3B28iPfNpULuxBzxSLHcRrSSoII+46foY0G63V/jx7huY\n\
13631        Fc2DPwOcO2Ni8fLssMaAnEdWPZaIN/DdjmUFw9XgHE9Eld6aKEXBcRFPzA==\n\
13632        -----END CERTIFICATE-----";
13633
13634    #[test]
13635    fn test_build_client_primary_path_when_platform_store_non_empty() {
13636        // The webpki set is a FALLBACK, never a replacement for platform
13637        // roots (managed fleets keep OS root-program control). Hermetic:
13638        // SSL_CERT_FILE pinned to a parseable fixture root makes the
13639        // native store non-empty on ANY host — including genuinely
13640        // CA-less ones — so the no-fallback assertion cannot depend on
13641        // the build machine. Serialized against the CA-less sibling test
13642        // by the CA-store mutex (its env window forces the fallback).
13643        let _ca_guard = lock_ca_store_test_mutex();
13644        let ca_dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
13645        let ca_file = ca_dir.path().join("hermetic-root.pem");
13646        std::fs::write(&ca_file, HERMETIC_TEST_ROOT_PEM).expect("write fixture root"); // allow-unwrap(test)
13647
13648        // Safety: test-only mutation of process env vars, restored
13649        // before any assertion below.
13650        let (prev_file, prev_dir) = unsafe {
13651            let prev = (
13652                std::env::var_os("SSL_CERT_FILE"),
13653                std::env::var_os("SSL_CERT_DIR"),
13654            );
13655            std::env::set_var("SSL_CERT_FILE", &ca_file);
13656            std::env::set_var("SSL_CERT_DIR", ca_dir.path());
13657            prev
13658        };
13659
13660        let fallbacks_before = build_client_fallback_count();
13661        let _client = build_client(&HttpConfig::default(), None);
13662        let fallbacks_taken = build_client_fallback_count() - fallbacks_before;
13663
13664        // Safety: restoring the previously-captured values.
13665        unsafe {
13666            match prev_file {
13667                Some(v) => std::env::set_var("SSL_CERT_FILE", v),
13668                None => std::env::remove_var("SSL_CERT_FILE"),
13669            }
13670            match prev_dir {
13671                Some(v) => std::env::set_var("SSL_CERT_DIR", v),
13672                None => std::env::remove_var("SSL_CERT_DIR"),
13673            }
13674        }
13675
13676        assert_eq!(
13677            fallbacks_taken, 0,
13678            "with at least one platform root loadable, build_client must \
13679             use the platform-verifier primary path, never the webpki \
13680             fallback"
13681        );
13682    }
13683}