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;
12#[cfg(test)]
13mod tls_harness;
14pub(crate) mod tls_reload;
15use crate::config::parse_ok_status_code_range;
16pub use bundle::HttpBundle;
17pub use bundle::HttpStaticBundle;
18pub(crate) use client_cache::{
19    HttpComponentKind, PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache,
20};
21pub use config::HttpConfig;
22pub use health::HttpHealthCheck;
23pub use registry::HttpRouteRegistry;
24pub use static_config::HttpStaticConfig;
25pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
26
27use std::collections::HashMap;
28use std::future::Future;
29use std::pin::Pin;
30
31use std::sync::{Arc, Mutex, OnceLock};
32use std::task::{Context, Poll};
33use std::time::Duration;
34
35use tokio::sync::OnceCell;
36use tower::Layer;
37use tower::Service;
38use tracing::debug;
39
40use axum::body::BodyDataStream;
41use camel_api::component_metadata::ComponentMetadata;
42use camel_auth::bearer_token_layer::BearerTokenLayer;
43use camel_auth::oauth2::TokenProvider;
44use camel_component_api::tls_source::ServerTlsSource;
45use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
46use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
47use camel_component_api::{UriComponents, UriConfig, parse_uri, raw_query_pairs};
48use futures::StreamExt;
49use futures::TryStreamExt;
50use futures::stream::BoxStream;
51
52// ---------------------------------------------------------------------------
53// HttpEndpointConfig
54// ---------------------------------------------------------------------------
55
56/// Configuration for an HTTP client (producer) endpoint.
57///
58/// # Memory Limits
59///
60/// HTTP operations enforce conservative memory limits to prevent denial-of-service
61/// attacks from untrusted network sources. These limits are significantly lower than
62/// file component limits (100MB) because HTTP typically handles API responses rather
63/// than large file transfers, and clients may be untrusted.
64///
65/// ## Default Limits
66///
67/// - **HTTP client body**: 10MB (typical API responses)
68/// - **HTTP server request**: 2MB (untrusted network input - see `HttpServerConfig`)
69/// - **HTTP server response**: 10MB (same as client - see `HttpServerConfig`)
70///
71/// ## Rationale
72///
73/// The 10MB limit for HTTP client responses is appropriate for most API interactions
74/// while providing protection against:
75/// - Malicious servers sending oversized responses
76/// - Runaway processes generating unexpectedly large payloads
77/// - Memory exhaustion attacks
78///
79/// The 2MB server request limit is even more conservative because it handles input
80/// from potentially untrusted clients on the public internet.
81///
82/// ## Overriding Limits
83///
84/// Override the default client body limit using the `maxBodySize` URI parameter:
85///
86/// ```text
87/// http://api.example.com/large-data?maxBodySize=52428800
88/// ```
89///
90/// For server endpoints, use `maxRequestBody` and `maxResponseBody` parameters:
91///
92/// ```text
93/// http://0.0.0.0:8080/upload?maxRequestBody=52428800
94/// ```
95///
96/// ## Behavior When Exceeded
97///
98/// When a body exceeds the configured limit:
99/// - An error is returned immediately
100/// - No memory is exhausted - the limit is checked before allocation
101/// - The HTTP connection is terminated cleanly
102///
103/// ## Security Considerations
104///
105/// HTTP endpoints should be treated with more caution than file endpoints because:
106/// - Clients may be unknown and untrusted
107/// - Network traffic can be spoofed or malicious
108/// - DoS attacks often exploit unbounded resource consumption
109///
110/// Only increase limits when you control both ends of the connection or when
111/// business requirements demand larger payloads.
112#[derive(Clone)]
113pub struct HttpEndpointConfig {
114    pub base_url: String,
115    pub http_method: Option<String>,
116    pub throw_exception_on_failure: bool,
117    pub ok_status_code_range: (u16, u16),
118    pub response_timeout: Option<Duration>,
119    /// Programmatic query parameters, serialized in declaration order with
120    /// minimal RFC-3986 encoding (`%20`, never `+`). Never populated from
121    /// the endpoint URI — set by callers via config construction.
122    pub query_params: Vec<(String, String)>,
123    /// Authored query bytes from the endpoint URI, verbatim (no decode, no
124    /// re-encode, no `RAW(...)` unwrapping). `Some("")` preserves a bare
125    /// `?` marker. Sole carrier of URI-authored pairs; consumed option
126    /// keys are filtered out at serialization time.
127    pub raw_query: Option<String>,
128    pub allow_internal: bool,
129    /// Public-cleartext transport consent (ADR-0081): when `false` (the
130    /// default), cleartext `http://` requests to PUBLIC targets are
131    /// rejected. Independent of `allow_internal` — internal-network
132    /// consent and cleartext-transport consent are separate decisions, and
133    /// there is deliberately no global cleartext lever.
134    pub allow_cleartext: bool,
135    pub blocked_hosts: Vec<String>,
136    pub max_body_size: usize,
137    pub read_timeout_ms: u64,
138    pub max_response_bytes: usize,
139    pub auth: HttpAuth,
140    pub token_provider: Option<Arc<dyn TokenProvider>>,
141    pub user_agent: Option<String>,
142    pub bridge_endpoint: bool,
143    pub connection_close: bool,
144    pub skip_request_headers: Vec<String>,
145    pub skip_response_headers: Vec<String>,
146    pub follow_redirects: bool,
147    pub max_redirects: usize,
148    /// CamelHttpUri host fence (`allowedUriHosts`): `None` when the option
149    /// is absent (override behavior unchanged); `Some` arms the fail-closed
150    /// fence. Parsed entries only — never re-serialized into the outbound
151    /// query.
152    pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
153}
154
155/// ADR-0051 redact-by-construction, ADR-0076 strictest-wins: query bytes
156/// (authored `raw_query` and programmatic `query_params`) may carry
157/// credentials. The display-surface Debug renders the raw view
158/// blanket-masked (mirroring `redact_url_for_diagnostics`) and programmatic
159/// values masked, mirroring `UriComponents`' sensitive-value masking.
160/// `base_url` routes through the canonical
161/// [`camel_api::redact::redact_url`] (string surgery, no `url::Url`
162/// roundtrip, so authored bytes are never WHATWG-normalized): userinfo is
163/// masked in every authority window, query and fragment bytes are dropped
164/// behind their sentinels, and the result is capped at 256 bytes (rc-yvjp3
165/// converged the former byte-preserving local variant). Wire fidelity is
166/// unaffected.
167impl std::fmt::Debug for HttpEndpointConfig {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.debug_struct("HttpEndpointConfig")
170            .field("base_url", &camel_api::redact::redact_url(&self.base_url))
171            .field("http_method", &self.http_method)
172            .field(
173                "throw_exception_on_failure",
174                &self.throw_exception_on_failure,
175            )
176            .field("ok_status_code_range", &self.ok_status_code_range)
177            .field("response_timeout", &self.response_timeout)
178            .field(
179                "query_params",
180                &self
181                    .query_params
182                    .iter()
183                    .map(|(key, _)| (key, "***"))
184                    .collect::<Vec<_>>(),
185            )
186            .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
187            .field("allow_internal", &self.allow_internal)
188            .field("allow_cleartext", &self.allow_cleartext)
189            .field("blocked_hosts", &self.blocked_hosts)
190            .field("max_body_size", &self.max_body_size)
191            .field("read_timeout_ms", &self.read_timeout_ms)
192            .field("max_response_bytes", &self.max_response_bytes)
193            .field("auth", &self.auth)
194            .field("token_provider", &self.token_provider)
195            .field("user_agent", &self.user_agent)
196            .field("bridge_endpoint", &self.bridge_endpoint)
197            .field("connection_close", &self.connection_close)
198            .field("skip_request_headers", &self.skip_request_headers)
199            .field("skip_response_headers", &self.skip_response_headers)
200            .field("follow_redirects", &self.follow_redirects)
201            .field("max_redirects", &self.max_redirects)
202            .field("allowed_uri_hosts", &self.allowed_uri_hosts)
203            .finish()
204    }
205}
206
207#[derive(Clone, PartialEq)]
208pub enum HttpAuth {
209    None,
210    Basic { username: String, password: String },
211    Bearer { token: String },
212}
213
214impl std::fmt::Debug for HttpAuth {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        match self {
217            HttpAuth::None => f.write_str("None"),
218            HttpAuth::Basic { username, .. } => f
219                .debug_struct("Basic")
220                .field("username", username)
221                .field("password", &"***")
222                .finish(),
223            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
224        }
225    }
226}
227
228/// Whether `key` names a camel-http endpoint option consumed at parse time.
229///
230/// Single metadata-driven owner of OUTBOUND option filtering (ADR-0041):
231/// derived from the `#[uri_param]` metadata behind
232/// [`HttpEndpointConfig::uri_options`], so the raw query filter consumes
233/// exactly the keys the component documents — no duplicated handwritten
234/// key lists. `from_components`'s manual typed parsing stays direct and
235/// unchanged; this predicate never re-wires it.
236fn is_consumed_option(key: &str) -> bool {
237    HttpEndpointConfig::uri_options()
238        .iter()
239        .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
240}
241
242impl UriConfig for HttpEndpointConfig {
243    /// Returns "http" as the primary scheme (also accepts "https")
244    fn scheme() -> &'static str {
245        "http"
246    }
247
248    fn from_uri(uri: &str) -> Result<Self, CamelError> {
249        let parts = parse_uri(uri)?;
250        Self::from_components(parts)
251    }
252
253    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
254        // Validate scheme - accept both http and https
255        if parts.scheme != "http" && parts.scheme != "https" {
256            return Err(CamelError::InvalidUri(format!(
257                "expected scheme 'http' or 'https', got '{}'",
258                parts.scheme
259            )));
260        }
261
262        // Construct base_url from scheme + path
263        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
264        let base_url = format!("{}:{}", parts.scheme, parts.path);
265
266        let http_method = parts.params.get("httpMethod").cloned();
267
268        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
269            Some(v) => parse_bool_param_http(v).map_err(|e| {
270                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
271            })?,
272            None => true,
273        };
274
275        // Parse status code range from "start-end" format (e.g., "200-299")
276        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
277            Some(v) => parse_ok_status_code_range(v)?,
278            None => (200, 299),
279        };
280
281        let response_timeout = match parts.params.get("responseTimeout") {
282            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
283                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
284            })?),
285            None => None,
286        };
287
288        // SSRF protection settings
289        let allow_internal = match parts.params.get("allowInternal") {
290            Some(v) => parse_bool_param_http(v).map_err(|e| {
291                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
292            })?,
293            None => false, // Default: block private IPs
294        };
295
296        // Public-cleartext transport consent (ADR-0081) — per-endpoint
297        // only, deliberately never inherited from the global HttpConfig.
298        let allow_cleartext = match parts.params.get("allowCleartext") {
299            Some(v) => parse_bool_param_http(v).map_err(|e| {
300                CamelError::InvalidUri(format!("invalid value for allowCleartext: {e}"))
301            })?,
302            None => false, // Default: reject cleartext http:// to public targets
303        };
304
305        // Parse comma-separated blocked hosts
306        let blocked_hosts = parts
307            .params
308            .get("blockedHosts")
309            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
310            .unwrap_or_default();
311
312        let max_body_size = match parts.params.get("maxBodySize") {
313            Some(v) => v.parse::<usize>().map_err(|e| {
314                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
315            })?,
316            None => 10 * 1024 * 1024, // Default: 10MB
317        };
318
319        let read_timeout_ms = match parts.params.get("readTimeout") {
320            Some(v) => v.parse::<u64>().map_err(|e| {
321                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
322            })?,
323            None => 30_000, // Default: 30s
324        };
325
326        let max_response_bytes = match parts.params.get("maxResponseBytes") {
327            Some(v) => v.parse::<usize>().map_err(|e| {
328                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
329            })?,
330            None => 10 * 1024 * 1024, // Default: 10MB
331        };
332
333        let auth = parse_auth_from_params(&parts.params)?;
334
335        let user_agent = parts.params.get("userAgent").cloned();
336
337        if parts.params.contains_key("cookieHandling") {
338            return Err(CamelError::InvalidUri(
339                "cookieHandling is not supported".into(),
340            ));
341        }
342
343        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
344            Some(v) => parse_bool_param_http(v).map_err(|e| {
345                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
346            })?,
347            None => false,
348        };
349
350        let connection_close = match parts.params.get("connectionClose") {
351            Some(v) => parse_bool_param_http(v).map_err(|e| {
352                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
353            })?,
354            None => false,
355        };
356
357        let skip_request_headers = parts
358            .params
359            .get("skipRequestHeaders")
360            .map(|v| {
361                v.split(',')
362                    .map(str::trim)
363                    .filter(|s| !s.is_empty())
364                    .map(|s| s.to_ascii_lowercase())
365                    .collect::<Vec<_>>()
366            })
367            .unwrap_or_default();
368
369        let skip_response_headers = parts
370            .params
371            .get("skipResponseHeaders")
372            .map(|v| {
373                v.split(',')
374                    .map(str::trim)
375                    .filter(|s| !s.is_empty())
376                    .map(|s| s.to_ascii_lowercase())
377                    .collect::<Vec<_>>()
378            })
379            .unwrap_or_default();
380
381        let follow_redirects = match parts.params.get("followRedirects") {
382            Some(v) => parse_bool_param_http(v).map_err(|e| {
383                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
384            })?,
385            None => false,
386        };
387
388        let max_redirects = match parts.params.get("maxRedirects") {
389            Some(v) => v.parse::<usize>().map_err(|e| {
390                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
391            })?,
392            None => 10,
393        };
394
395        // CamelHttpUri host fence: parsed eagerly so a malformed or empty
396        // allowlist fails endpoint creation (fail-closed), not resolution.
397        let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
398            Some(v) => Some(parse_allowed_uri_hosts(v)?),
399            None => None,
400        };
401
402        // Authored pairs ride raw_query verbatim (the sole carrier);
403        // query_params is programmatic-only — never auto-populated from
404        // URI leftovers. Consumed option keys are filtered at
405        // serialization time by `is_consumed_option`.
406        let raw_query = parts.raw_query.clone();
407
408        Ok(Self {
409            base_url,
410            http_method,
411            throw_exception_on_failure,
412            ok_status_code_range,
413            response_timeout,
414            query_params: Vec::new(),
415            raw_query,
416            allow_internal,
417            allow_cleartext,
418            blocked_hosts,
419            max_body_size,
420            read_timeout_ms,
421            max_response_bytes,
422            auth,
423            token_provider: None,
424            user_agent,
425            bridge_endpoint,
426            connection_close,
427            skip_request_headers,
428            skip_response_headers,
429            follow_redirects,
430            max_redirects,
431            allowed_uri_hosts,
432        })
433    }
434}
435
436/// Private container for macro-derived `uri_options()` and `metadata()`.
437///
438/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
439/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
440/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
441/// derivation targets this inner type whose fields are all URI-param-compatible.
442#[derive(Debug, Clone, UriConfig)]
443#[allow(dead_code)]
444#[uri_scheme = "http"]
445#[uri_config(
446    skip_impl,
447    metadata(
448        scheme = "http",
449        description = "HTTP client and server component",
450        producer,
451        consumer,
452        streaming
453    ),
454    crate = "camel_component_api"
455)]
456struct HttpEndpointUriConfig {
457    #[allow(dead_code)]
458    _base_url: String,
459
460    #[uri_param(
461        name = "httpMethod",
462        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
463    )]
464    http_method: Option<String>,
465
466    #[uri_param(
467        name = "throwExceptionOnFailure",
468        default = "true",
469        desc = "Throw on non-2xx status"
470    )]
471    throw_exception_on_failure: bool,
472
473    #[uri_param(
474        name = "okStatusCodeRange",
475        default = "200-299",
476        desc = "Success status code range"
477    )]
478    ok_status_code_range: String,
479
480    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
481    response_timeout: Option<u64>,
482
483    #[uri_param(
484        name = "connectTimeout",
485        desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
486    )]
487    connect_timeout: Option<u64>,
488
489    #[uri_param(
490        name = "allowInternal",
491        default = "false",
492        desc = "Allow private/internal network destinations (SSRF)"
493    )]
494    allow_internal: bool,
495
496    #[uri_param(
497        name = "allowCleartext",
498        default = "false",
499        desc = "Allow cleartext http:// to public targets (transport consent; ADR-0081)"
500    )]
501    allow_cleartext: bool,
502
503    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
504    blocked_hosts: Option<String>,
505
506    #[uri_param(
507        name = "maxBodySize",
508        default = "10485760",
509        desc = "Max request/response body bytes"
510    )]
511    max_body_size: u64,
512
513    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
514    read_timeout: Option<u64>,
515
516    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
517    max_response_bytes: Option<u64>,
518
519    #[uri_param(
520        name = "authMethod",
521        kind = "enum:None,Basic,Bearer",
522        desc = "Authentication method"
523    )]
524    auth_method: Option<String>,
525
526    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
527    auth_username: Option<String>,
528
529    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
530    auth_password: Option<String>,
531
532    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
533    auth_bearer_token: Option<String>,
534
535    #[uri_param(name = "userAgent", desc = "User-Agent header")]
536    user_agent: Option<String>,
537
538    #[uri_param(
539        name = "bridgeEndpoint",
540        default = "false",
541        desc = "Bridge endpoint mode"
542    )]
543    bridge_endpoint: bool,
544
545    #[uri_param(
546        name = "connectionClose",
547        default = "false",
548        desc = "Send Connection: close"
549    )]
550    connection_close: bool,
551
552    #[uri_param(
553        name = "skipRequestHeaders",
554        desc = "Comma-separated request headers to skip"
555    )]
556    skip_request_headers: Option<String>,
557
558    #[uri_param(
559        name = "skipResponseHeaders",
560        desc = "Comma-separated response headers to skip"
561    )]
562    skip_response_headers: Option<String>,
563
564    #[uri_param(
565        name = "followRedirects",
566        default = "false",
567        desc = "Follow HTTP redirects"
568    )]
569    follow_redirects: bool,
570
571    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
572    max_redirects: u64,
573
574    #[uri_param(
575        name = "allowedUriHosts",
576        desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
577    )]
578    allowed_uri_hosts: Option<String>,
579}
580
581impl HttpEndpointConfig {
582    /// Component metadata for the http/https scheme, derived from the
583    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
584    pub fn metadata() -> ComponentMetadata {
585        HttpEndpointUriConfig::metadata()
586    }
587
588    /// URI option definitions, derived from `#[uri_param]` fields.
589    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
590        HttpEndpointUriConfig::uri_options()
591    }
592}
593
594fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
595    let Some(method) = params.get("authMethod") else {
596        return Ok(HttpAuth::None);
597    };
598
599    if method.eq_ignore_ascii_case("none") {
600        return Ok(HttpAuth::None);
601    }
602
603    if method.eq_ignore_ascii_case("basic") {
604        let username = params.get("authUsername").cloned().ok_or_else(|| {
605            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
606        })?;
607        let password = params.get("authPassword").cloned().ok_or_else(|| {
608            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
609        })?;
610        return Ok(HttpAuth::Basic { username, password });
611    }
612
613    if method.eq_ignore_ascii_case("bearer") {
614        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
615            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
616        })?;
617        return Ok(HttpAuth::Bearer { token });
618    }
619
620    Err(CamelError::InvalidUri(format!(
621        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
622    )))
623}
624
625fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
626    match value.to_ascii_lowercase().as_str() {
627        "true" | "1" | "yes" => Ok(true),
628        "false" | "0" | "no" => Ok(false),
629        _ => Err(CamelError::InvalidUri(format!(
630            "invalid boolean value: '{value}'"
631        ))),
632    }
633}
634
635impl HttpEndpointConfig {
636    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
637        let parts = parse_uri(uri)?;
638        let mut endpoint = Self::from_components(parts.clone())?;
639        if endpoint.response_timeout.is_none() {
640            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
641        }
642        if !parts.params.contains_key("allowInternal") {
643            endpoint.allow_internal = config.allow_internal;
644        }
645        if !parts.params.contains_key("blockedHosts") {
646            endpoint.blocked_hosts = config.blocked_hosts.clone();
647        }
648        if !parts.params.contains_key("maxBodySize") {
649            endpoint.max_body_size = config.max_body_size;
650        }
651        if !parts.params.contains_key("readTimeout") {
652            endpoint.read_timeout_ms = config.read_timeout_ms;
653        }
654        if !parts.params.contains_key("maxResponseBytes") {
655            endpoint.max_response_bytes = config.max_response_bytes;
656        }
657        if !parts.params.contains_key("okStatusCodeRange")
658            && let Some(range) = &config.ok_status_code_range
659        {
660            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
661        }
662        if !parts.params.contains_key("followRedirects") {
663            endpoint.follow_redirects = config.follow_redirects;
664        }
665        if !parts.params.contains_key("maxRedirects") {
666            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
667        }
668
669        Ok(endpoint)
670    }
671}
672
673// ---------------------------------------------------------------------------
674// HttpServerConfig
675// ---------------------------------------------------------------------------
676
677/// Configuration for an HTTP server (consumer) endpoint.
678#[derive(Debug, Clone)]
679pub struct HttpServerConfig {
680    /// URI scheme ("http" or "https") parsed from the endpoint URI.
681    pub scheme: String,
682    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
683    pub host: String,
684    /// TCP port to listen on.
685    pub port: u16,
686    /// URL path this consumer handles, e.g. "/orders".
687    pub path: String,
688    /// Maximum request body size in bytes.
689    pub max_request_body: usize,
690    /// Maximum response body size for materializing streams in bytes.
691    pub max_response_body: usize,
692    /// Maximum number of in-flight requests handled concurrently by this server.
693    /// bd rc-ns3yc: values above `tokio::sync::Semaphore::MAX_PERMITS` are
694    /// rejected at parse, `create_consumer`, start, and `spawn_entry` (the
695    /// inflight semaphore panics above that bound); `0` remains a
696    /// representable reject-everything value.
697    pub max_inflight_requests: usize,
698    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
699    /// the consumer registers as a method-aware REST endpoint and the
700    /// path is treated as a template (e.g. `/users/{id}` is matched
701    /// against any `/users/<value>`). When `None`, the consumer
702    /// registers in the legacy path-only `api_routes` registry.
703    /// Extracted from the `httpMethod=` URI param at config build time.
704    pub method: Option<String>,
705    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
706    /// `None` for plain HTTP servers.
707    pub tls_config: Option<crate::config::ServerTlsConfig>,
708}
709
710impl UriConfig for HttpServerConfig {
711    /// Returns "http" as the primary scheme (also accepts "https")
712    fn scheme() -> &'static str {
713        "http"
714    }
715
716    fn from_uri(uri: &str) -> Result<Self, CamelError> {
717        let parts = parse_uri(uri)?;
718        Self::from_components(parts)
719    }
720
721    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
722        // Validate scheme - accept both http and https
723        if parts.scheme != "http" && parts.scheme != "https" {
724            return Err(CamelError::InvalidUri(format!(
725                "expected scheme 'http' or 'https', got '{}'",
726                parts.scheme
727            )));
728        }
729
730        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
731        // Strip leading "//"
732        let authority_and_path = parts.path.trim_start_matches('/');
733
734        // Split on the first "/" to separate "host:port" from "/path"
735        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
736            (&authority_and_path[..idx], &authority_and_path[idx..])
737        } else {
738            (authority_and_path, "/")
739        };
740
741        let path = if path_suffix.is_empty() {
742            "/"
743        } else {
744            path_suffix
745        }
746        .to_string();
747
748        // Parse host:port from authority
749        let (host, port) = if let Some(colon) = authority.rfind(':') {
750            let port_str = &authority[colon + 1..];
751            match port_str.parse::<u16>() {
752                Ok(p) => (authority[..colon].to_string(), p),
753                Err(_) => {
754                    return Err(CamelError::InvalidUri(format!(
755                        "invalid port '{}' in authority",
756                        port_str
757                    )));
758                }
759            }
760        } else {
761            // Default port based on scheme: 443 for https, 80 for http
762            let default_port = if parts.scheme == "https" { 443 } else { 80 };
763            (authority.to_string(), default_port)
764        };
765
766        let max_request_body = parts
767            .params
768            .get("maxRequestBody")
769            .and_then(|v| v.parse::<usize>().ok())
770            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
771
772        let max_response_body = parts
773            .params
774            .get("maxResponseBody")
775            .and_then(|v| v.parse::<usize>().ok())
776            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
777
778        let max_inflight_requests = parts
779            .params
780            .get("maxInflightRequests")
781            .and_then(|v| v.parse::<usize>().ok())
782            .unwrap_or(1024);
783        // bd rc-ns3yc: reject values above the semaphore primitive's bound
784        // at parse time — `Semaphore::new` panics above MAX_PERMITS
785        // (mirror of grpc consumer_concurrency_limit, commit 101327e5).
786        let max_inflight_requests = max_inflight_requests_limit(max_inflight_requests)?;
787
788        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
789        // uppercase method the dispatcher compares against (axum's
790        // `req.method().to_string()` yields "GET"). Without this, a
791        // lower-case `httpMethod` would never match and silently 404.
792        // Review I5.
793        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
794
795        Ok(Self {
796            scheme: parts.scheme,
797            host,
798            port,
799            path,
800            max_request_body,
801            max_response_body,
802            max_inflight_requests,
803            method,
804            tls_config: {
805                let cert = parts.params.get("tlsCert").cloned();
806                let key = parts.params.get("tlsKey").cloned();
807                match (cert, key) {
808                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
809                        cert_path: c,
810                        key_path: k,
811                    }),
812                    (None, None) => None,
813                    _ => None, // partial — enforced in create_consumer, not here
814                }
815            },
816        })
817    }
818}
819
820impl HttpServerConfig {
821    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
822        let parts = parse_uri(uri)?;
823        let mut server = Self::from_components(parts.clone())?;
824        if !parts.params.contains_key("maxRequestBody") {
825            server.max_request_body = config.max_request_body;
826        }
827        if !parts.params.contains_key("maxResponseBody") {
828            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
829            server.max_response_body = config.max_body_size;
830        }
831        Ok(server)
832    }
833}
834
835// ---------------------------------------------------------------------------
836// RequestEnvelope / HttpReply
837// ---------------------------------------------------------------------------
838
839/// Body of the HTTP response: already-materialized bytes or a lazy stream.
840///
841/// **Internal plumbing** — subject to change without notice.
842pub enum HttpReplyBody {
843    Bytes(bytes::Bytes),
844    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
845}
846
847/// An inbound HTTP request sent from the Axum dispatch handler to an
848/// `HttpConsumer` receive loop.
849///
850/// **Internal plumbing** — subject to change without notice.
851pub struct RequestEnvelope {
852    pub method: String,
853    pub path: String,
854    pub query: String,
855    pub headers: http::HeaderMap,
856    pub body: StreamBody,
857    /// Path parameters extracted from a REST template match, e.g.
858    /// `id=42` for a request to `/users/42` matched against
859    /// `/users/{id}`. Empty for non-REST requests or for literal
860    /// template matches. The consumer turns these into
861    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
862    pub path_params: std::collections::HashMap<String, String>,
863    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
864}
865
866/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
867///
868/// **Internal plumbing** — subject to change without notice.
869pub struct HttpReply {
870    pub status: u16,
871    pub headers: Vec<(String, String)>,
872    pub body: HttpReplyBody,
873}
874
875// ---------------------------------------------------------------------------
876// HttpRouteRegistry / ServerRegistry
877// ---------------------------------------------------------------------------
878
879type ServerKey = (String, u16);
880
881/// Handle to a running Axum server on one interface/port.
882struct ServerHandle {
883    registry: HttpRouteRegistry,
884    /// Actual local address of the served listening socket (differs from the
885    /// configured `host:port` when spawning from a staged/pre-bound listener).
886    bound_addr: std::net::SocketAddr,
887    max_request_body: usize,
888    max_response_body: usize,
889    max_inflight_requests: usize,
890    is_tls: bool,
891    tls_cert_path: Option<String>,
892    tls_key_path: Option<String>,
893    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
894    /// dead-server eviction signal in `get_or_spawn`.
895    monitor_task: tokio::task::JoinHandle<()>,
896    /// Abort handle for the Axum server task itself. The JoinHandle is
897    /// consumed by `monitor_axum_task`; this survives on the handle so
898    /// crashed-server tests (and future ops tooling) can deterministically
899    /// kill the shared transport to exercise the death path.
900    /// Test-only today — no production reader yet (rc-szmob).
901    #[allow(dead_code)]
902    server_abort: tokio::task::AbortHandle,
903    // Retained so the reload handler (Task 7) can call reload_from_config()
904    // to hot-swap certs without restarting the server.
905    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
906    tls_source: Option<ServerTlsSource>,
907}
908
909/// Internal registry state: live server entries plus pre-bound listeners
910/// staged for consumption by the next spawn on the same key.
911#[derive(Default)]
912struct RegistryState {
913    entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
914    staged: HashMap<ServerKey, tokio::net::TcpListener>,
915}
916
917/// Process-global registry mapping (host, port) → running Axum server handle.
918pub struct ServerRegistry {
919    inner: Mutex<RegistryState>,
920}
921
922impl ServerRegistry {
923    /// Returns the global singleton.
924    pub fn global() -> &'static Self {
925        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
926        INSTANCE.get_or_init(|| ServerRegistry {
927            inner: Mutex::new(RegistryState::default()),
928        })
929    }
930
931    /// Returns route registry for `port`, spawning new Axum server if
932    /// none is running on that port yet.
933    #[allow(clippy::too_many_arguments)]
934    pub async fn get_or_spawn(
935        &'static self,
936        host: &str,
937        port: u16,
938        max_request_body: usize,
939        max_response_body: usize,
940        max_inflight_requests: usize,
941        runtime: Arc<dyn RuntimeObservability>,
942        route_id: String,
943        tls_config: Option<crate::config::ServerTlsConfig>,
944    ) -> Result<HttpRouteRegistry, CamelError> {
945        self.get_or_spawn_internal(
946            host,
947            port,
948            max_request_body,
949            max_response_body,
950            max_inflight_requests,
951            runtime,
952            route_id,
953            tls_config,
954            None,
955        )
956        .await
957    }
958
959    /// Like [`ServerRegistry::get_or_spawn`], but serves `listener` instead
960    /// of binding `host:port`. The registry key is derived from the listener's
961    /// actual local address, so callers must query that port afterwards. If an
962    /// entry for the key already holds a live server, the same compatibility
963    /// checks as `get_or_spawn` apply and the entry is reused; the passed
964    /// listener is simply dropped.
965    #[allow(clippy::too_many_arguments)]
966    pub async fn get_or_spawn_with_listener(
967        &'static self,
968        listener: tokio::net::TcpListener,
969        max_request_body: usize,
970        max_response_body: usize,
971        max_inflight_requests: usize,
972        runtime: Arc<dyn RuntimeObservability>,
973        route_id: String,
974        tls_config: Option<crate::config::ServerTlsConfig>,
975    ) -> Result<HttpRouteRegistry, CamelError> {
976        let addr = listener
977            .local_addr()
978            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
979        self.get_or_spawn_internal(
980            &addr.ip().to_string(),
981            addr.port(),
982            max_request_body,
983            max_response_body,
984            max_inflight_requests,
985            runtime,
986            route_id,
987            tls_config,
988            Some(listener),
989        )
990        .await
991    }
992
993    /// Stage a pre-bound listener so the next `get_or_spawn` for its
994    /// `(ip, port)` key serves this socket instead of binding a new one.
995    ///
996    /// The staged listener is consumed by exactly one spawn: the exact-key
997    /// `get_or_spawn` takes it under the registry lock, eliminating the bind
998    /// window between a port probe and server startup (itest-bound-ports).
999    pub async fn stage_listener(
1000        &'static self,
1001        listener: tokio::net::TcpListener,
1002    ) -> Result<(), CamelError> {
1003        let addr = listener
1004            .local_addr()
1005            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1006        let host = addr.ip().to_string();
1007        use std::collections::hash_map::Entry;
1008        let mut guard = self.inner.lock().map_err(|_| {
1009            CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1010        })?;
1011        match guard.staged.entry((host.clone(), addr.port())) {
1012            Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
1013                "listener already staged for {host}:{}",
1014                addr.port()
1015            ))),
1016            Entry::Vacant(slot) => {
1017                slot.insert(listener);
1018                Ok(())
1019            }
1020        }
1021    }
1022
1023    /// Returns the bound address of the live server entry for `(host, port)`,
1024    /// if one is initialized.
1025    pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
1026        let guard = self.inner.lock().ok()?;
1027        guard
1028            .entries
1029            .get(&(host.to_string(), port))
1030            .and_then(|cell| cell.get())
1031            .map(|handle| handle.bound_addr)
1032    }
1033
1034    #[allow(clippy::too_many_arguments)]
1035    async fn get_or_spawn_internal(
1036        &'static self,
1037        host: &str,
1038        port: u16,
1039        max_request_body: usize,
1040        max_response_body: usize,
1041        max_inflight_requests: usize,
1042        runtime: Arc<dyn RuntimeObservability>,
1043        route_id: String,
1044        tls_config: Option<crate::config::ServerTlsConfig>,
1045        provided: Option<tokio::net::TcpListener>,
1046    ) -> Result<HttpRouteRegistry, CamelError> {
1047        let host_owned = host.to_string();
1048        let key = (host.to_string(), port);
1049
1050        let cell = {
1051            let mut guard = self.inner.lock().map_err(|_| {
1052                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1053            })?;
1054            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
1055            // The monitor task awaits the server task, so monitor_task.is_finished()
1056            // is a reliable proxy for the server being gone (either crashed or aborted).
1057            if let Some(existing) = guard.entries.get(&key)
1058                && let Some(handle) = existing.get()
1059                && handle.monitor_task.is_finished()
1060            {
1061                // Deregister TLS reload handler so a respawned HTTPS server
1062                // doesn't reload stale cert config from the crashed handler.
1063                if handle.is_tls {
1064                    let scheme = if handle.is_tls { "https" } else { "http" };
1065                    camel_component_api::tls_source::TlsReloadRegistry::global()
1066                        .unregister(scheme, host, port);
1067                }
1068                guard.entries.remove(&key);
1069            }
1070            guard
1071                .entries
1072                .entry(key)
1073                .or_insert_with(|| Arc::new(OnceCell::new()))
1074                .clone()
1075        };
1076
1077        if let Some(existing) = cell.get()
1078            && existing.max_request_body != max_request_body
1079        {
1080            return Err(CamelError::EndpointCreationFailed(format!(
1081                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1082                existing.max_request_body, max_request_body
1083            )));
1084        }
1085
1086        if let Some(existing) = cell.get()
1087            && existing.max_response_body != max_response_body
1088        {
1089            return Err(CamelError::EndpointCreationFailed(format!(
1090                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1091                existing.max_response_body, max_response_body
1092            )));
1093        }
1094
1095        if let Some(existing) = cell.get()
1096            && existing.max_inflight_requests != max_inflight_requests
1097        {
1098            return Err(CamelError::EndpointCreationFailed(format!(
1099                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1100                existing.max_inflight_requests, max_inflight_requests
1101            )));
1102        }
1103
1104        // TLS mode mismatch: plain vs TLS
1105        if let Some(existing) = cell.get()
1106            && existing.is_tls != tls_config.is_some()
1107        {
1108            return Err(CamelError::EndpointCreationFailed(format!(
1109                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1110                existing.is_tls,
1111                tls_config.is_some()
1112            )));
1113        }
1114
1115        // TLS cert/key mismatch: different cert on same TLS port
1116        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1117            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1118                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1119        {
1120            return Err(CamelError::EndpointCreationFailed(format!(
1121                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1122            )));
1123        }
1124
1125        let handle = cell
1126            .get_or_try_init(|| {
1127                let rt = Arc::clone(&runtime);
1128                let rid = route_id.clone();
1129                let key = (host_owned.clone(), port);
1130                async move {
1131                    // Resolve the listener source inside the init body so
1132                    // exactly one caller — the init winner — consumes a
1133                    // staged listener. Resolving it before the cell init let
1134                    // a racing caller strand the staged socket in the
1135                    // loser's hands: the winner then bound the same port and
1136                    // failed with EADDRINUSE. The sync registry lock here is
1137                    // never held across an await. Occupied cells never run
1138                    // this body, so they never touch the staged map.
1139                    let source = match provided {
1140                        Some(listener) => ListenerSource::Staged(listener),
1141                        None => {
1142                            let mut guard = self.inner.lock().map_err(|_| {
1143                                CamelError::EndpointCreationFailed(
1144                                    "ServerRegistry lock poisoned".into(),
1145                                )
1146                            })?;
1147                            match guard.staged.remove(&key) {
1148                                Some(listener) => ListenerSource::Staged(listener),
1149                                // Conflict check before any entry is
1150                                // initialized so the error leaves the staged
1151                                // slot untouched.
1152                                None => {
1153                                    if let Some((staged_host, _)) = guard
1154                                        .staged
1155                                        .keys()
1156                                        .find(|(_, staged_port)| *staged_port == port)
1157                                    {
1158                                        let staged_host = staged_host.clone();
1159                                        return Err(CamelError::EndpointCreationFailed(
1160                                            format!(
1161                                                "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1162                                            ),
1163                                        ));
1164                                    }
1165                                    ListenerSource::Bind
1166                                }
1167                            }
1168                        }
1169                    };
1170                    spawn_entry(
1171                        key,
1172                        source,
1173                        max_request_body,
1174                        max_response_body,
1175                        max_inflight_requests,
1176                        rt,
1177                        rid,
1178                        tls_config,
1179                    )
1180                    .await
1181                    .and_then(|handle| {
1182                        // spawn_entry returns a freshly created Arc (refcount
1183                        // 1), so unwrapping it back into the owned handle for
1184                        // the cell always succeeds here.
1185                        Arc::try_unwrap(handle).map_err(|_| {
1186                            CamelError::EndpointCreationFailed(
1187                                "spawned server handle has dangling clones".into(),
1188                            )
1189                        })
1190                    })
1191                }
1192            })
1193            .await?;
1194
1195        Ok(handle.registry.clone())
1196    }
1197
1198    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
1199    /// the server stays in the registry for potential restart. Path
1200    /// deregistration happens separately in the consumer's cleanup.
1201    pub async fn unregister(&self, host: &str, port: u16) {
1202        debug!(
1203            host = camel_api::redact::redact_host(host),
1204            port = port,
1205            "consumer unregistered from HTTP server"
1206        );
1207    }
1208
1209    /// Reset the global registry — **test-only**.
1210    ///
1211    /// Clears all registered server handles so that tests can start from a clean
1212    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1213    /// process-global singleton in production and resetting it would break
1214    /// running servers.
1215    #[cfg(test)]
1216    pub fn reset() {
1217        let instance = Self::global();
1218        let mut guard = instance
1219            .inner
1220            .lock()
1221            .expect("ServerRegistry lock poisoned during test reset");
1222        guard.entries.clear();
1223        guard.staged.clear();
1224    }
1225}
1226
1227/// Where a spawned server's listening socket comes from: a fresh bind on
1228/// `key`, or a listener pre-bound (staged or passed) by the caller.
1229enum ListenerSource {
1230    Bind,
1231    Staged(tokio::net::TcpListener),
1232}
1233
1234/// Create the server handle for a vacant registry entry: serve `key` via a
1235/// freshly bound or caller-provided listener. This is the OnceCell init body
1236/// of `get_or_spawn`, extracted so the legacy and staged entry points share
1237/// one spawn path.
1238#[allow(clippy::too_many_arguments)]
1239async fn spawn_entry(
1240    key: ServerKey,
1241    source: ListenerSource,
1242    max_request_body: usize,
1243    max_response_body: usize,
1244    max_inflight_requests: usize,
1245    runtime: Arc<dyn RuntimeObservability>,
1246    route_id: String,
1247    tls_config: Option<crate::config::ServerTlsConfig>,
1248) -> Result<Arc<ServerHandle>, CamelError> {
1249    // bd rc-ns3yc: defense-in-depth — validate before any listener side
1250    // effect (fresh bind or staged-listener consumption), before the
1251    // registry/CancellationToken work, and immediately guarding the
1252    // `tokio::sync::Semaphore::new` construction below (panics above
1253    // MAX_PERMITS).
1254    max_inflight_requests_limit(max_inflight_requests)?;
1255    let rt = Arc::clone(&runtime);
1256    let rid = route_id.clone();
1257    let (host_owned, port) = key;
1258    let listener = match source {
1259        ListenerSource::Bind => {
1260            let addr = format!("{host_owned}:{port}");
1261            tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1262                CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1263            })?
1264        }
1265        ListenerSource::Staged(listener) => listener,
1266    };
1267    let bound_addr = listener
1268        .local_addr()
1269        .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1270    let server_exited = tokio_util::sync::CancellationToken::new();
1271    let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1272    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1273    // Constructed once in the TLS branch so they can be retained
1274    // on ServerHandle for the reload handler (Task 7).
1275    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1276    let tls_source: Option<ServerTlsSource>;
1277    let server_task = if let Some(ref tls) = tls_config {
1278        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1279        let source = ServerTlsSource {
1280            cert_path: std::path::PathBuf::from(&tls.cert_path),
1281            key_path: std::path::PathBuf::from(&tls.key_path),
1282            client_ca_path: None,
1283        };
1284        // Build the RustlsConfig once — clone() is cheap (Arc
1285        // internally) and shares the ArcSwap the reload handler
1286        // will mutate via reload_from_config().
1287        let rustls_cfg =
1288            axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1289        tls_rustls_cfg = Some(rustls_cfg.clone());
1290        tls_source = Some(source);
1291        // Convert tokio listener to std for axum-server
1292        let std_listener = listener.into_std().map_err(|e| {
1293            CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1294        })?;
1295        tokio::spawn(run_axum_server_tls(
1296            std_listener,
1297            rustls_cfg,
1298            registry.clone(),
1299            max_request_body,
1300            max_response_body,
1301            Arc::clone(&inflight),
1302            Arc::clone(&rt),
1303            rid.clone(),
1304        ))
1305    } else {
1306        tls_rustls_cfg = None;
1307        tls_source = None;
1308        tokio::spawn(run_axum_server(
1309            listener,
1310            registry.clone(),
1311            max_request_body,
1312            max_response_body,
1313            Arc::clone(&inflight),
1314            Arc::clone(&rt),
1315            rid.clone(),
1316        ))
1317    };
1318    let addr_for_monitor = format!("{host_owned}:{port}");
1319    let server_abort = server_task.abort_handle();
1320    let monitor_task = tokio::spawn(monitor_axum_task(
1321        server_task,
1322        addr_for_monitor,
1323        Arc::clone(&rt),
1324        rid,
1325        server_exited,
1326    ));
1327    let handle = ServerHandle {
1328        registry,
1329        bound_addr,
1330        max_request_body,
1331        max_response_body,
1332        max_inflight_requests,
1333        is_tls: tls_config.is_some(),
1334        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1335        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1336        monitor_task,
1337        server_abort,
1338        tls_config: tls_rustls_cfg,
1339        tls_source,
1340    };
1341    // Register reload handler (exactly-once: inside OnceCell init closure).
1342    // Note: HTTP servers are process-lifetime (no release/eviction path),
1343    // so handlers are never unregistered. If eviction is added later,
1344    // add TlsReloadRegistry::global().unregister() there.
1345    if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1346    {
1347        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1348            tls_cfg.clone(),
1349            source.clone(),
1350            host_owned.clone(),
1351            port,
1352        ));
1353        camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1354    }
1355    Ok(Arc::new(handle))
1356}
1357
1358// ---------------------------------------------------------------------------
1359// Axum server
1360// ---------------------------------------------------------------------------
1361
1362use axum::{
1363    Router,
1364    body::Body as AxumBody,
1365    extract::{Request, State},
1366    http::{Response, StatusCode},
1367    response::IntoResponse,
1368};
1369
1370#[derive(Clone)]
1371pub(crate) struct AppState {
1372    registry: HttpRouteRegistry,
1373    max_request_body: usize,
1374    max_response_body: usize,
1375    inflight: Arc<tokio::sync::Semaphore>,
1376}
1377
1378/// Hard wall-clock limit for one inbound request on the consumer side
1379/// (audit 2026-08-31, F2-1). A slow-drip client otherwise holds an
1380/// `inflight` semaphore permit (and its connection) indefinitely, starving
1381/// the consumer into 503s. 30s matches the documented component default
1382/// timeouts. Applies to the whole dispatch; streaming bodies are additionally
1383/// protected by the byte cap in `dispatch_handler`.
1384const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1385
1386async fn run_axum_server(
1387    listener: tokio::net::TcpListener,
1388    registry: HttpRouteRegistry,
1389    max_request_body: usize,
1390    max_response_body: usize,
1391    inflight: Arc<tokio::sync::Semaphore>,
1392    runtime: Arc<dyn RuntimeObservability>,
1393    route_id: String,
1394) {
1395    let state = AppState {
1396        registry,
1397        max_request_body,
1398        max_response_body,
1399        inflight,
1400    };
1401    let app = Router::new()
1402        .fallback(dispatch_handler)
1403        .with_state(state)
1404        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1405            StatusCode::REQUEST_TIMEOUT,
1406            CONSUMER_REQUEST_TIMEOUT,
1407        ));
1408
1409    axum::serve(listener, app).await.unwrap_or_else(|e| {
1410        runtime
1411            .metrics()
1412            .increment_errors(&route_id, "e:http:accept");
1413        // log-policy: outside-contract
1414        tracing::error!(error = %e, "Axum server error");
1415    });
1416}
1417
1418#[allow(clippy::too_many_arguments)]
1419async fn run_axum_server_tls(
1420    listener: std::net::TcpListener,
1421    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1422    registry: HttpRouteRegistry,
1423    max_request_body: usize,
1424    max_response_body: usize,
1425    inflight: Arc<tokio::sync::Semaphore>,
1426    runtime: Arc<dyn RuntimeObservability>,
1427    route_id: String,
1428) {
1429    let state = AppState {
1430        registry,
1431        max_request_body,
1432        max_response_body,
1433        inflight,
1434    };
1435    let app = Router::new()
1436        .fallback(dispatch_handler)
1437        .with_state(state)
1438        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1439            StatusCode::REQUEST_TIMEOUT,
1440            CONSUMER_REQUEST_TIMEOUT,
1441        ));
1442
1443    // RustlsConfig is now constructed once in get_or_spawn and retained on
1444    // ServerHandle so the reload handler can call reload_from_config() on it.
1445
1446    // axum-server 0.8: from_tcp_rustls is fallible (TLS acceptor setup).
1447    let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1448        Ok(server) => server,
1449        Err(e) => {
1450            runtime
1451                .metrics()
1452                .increment_errors(&route_id, "e:http:accept-tls");
1453            // log-policy: outside-contract
1454            tracing::error!(error = %e, "Axum TLS server setup error");
1455            return;
1456        }
1457    };
1458
1459    server
1460        .serve(app.into_make_service())
1461        .await
1462        .unwrap_or_else(|e| {
1463            runtime
1464                .metrics()
1465                .increment_errors(&route_id, "e:http:accept-tls");
1466            // log-policy: outside-contract
1467            tracing::error!(error = %e, "Axum TLS server error");
1468        });
1469}
1470
1471/// Monitors the shared Axum server task of one (host, port).
1472///
1473/// On unexpected exit (panic or abort) it records the structured error
1474/// event and cancels the server's `server_exited` token. Every
1475/// `HttpConsumer` hosted on that server observes the cancellation in its
1476/// `start()` loop and returns `Err`, which camel-core's consumer watcher
1477/// turns into a per-route `CrashNotification` → `FailRoute` → supervision
1478/// backoff restart (ADR-0007). A clean exit (`Ok(())` — process shutdown)
1479/// cancels nothing: route stops own their termination.
1480async fn monitor_axum_task(
1481    handle: tokio::task::JoinHandle<()>,
1482    addr: String,
1483    runtime: Arc<dyn RuntimeObservability>,
1484    route_id: String,
1485    server_exited: tokio_util::sync::CancellationToken,
1486) {
1487    match handle.await {
1488        Ok(()) => {
1489            // Clean exit (process shutdown or normal stop)
1490        }
1491        Err(join_err) => {
1492            runtime
1493                .metrics()
1494                .increment_errors(&route_id, "e:http:server-task-exited");
1495            // log-policy: outside-contract
1496            tracing::error!(
1497                addr = %addr,
1498                error = %join_err,
1499                "Axum server task exited unexpectedly — all routes on this port are now dead"
1500            );
1501            // Fail every hosted route's consumer: each `start()` returns Err
1502            // and camel-core emits one CrashNotification per route (ADR-0007
1503            // parity with per-route transport death).
1504            server_exited.cancel();
1505        }
1506    }
1507}
1508
1509/// Load a rustls ServerConfig from PEM cert/key files.
1510/// Adapted from camel-ws lib.rs load_tls_config.
1511fn load_tls_config(
1512    cert_path: &str,
1513    key_path: &str,
1514) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1515    use std::fs::File;
1516    use std::io::BufReader;
1517
1518    let cert_file = File::open(cert_path)
1519        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1520    let key_file = File::open(key_path)
1521        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1522
1523    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1524        .collect::<Result<Vec<_>, _>>()
1525        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1526
1527    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1528        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1529        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1530
1531    tokio_rustls::rustls::ServerConfig::builder()
1532        .with_no_client_auth()
1533        .with_single_cert(certs, key)
1534        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1535}
1536
1537async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1538    let path = req.uri().path().to_owned();
1539    let method = req.method().to_string();
1540
1541    // Dispatch precedence (spec §7.2 / ADR-0009):
1542    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1543    //   2. Templated API path match (REST, method-aware, by specificity)
1544    //   3. Static mount longest-prefix
1545    //   4. SPA fallback
1546    //
1547    // Legacy exact runs first: it is a cheap HashMap get, and the two
1548    // registries are mutually exclusive per route — a legacy route carries
1549    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1550    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1551    // exact hit can never shadow a REST route that should have matched,
1552    // and running exact-first honours the documented precedence (the prior
1553    // REST-first order let a templated `GET /api/{resource}` steal a
1554    // request meant for an exact `GET /api/users`). Intra-REST method
1555    // disambiguation is handled inside `match_endpoint`, not by this
1556    // ordering. Review C2.
1557    let api_sender = {
1558        let inner = state.registry.inner.read().await;
1559        inner.api_routes.get(&path).cloned()
1560    }; // lock released BEFORE any IO
1561
1562    let (rest_sender, path_params) = if api_sender.is_some() {
1563        // Exact legacy match won — skip the templated scan entirely.
1564        (None, Default::default())
1565    } else {
1566        let inner = state.registry.inner.read().await;
1567        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1568            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1569            rest_match::MatchOutcome::Ambiguous => {
1570                // Ambiguous registration should have been rejected at
1571                // lowering time (rest.rs). Reaching here means two
1572                // equal-specificity templates matched one request —
1573                // surface a loud error rather than a silent 404. Review C3.
1574                // log-policy: handler-owned
1575                tracing::warn!(
1576                    method = %method,
1577                    path = %path,
1578                    "ambiguous REST template match — returning 500"
1579                );
1580                return Response::builder()
1581                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1582                    .body(AxumBody::from("Internal Server Error"))
1583                    .expect("infallible"); // allow-unwrap
1584            }
1585            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1586        }
1587    }; // lock released BEFORE any IO
1588
1589    let sender = api_sender.or(rest_sender);
1590
1591    if let Some(sender) = sender {
1592        let query = req.uri().query().unwrap_or("").to_string();
1593        let headers = req.headers().clone();
1594
1595        // Check Content-Length against limit BEFORE opening the stream
1596        let content_length: Option<u64> = headers
1597            .get(http::header::CONTENT_LENGTH)
1598            .and_then(|v| v.to_str().ok())
1599            .and_then(|s| s.parse().ok());
1600
1601        if let Some(len) = content_length
1602            && len > state.max_request_body as u64
1603        {
1604            return Response::builder()
1605                .status(StatusCode::PAYLOAD_TOO_LARGE)
1606                .body(AxumBody::from("Request body exceeds configured limit"))
1607                .expect("infallible"); // allow-unwrap
1608        }
1609
1610        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1611            Ok(permit) => permit,
1612            Err(_) => {
1613                return Response::builder()
1614                    .status(StatusCode::SERVICE_UNAVAILABLE)
1615                    .body(AxumBody::from("Service Unavailable"))
1616                    .expect("infallible"); // allow-unwrap
1617            }
1618        };
1619
1620        // Build StreamBody from Axum body WITHOUT materializing.
1621        // SECURITY (audit 2026-08-31, F2-1): the Content-Length pre-check above
1622        // cannot see chunked/no-length requests. Wrap the stream with a hard
1623        // byte cap so ANY downstream consumption fails closed once
1624        // max_request_body is exceeded — the cap travels with the body.
1625        let content_type = headers
1626            .get(http::header::CONTENT_TYPE)
1627            .and_then(|v| v.to_str().ok())
1628            .map(|s| s.to_string());
1629
1630        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1631        let max_body = state.max_request_body;
1632        let mut seen: u64 = 0;
1633        let capped_stream =
1634            data_stream
1635                .map_err(|e| CamelError::Io(e.to_string()))
1636                .map(move |chunk| match chunk {
1637                    Ok(bytes) => {
1638                        seen = seen.saturating_add(bytes.len() as u64);
1639                        if seen > max_body as u64 {
1640                            Err(CamelError::ProcessorError(format!(
1641                                "Request body exceeds configured limit of {max_body} bytes"
1642                            )))
1643                        } else {
1644                            Ok(bytes)
1645                        }
1646                    }
1647                    Err(e) => Err(e),
1648                });
1649        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1650
1651        let stream_body = StreamBody {
1652            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1653            metadata: StreamMetadata {
1654                size_hint: content_length,
1655                content_type,
1656                origin: None,
1657            },
1658        };
1659
1660        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1661        let envelope = RequestEnvelope {
1662            method,
1663            path,
1664            query,
1665            headers,
1666            body: stream_body,
1667            path_params,
1668            reply_tx,
1669        };
1670
1671        if sender.send(envelope).await.is_err() {
1672            return Response::builder()
1673                .status(StatusCode::SERVICE_UNAVAILABLE)
1674                .body(AxumBody::from("Consumer unavailable"))
1675                .expect("infallible"); // allow-unwrap
1676        }
1677
1678        match reply_rx.await {
1679            Ok(reply) => {
1680                let reply = match reply.body {
1681                    HttpReplyBody::Bytes(b)
1682                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1683                    {
1684                        HttpReply {
1685                            status: 500,
1686                            headers: vec![],
1687                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1688                                "Response body exceeds configured limit",
1689                            )),
1690                        }
1691                    }
1692                    _ => reply,
1693                };
1694
1695                let status =
1696                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1697                let mut builder = Response::builder().status(status);
1698                for (k, v) in &reply.headers {
1699                    builder = builder.header(k.as_str(), v.as_str());
1700                }
1701                match reply.body {
1702                    HttpReplyBody::Bytes(b) => {
1703                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1704                            Response::builder()
1705                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1706                                .body(AxumBody::from("Invalid response headers from consumer"))
1707                                .expect("infallible") // allow-unwrap
1708                        })
1709                    }
1710                    HttpReplyBody::Stream(stream) => builder
1711                        .body(AxumBody::from_stream(stream))
1712                        .unwrap_or_else(|_| {
1713                            Response::builder()
1714                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1715                                .body(AxumBody::from("Invalid response headers from consumer"))
1716                                .expect("infallible") // allow-unwrap
1717                        }),
1718                }
1719            }
1720            Err(_) => Response::builder()
1721                .status(StatusCode::INTERNAL_SERVER_ERROR)
1722                .body(AxumBody::from("Pipeline error"))
1723                .expect("infallible"), // allow-unwrap
1724        }
1725    } else {
1726        // No API route matched — try static mounts
1727        static_dispatch::dispatch_static(&state, req, &path).await
1728    }
1729}
1730
1731fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1732    len > max
1733}
1734
1735fn title_case_header(name: &str) -> String {
1736    name.split('-')
1737        .map(|part| {
1738            let mut chars = part.chars();
1739            match chars.next() {
1740                None => String::new(),
1741                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1742            }
1743        })
1744        .collect::<Vec<_>>()
1745        .join("-")
1746}
1747
1748// ---------------------------------------------------------------------------
1749// HttpConsumer
1750// ---------------------------------------------------------------------------
1751
1752/// Kernel authentication state captured from a route's [`SecurityContext`]
1753/// (`unify-transport-auth`, Task 2.9).
1754///
1755/// Same construction-order lifecycle as gRPC's `GrpcKernelAuth` (Task 2.1):
1756/// the compiled plan and the provider registry arrive via
1757/// `Consumer::set_security_context` before `start()` accepts requests. A
1758/// context lacking either piece keeps `kernel = None` — a plan without
1759/// providers can never mint a principal (fail-closed, never a silently
1760/// unauthenticated route: the controller's strict-mode dispatch check then
1761/// denies carrier-less Exchanges on non-Public plans).
1762pub(crate) struct HttpKernelAuth {
1763    pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1764    pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1765}
1766
1767impl HttpKernelAuth {
1768    /// Capture the kernel state from a route's security context.
1769    ///
1770    /// `None` unless both the compiled plan and the provider registry are
1771    /// present.
1772    pub(crate) fn from_security_context(
1773        ctx: &camel_component_api::SecurityContext,
1774    ) -> Option<Self> {
1775        Some(Self {
1776            plan: ctx.plan.clone()?,
1777            providers: ctx.providers.clone()?,
1778        })
1779    }
1780}
1781
1782/// Capacity for the per-route RequestEnvelope channel.
1783///
1784/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1785/// permit from before `send()` until its reply, so at most N envelopes can be
1786/// outstanding at any time. A buffer of N therefore can never fill before the
1787/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1788/// and the semaphore stays the single, URI-configurable backpressure point.
1789/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1790/// (rc-3y6j: 64 vs default 1024 permits).
1791///
1792/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1793/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1794/// start panic-free (the empty semaphore still 503s every request).
1795fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1796    max_inflight_requests.max(1)
1797}
1798
1799/// Upper bound check for `maxInflightRequests`.
1800///
1801/// bd rc-ns3yc: `tokio::sync::Semaphore::new` panics for permit counts above
1802/// [`tokio::sync::Semaphore::MAX_PERMITS`], so any representable `usize`
1803/// above that bound must be rejected fail-closed with a typed configuration
1804/// error naming the parameter, the configured value, and the limit — before
1805/// the semaphore primitive is ever constructed. Enforced at URI parse,
1806/// `create_consumer`, consumer start, and `spawn_entry` (defense-in-depth);
1807/// mirror of the grpc `consumer_concurrency_limit` fix (commit 101327e5).
1808///
1809/// `0` is deliberately NOT normalized here (rc-3y6j): it remains a
1810/// representable reject-everything value (`envelope_channel_capacity`
1811/// separately guards the one primitive — the envelope channel — that
1812/// cannot take 0).
1813pub(crate) fn max_inflight_requests_limit(configured: usize) -> Result<usize, CamelError> {
1814    if configured > tokio::sync::Semaphore::MAX_PERMITS {
1815        return Err(CamelError::Config(format!(
1816            "maxInflightRequests {configured} exceeds the supported upper bound {} (tokio::sync::Semaphore::MAX_PERMITS)",
1817            tokio::sync::Semaphore::MAX_PERMITS
1818        )));
1819    }
1820    Ok(configured)
1821}
1822
1823pub struct HttpConsumer {
1824    config: HttpServerConfig,
1825    /// Runtime observability handle for ADR-0012 metrics and health calls.
1826    runtime: Arc<dyn RuntimeObservability>,
1827    /// Kernel authentication state (plan + providers), set via
1828    /// `set_security_context` before `start()` (Task 2.9). `None` for routes
1829    /// without route-level security (Public under the per-bind gate).
1830    kernel: Option<Arc<HttpKernelAuth>>,
1831}
1832
1833impl HttpConsumer {
1834    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1835        Self {
1836            config,
1837            runtime,
1838            kernel: None,
1839        }
1840    }
1841}
1842
1843#[async_trait::async_trait]
1844impl Consumer for HttpConsumer {
1845    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1846        use camel_component_api::{Body, Exchange, Message};
1847
1848        // bd rc-ns3yc: fail-closed BEFORE shared-server registry
1849        // interaction, listener binding, envelope-channel construction, or
1850        // inflight-semaphore construction — an oversized value must never
1851        // reach Semaphore::new (panic).
1852        max_inflight_requests_limit(self.config.max_inflight_requests)?;
1853
1854        let registry = ServerRegistry::global()
1855            .get_or_spawn(
1856                &self.config.host,
1857                self.config.port,
1858                self.config.max_request_body,
1859                self.config.max_response_body,
1860                self.config.max_inflight_requests,
1861                self.runtime.clone(),
1862                ctx.route_id().to_string(),
1863                self.config.tls_config.clone(),
1864            )
1865            .await?;
1866
1867        // Create channel for this path and register it. Capacity matches the
1868        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1869        // the channel can never become a second backpressure point.
1870        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1871            envelope_channel_capacity(self.config.max_inflight_requests),
1872        );
1873        // When the from-URI carries `httpMethod=...` (REST-lowered
1874        // route), register the consumer as a method-aware REST endpoint
1875        // so the dispatcher can route by (method, path template).
1876        // Otherwise fall back to the legacy path-only api_routes
1877        // registry. The two registries never overlap for the same
1878        // route: each consumer registers in exactly one of them.
1879        if let Some(method) = self.config.method.clone() {
1880            let segments = rest_match::parse_path_template(&self.config.path);
1881            registry
1882                .register_rest_endpoint(method, segments, env_tx)
1883                .await;
1884        } else {
1885            registry
1886                .register_api_route(self.config.path.clone(), env_tx)
1887                .await;
1888        }
1889
1890        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1891        // (inside get_or_spawn above), (2) the axum server task was spawned,
1892        // and (3) this route's path/REST endpoint was registered. At this
1893        // point the listener is genuinely accepting connections and any
1894        // request to this route will be dispatched (not 404'd). The runtime
1895        // uses this signal to publish RouteStarted and to release
1896        // ctx.start() so external benchmarks can emit a reliable
1897        // listener-bound marker.
1898        ctx.mark_ready();
1899
1900        // rc-nftni (drainclaim): capture the context-global counter once;
1901        // every envelope this raw-sender consumer constructs carries a
1902        // claim minted at the acceptance dequeue below.
1903        let in_flight = ctx.in_flight_counter();
1904
1905        let path = self.config.path.clone();
1906        let registry_for_cleanup = registry.clone();
1907        let server_exited = registry.server_exited.clone();
1908        let cancel_token = ctx.cancel_token();
1909        let kernel = self.kernel.clone();
1910        // Set when the loop exits because the shared server died. The
1911        // post-loop cleanup still runs, then `start()` returns Err so
1912        // camel-core's consumer watcher emits a CrashNotification for THIS
1913        // route and supervision backoff engages (ADR-0007).
1914        let mut server_died = false;
1915        loop {
1916            tokio::select! {
1917                _ = ctx.cancelled() => {
1918                    break;
1919                }
1920                _ = server_exited.cancelled() => {
1921                    // Shared transport death: this route's consumer cannot
1922                    // continue. Fail (do NOT hang in Running) — parity with
1923                    // per-route transport death, which also surfaces as a
1924                    // consumer-task error.
1925                    server_died = true;
1926                    break;
1927                }
1928                 envelope = env_rx.recv() => {
1929                    let Some(envelope) = envelope else { break; };
1930
1931                    // rc-nftni: mint at acceptance — the dequeue of the
1932                    // dispatcher's RequestEnvelope is where this consumer
1933                    // takes ownership of the wire request. The claim is held
1934                    // across the authn await, the route channel, and the
1935                    // pipeline; every early exit in the per-request task
1936                    // (cancel-503, auth denial) drops it, and a failed push
1937                    // rolls it back with the dropped envelope (RAII).
1938                    let claim =
1939                        in_flight.as_ref().map(camel_component_api::InFlightClaim::attach);
1940
1941                    // Build Exchange from HTTP request
1942                    let mut msg = Message::default();
1943
1944                    // Set standard Camel HTTP headers
1945                    msg.set_header("CamelHttpMethod",
1946                        serde_json::Value::String(envelope.method.clone()));
1947                    msg.set_header("CamelHttpPath",
1948                        serde_json::Value::String(envelope.path.clone()));
1949                    msg.set_header("CamelHttpQuery",
1950                        serde_json::Value::String(envelope.query.clone()));
1951
1952                    // Set path-parameter headers from REST template
1953                    // match. Expert guidance E2: the consumer is
1954                    // responsible for translating the dispatcher's
1955                    // matched params into `CamelHttpPath_<param>`
1956                    // headers on the Exchange, matching the convention
1957                    // used by Camel HTTP for templated routes.
1958                    for (param_name, param_value) in &envelope.path_params {
1959                        msg.set_header(
1960                            format!("CamelHttpPath_{param_name}"),
1961                            serde_json::Value::String(param_value.clone()),
1962                        );
1963                    }
1964
1965                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1966                    for (k, v) in &envelope.headers {
1967                        if let Ok(val_str) = v.to_str() {
1968                            msg.set_header(
1969                                title_case_header(k.as_str()),
1970                                serde_json::Value::String(val_str.to_string()),
1971                            );
1972                        }
1973                    }
1974
1975                    // Body: always arrives as Body::Stream (native streaming)
1976                    // Routes can call into_bytes() if they need to materialize
1977                    msg.body = Body::Stream(envelope.body);
1978
1979                    #[allow(unused_mut)]
1980                    let mut exchange = Exchange::new(msg);
1981
1982                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1983                    #[cfg(feature = "otel")]
1984                    {
1985                        let headers: HashMap<String, String> = envelope
1986                            .headers
1987                            .iter()
1988                            .filter_map(|(k, v)| {
1989                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1990                            })
1991                            .collect();
1992                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1993                    }
1994
1995                    let reply_tx = envelope.reply_tx;
1996                    let sender = ctx.sender().clone();
1997                    let path_clone = path.clone();
1998                    let cancel = cancel_token.clone();
1999                    // Task 2.9 boundary-auth inputs: the raw header map and
2000                    // the request URI (path + query) feed kernel credential
2001                    // extraction inside the per-request task.
2002                    let auth_headers = envelope.headers.clone();
2003                    let auth_uri: http::Uri = {
2004                        let full = if envelope.query.is_empty() {
2005                            envelope.path.clone()
2006                        } else {
2007                            format!("{}?{}", envelope.path, envelope.query)
2008                        };
2009                        // A malformed path cannot become a valid `Uri`; the
2010                        // empty default then carries no credentials, so
2011                        // extraction finds nothing and authn fails closed.
2012                        full.parse().unwrap_or_default()
2013                    };
2014                    let kernel = kernel.clone();
2015
2016                    // Spawn a task to handle this request concurrently
2017                    //
2018                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
2019                    // true concurrent request processing. This change was introduced as part of the
2020                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
2021                    //
2022                    // Rationale:
2023                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
2024                    //    the consumer's main loop until the pipeline processing completes
2025                    // 2. This blocking would prevent multiple HTTP requests from being processed
2026                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
2027                    // 3. The channel would never have multiple exchanges buffered simultaneously,
2028                    //    defeating the purpose of pipeline-side concurrency
2029                    // 4. By spawning a task per request, we allow the consumer loop to continue
2030                    //    accepting new requests while existing ones are processed in the pipeline
2031                    //
2032                    // This approach effectively decouples request acceptance from pipeline processing,
2033                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
2034                    // by the pipeline when ConcurrencyModel::Concurrent is active.
2035                    tokio::spawn(async move {
2036                        // Check for cancellation before sending to pipeline.
2037                        // Returns 503 (Service Unavailable) instead of letting the request
2038                        // enter a shutting-down pipeline. This is a behavioral change from
2039                        // the pre-concurrency implementation where cancellation during
2040                        // processing would result in a 500 (Internal Server Error).
2041                        // 503 is more semantically correct: the server is temporarily
2042                        // unable to handle the request due to shutdown.
2043                        if cancel.is_cancelled() {
2044                            let _ = reply_tx.send(HttpReply {
2045                                status: 503,
2046                                headers: vec![],
2047                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2048                            });
2049                            return;
2050                        }
2051
2052                        // ADR-0061 Task 2.9: kernel authentication at the
2053                        // request boundary. A `Public` plan passes through
2054                        // with no extraction; any other mode extracts per
2055                        // the plan's sources, authenticates through the
2056                        // kernel, and installs the typed carrier BEFORE the
2057                        // pipeline runs. A denial renders in the HTTP idiom
2058                        // (401 via `pipeline_error_to_reply`) and the route
2059                        // body never sees the request.
2060                        if let Some(kernel) = kernel.as_ref()
2061                            && !matches!(
2062                                kernel.plan.access_mode,
2063                                camel_api::security_policy::AccessMode::Public
2064                            )
2065                        {
2066                            let principal = match camel_auth::extract_token_multi(
2067                                &auth_headers,
2068                                &auth_uri,
2069                                &kernel.plan.credential_sources,
2070                            ) {
2071                                Some(extracted) => {
2072                                    match camel_auth::kernel_authenticate(
2073                                        &kernel.plan,
2074                                        &kernel.providers,
2075                                        &extracted,
2076                                    )
2077                                    .await
2078                                    {
2079                                        Ok(principal) => principal,
2080                                        Err(e) => {
2081                                            // log-policy: handler-owned
2082                                            tracing::warn!(
2083                                                path = %path_clone,
2084                                                error = %e,
2085                                                "HTTP request authentication failed"
2086                                            );
2087                                            let _ = reply_tx.send(pipeline_error_to_reply(
2088                                                e,
2089                                                &path_clone,
2090                                            ));
2091                                            return;
2092                                        }
2093                                    }
2094                                }
2095                                None => {
2096                                    // log-policy: handler-owned
2097                                    tracing::warn!(
2098                                        path = %path_clone,
2099                                        "HTTP request rejected: no credential found in any source"
2100                                    );
2101                                    let _ = reply_tx.send(pipeline_error_to_reply(
2102                                        CamelError::Unauthenticated(
2103                                            "no credential found in any source".to_string(),
2104                                        ),
2105                                        &path_clone,
2106                                    ));
2107                                    return;
2108                                }
2109                            };
2110                            camel_auth::install_carrier(&mut exchange, &principal);
2111                        }
2112
2113                        // Send through pipeline and await result
2114                        let (tx, rx) = tokio::sync::oneshot::channel();
2115                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
2116                            exchange,
2117                            reply_tx: Some(tx),
2118                            // rc-nftni: the acceptance-minted claim rides the
2119                            // envelope; the pipeline drain sites take it and
2120                            // hold it across the pipeline (release at
2121                            // completion; rejection paths above already
2122                            // dropped it).
2123                            in_flight_claim: claim,
2124                        };
2125
2126                        let result = match sender.send(envelope).await {
2127                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2128                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2129                        }
2130                        .and_then(|r| r);
2131
2132                        let reply = match result {
2133                            Ok(out) => {
2134                                let status = out
2135                                    .input
2136                                    .header("CamelHttpResponseCode")
2137                                    .and_then(|v| {
2138                                        let raw = v.as_u64()
2139                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2140                                        let code = raw as u16;
2141                                        (100..1000).contains(&code).then_some(code)
2142                                    })
2143                                    .unwrap_or(200);
2144
2145                                let user_content_type = out
2146                                    .input
2147                                    .header("Content-Type")
2148                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2149
2150                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2151                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2152                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2153                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2154                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2155                                        v.to_string().into_bytes(),
2156                                    )), Some("application/json".to_string())),
2157                                    Body::Stream(s) => {
2158                                        let ct = s.metadata.content_type.clone();
2159                                        match s.stream.lock().await.take() {
2160                                            Some(stream) => (
2161                                                HttpReplyBody::Stream(stream),
2162                                                ct,
2163                                            ),
2164                                            None => {
2165                                                // log-policy: system-broken
2166                                                tracing::error!(
2167                                                    "Body::Stream already consumed before HTTP reply — returning 500"
2168                                                );
2169                                                let error_reply = HttpReply {
2170                                                    status: 500,
2171                                                    headers: vec![],
2172                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2173                                                };
2174                                                if reply_tx.send(error_reply).is_err() {
2175                                                    debug!("reply_tx dropped before error reply could be sent");
2176                                                }
2177                                                return;
2178                                            }
2179                                        }
2180                                    }
2181                                    // Empty and future variants produce an empty reply body.
2182                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2183                                };
2184
2185                                let resp_headers = select_response_headers(
2186                                    &out.input.headers,
2187                                    user_content_type,
2188                                    inferred_content_type,
2189                                );
2190
2191                                HttpReply {
2192                                    status,
2193                                    headers: resp_headers,
2194                                    body: reply_body,
2195                                }
2196                            }
2197                            Err(e) => {
2198                                pipeline_error_to_reply(e, &path_clone)
2199                            }
2200                        };
2201
2202                        // Reply to Axum handler (ignore error if client disconnected)
2203                        let _ = reply_tx.send(reply);
2204                    });
2205                }
2206            }
2207        }
2208
2209        // Deregister this consumer. Mirror the registration choice:
2210        // REST-registered consumers remove their (method, path) endpoint
2211        // WITHOUT touching sibling verbs on the same template (review C1);
2212        // legacy consumers clean up api_routes.
2213        if let Some(method) = &self.config.method {
2214            registry_for_cleanup
2215                .unregister_rest_endpoint(method, &path)
2216                .await;
2217        } else {
2218            registry_for_cleanup.unregister_api_route(&path).await;
2219        }
2220
2221        // Leave the shared-server entry: `unregister` is a no-op today (no
2222        // refcount exists — stale D-L10 wording removed, rc-szmob review).
2223        // Dead servers are evicted lazily by `get_or_spawn_internal`, which
2224        // checks `monitor_task.is_finished()` and rebinds on the next spawn
2225        // (e.g. a supervision restart after this consumer's Err).
2226        ServerRegistry::global()
2227            .unregister(&self.config.host, self.config.port)
2228            .await;
2229
2230        if server_died {
2231            // log-policy: system-broken
2232            tracing::error!(
2233                host = %camel_api::redact::redact_host(&self.config.host),
2234                port = self.config.port,
2235                path = %path,
2236                "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2237            );
2238            // The error value is logged upstream by supervision (ADR-0076):
2239            // the host must ride the canonical masker, message structure
2240            // unchanged (bd rc-8bxeo item 3).
2241            return Err(CamelError::RouteError(format!(
2242                "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2243                camel_api::redact::redact_host(&self.config.host),
2244                self.config.port
2245            )));
2246        }
2247
2248        Ok(())
2249    }
2250
2251    async fn stop(&mut self) -> Result<(), CamelError> {
2252        Ok(())
2253    }
2254
2255    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2256        camel_component_api::ConcurrencyModel::Concurrent { max: None }
2257    }
2258
2259    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
2260    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
2261    // Opting into Explicit startup makes ctx.start() await the bind+register
2262    // completion so listeners fail fast on bind errors (previously a silent
2263    // background log) and external markers can reliably detect listener-bound
2264    // state.
2265    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2266        camel_component_api::ConsumerStartupMode::Explicit
2267    }
2268
2269    // Task 2.9: capture the kernel state (compiled plan + provider registry)
2270    // wired by the route controller before start(). See `HttpKernelAuth`.
2271    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2272        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2273    }
2274}
2275
2276// ---------------------------------------------------------------------------
2277// HttpComponent / HttpsComponent
2278// ---------------------------------------------------------------------------
2279
2280pub struct HttpComponent {
2281    config: HttpConfig,
2282    pinned_cache: std::sync::Arc<PinnedClientCache>,
2283    client: reqwest::Client,
2284    /// Set at construction when `tls.strict` is on and the configured
2285    /// material fails to load; surfaced as an endpoint-creation failure
2286    /// (rc-ayrwk).
2287    strict_tls_error: Option<CamelError>,
2288}
2289
2290#[cfg(test)]
2291thread_local! {
2292    static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2293    static BUILD_CLIENT_FALLBACKS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2294    /// Test seam arming [`build_client`] to skip the platform-verifier
2295    /// primary and enter [`webpki_fallback_client`] directly (see
2296    /// [`FallbackTrigger::Forced`]). Always manipulated through the
2297    /// panic-safe [`crate::tls_harness::force_webpki_fallback`] guard.
2298    static FORCE_WEBPKI_FALLBACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
2299    /// Test seam arming the shared second-error terminal in
2300    /// [`webpki_fallback_client`]: a successful preconfigured-backend
2301    /// build is then treated as a rebuild failure, exercising the
2302    /// terminal hermetically (it is unreachable through real triggers
2303    /// on reqwest 0.13.4). Always manipulated through the panic-safe
2304    /// [`crate::tls_harness::force_webpki_fallback_rebuild_failure`]
2305    /// guard.
2306    static FORCE_FALLBACK_REBUILD_FAIL: std::cell::Cell<bool> =
2307        const { std::cell::Cell::new(false) };
2308}
2309
2310/// Shared builder assembly for [`build_client`]: everything except the
2311/// terminal `build()` — proxy, timeouts, pool, redirect policy, DNS-pin
2312/// override, and the permissive (warn-on-degrade) TLS material loads
2313/// (rc-ayrwk / audit F2-7).
2314fn client_builder(
2315    config: &HttpConfig,
2316    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2317) -> reqwest::ClientBuilder {
2318    let mut builder = reqwest::Client::builder()
2319        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
2320        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2321        .pool_max_idle_per_host(config.pool_max_idle_per_host)
2322        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2323
2324    // Redirects are always handled manually in the producer's send path
2325    // so that each hop can be SSRF-validated. reqwest's built-in redirect
2326    // policy is sync and cannot perform async DNS resolution or SSRF checks.
2327    builder = builder.redirect(reqwest::redirect::Policy::none());
2328
2329    if let Some((host, addrs)) = resolve_override {
2330        builder = builder.resolve_to_addrs(host, addrs);
2331    }
2332
2333    if let Some(tls) = &config.tls
2334        && tls.enabled
2335    {
2336        if tls.insecure || !tls.verify_peer {
2337            // log-policy: handler-owned
2338            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2339            builder = builder.danger_accept_invalid_certs(true);
2340        }
2341
2342        if let Some(ca_path) = &tls.ca_cert_path {
2343            // Audit 2026-08-31, F2-7: a configured CA that fails to load must
2344            // never degrade silently to system roots. Loud warn (config error
2345            // class: fail-fast would break existing deployments relying on the
2346            // fallback; the warning is the operator signal).
2347            match std::fs::read(ca_path) {
2348                Ok(ca_bytes) => {
2349                    // Under the rustls backend `Certificate::from_pem`
2350                    // never fails (it defers parsing), so the parse-error
2351                    // warn below is effectively dead and a file with zero
2352                    // parseable PEM CERTIFICATE sections would silently
2353                    // contribute no roots. Warn on that case explicitly
2354                    // (e_glm stage-4 finding 1).
2355                    let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2356                        .filter(|r| r.is_ok())
2357                        .count();
2358                    if pem_sections == 0 {
2359                        // log-policy: handler-owned
2360                        tracing::warn!(
2361                            "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2362                        );
2363                    }
2364                    match reqwest::Certificate::from_pem(&ca_bytes)
2365                        .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2366                    {
2367                        Ok(ca_cert) => {
2368                            builder = builder.add_root_certificate(ca_cert);
2369                        }
2370                        Err(e) => {
2371                            // log-policy: handler-owned
2372                            tracing::warn!(
2373                                error = %e,
2374                                "configured CA certificate failed to parse — falling back to system roots"
2375                            );
2376                        }
2377                    }
2378                }
2379                Err(e) => {
2380                    // log-policy: handler-owned
2381                    tracing::warn!(
2382                        error = %e,
2383                        "configured CA certificate file unreadable — falling back to system roots"
2384                    );
2385                }
2386            }
2387        }
2388
2389        // mTLS identity: BOTH files must load and parse, or the identity is
2390        // absent. A partial failure previously meant silently downgrading to
2391        // non-mTLS — now loud.
2392        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2393            match (std::fs::read(cert_path), std::fs::read(key_path)) {
2394                (Ok(cert_bytes), Ok(key_bytes)) => {
2395                    let mut identity_pem = cert_bytes;
2396                    identity_pem.extend_from_slice(&key_bytes);
2397                    match reqwest::Identity::from_pem(&identity_pem) {
2398                        Ok(identity) => {
2399                            builder = builder.identity(identity);
2400                        }
2401                        Err(e) => {
2402                            // log-policy: handler-owned
2403                            tracing::warn!(
2404                                error = %e,
2405                                "configured mTLS identity failed to parse — client certificate NOT used"
2406                            );
2407                        }
2408                    }
2409                }
2410                (cert_r, key_r) => {
2411                    // log-policy: handler-owned
2412                    tracing::warn!(
2413                        cert_ok = cert_r.is_ok(),
2414                        key_ok = key_r.is_ok(),
2415                        "configured mTLS cert/key file unreadable — client certificate NOT used"
2416                    );
2417                }
2418            }
2419        }
2420    }
2421
2422    builder
2423}
2424
2425/// Bundled-root TLS config for the CA-less-platform fallback (rc-3j4mq):
2426/// Mozilla's root set from `webpki-roots`, precedent rc-ayy11 (camel-cli
2427/// redis-tls pure-rust roots).
2428///
2429/// Why a preconfigured `rustls::ClientConfig` and not a reqwest root
2430/// knob: reqwest 0.13 removed `tls_built_in_root_certs` and its
2431/// webpki-roots feature, `Certificate::from_der` needs full DER
2432/// certificates while webpki-roots ships pre-parsed `TrustAnchor`s, and
2433/// `tls_certs_merge` still routes through rustls-platform-verifier
2434/// (which hard-errors on android/apple targets when extra roots are
2435/// set). `tls_backend_preconfigured` swaps the whole TLS backend; on
2436/// reqwest 0.13.4 the preconfigured path builds its connector without
2437/// consulting platform roots, so the empty-CA-store builder error cannot
2438/// recur there.
2439fn webpki_root_client_config() -> rustls::ClientConfig {
2440    // Mirror reqwest's own provider resolution (async_impl/client.rs):
2441    // process-default provider when the host installed one (camel-cli
2442    // installs ring), else the aws-lc-rs default reqwest's `rustls`
2443    // feature falls back to.
2444    let provider = rustls::crypto::CryptoProvider::get_default()
2445        .cloned()
2446        .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
2447    rustls::ClientConfig::builder_with_provider(provider)
2448        .with_safe_default_protocol_versions()
2449        // Stock rustls providers (ring, aws-lc-rs) always support the
2450        // safe default TLS versions; this config is static, not input- or
2451        // platform-dependent.
2452        .expect("stock rustls provider supports the safe default protocol versions") // allow-unwrap
2453        .with_root_certificates(mozilla_only())
2454        .with_no_client_auth()
2455    // No ALPN override: the workspace reqwest builds without the http2
2456    // feature, so the primary path negotiates plain HTTP/1.1. Sending no
2457    // ALPN extension yields the same HTTP/1.1 outcome here without
2458    // duplicating reqwest feature knowledge in this crate.
2459}
2460
2461/// Root store for the CA-less-platform fallback that UNIONS the bundled
2462/// Mozilla anchors with any configured custom CA, instead of the
2463/// primary path's system-roots-plus-CA scheme (which cannot load on a
2464/// CA-less platform — that is what triggered the fallback). Order
2465/// contract: accepted custom anchors come FIRST, then the bundled
2466/// Mozilla anchors — parity with the primary `add_root_certificate`
2467/// path's extra-roots-before-platform-roots order (rustls-platform-
2468/// verifier 0.7.0 `src/verification/others.rs:61-93` adds extra roots
2469/// before native certs). The no-CA path and the non-strict degrade
2470/// paths (unreadable file, zero PEM sections, zero roots accepted)
2471/// return Mozilla-only stores. Strict mode fails closed on every CA
2472/// load/parse/reject failure; non-strict warns and degrades to
2473/// Mozilla-only.
2474fn fallback_root_store(
2475    custom_ca: Option<&str>,
2476    strict: bool,
2477) -> Result<rustls::RootCertStore, CamelError> {
2478    // Start EMPTY, not Mozilla-seeded: the custom anchors must land
2479    // before the Mozilla tail, so the bundle is appended only on the
2480    // success path (and served verbatim by `mozilla_only()` elsewhere).
2481    // A missed early-return rewrite would otherwise hand back an empty
2482    // store and break every handshake, which the degrade arms below
2483    // must never do.
2484    let mut store = rustls::RootCertStore::empty();
2485    let Some(ca_path) = custom_ca else {
2486        return Ok(mozilla_only());
2487    };
2488    let ca_bytes = match std::fs::read(ca_path) {
2489        Ok(bytes) => bytes,
2490        Err(e) => {
2491            if strict {
2492                return Err(CamelError::EndpointCreationFailed(format!(
2493                    "tls.strict/webpki-fallback: configured CA certificate '{ca_path}' is unreadable: {e}"
2494                )));
2495            }
2496            // log-policy: handler-owned
2497            tracing::warn!(
2498                error = %e,
2499                "configured CA certificate file unreadable — falling back to bundled Mozilla roots"
2500            );
2501            return Ok(mozilla_only());
2502        }
2503    };
2504    let certs: Vec<_> = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2505        .filter_map(|r| r.ok())
2506        .collect();
2507    if certs.is_empty() {
2508        if strict {
2509            return Err(CamelError::EndpointCreationFailed(format!(
2510                "tls.strict/webpki-fallback: configured CA certificate '{ca_path}' has no \
2511                 parseable PEM CERTIFICATE section"
2512            )));
2513        }
2514        // log-policy: handler-owned
2515        tracing::warn!(
2516            "configured CA certificate contains no parseable PEM CERTIFICATE section — \
2517             falling back to bundled Mozilla roots"
2518        );
2519        return Ok(mozilla_only());
2520    }
2521    let certs_len = certs.len();
2522    let (added, _ignored) = store.add_parsable_certificates(certs);
2523    if added == 0 {
2524        if strict {
2525            return Err(CamelError::EndpointCreationFailed(format!(
2526                "tls.strict/webpki-fallback: configured CA certificate '{ca_path}' was \
2527                 rejected by the TLS root store (0 of {certs_len} accepted)"
2528            )));
2529        }
2530        // log-policy: handler-owned
2531        tracing::warn!(
2532            "configured CA certificate was rejected by the TLS root store — \
2533             falling back to bundled Mozilla roots"
2534        );
2535        return Ok(mozilla_only());
2536    }
2537    // Custom anchors first, then the bundled Mozilla anchors — the
2538    // same extra-roots-before-platform-roots order the primary path
2539    // produces (rustls-platform-verifier 0.7.0
2540    // `src/verification/others.rs:61-93`). Order does not affect
2541    // accept/reject (webpki tries anchors until one validates), so
2542    // this is parity hygiene, not a security boundary.
2543    store
2544        .roots
2545        .extend_from_slice(webpki_roots::TLS_SERVER_ROOTS);
2546    Ok(store)
2547}
2548
2549/// The bundled Mozilla webpki anchors alone — the no-CA root set, the
2550/// target of every non-strict degrade path in [`fallback_root_store`],
2551/// and the root store behind [`webpki_root_client_config`].
2552fn mozilla_only() -> rustls::RootCertStore {
2553    rustls::RootCertStore {
2554        roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
2555    }
2556}
2557
2558/// Server-cert verifier that accepts any certificate without validation
2559/// — the preconfigured-backend stand-in for reqwest's
2560/// `danger_accept_invalid_certs(true)` knob (that knob does not exist on
2561/// the `tls_backend_preconfigured` path). Mirrors reqwest 0.13.4's
2562/// internal `NoVerifier` exactly, scheme list included.
2563#[derive(Debug)]
2564struct NoVerifyServerCertVerifier;
2565
2566impl rustls::client::danger::ServerCertVerifier for NoVerifyServerCertVerifier {
2567    fn verify_server_cert(
2568        &self,
2569        _end_entity: &rustls::pki_types::CertificateDer<'_>,
2570        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
2571        _server_name: &rustls::pki_types::ServerName<'_>,
2572        _ocsp_response: &[u8],
2573        _now: rustls::pki_types::UnixTime,
2574    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
2575        // danger_accept_invalid_certs parity: no validation whatsoever.
2576        Ok(rustls::client::danger::ServerCertVerified::assertion())
2577    }
2578
2579    fn verify_tls12_signature(
2580        &self,
2581        _message: &[u8],
2582        _cert: &rustls::pki_types::CertificateDer<'_>,
2583        _dss: &rustls::DigitallySignedStruct,
2584    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
2585        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
2586    }
2587
2588    fn verify_tls13_signature(
2589        &self,
2590        _message: &[u8],
2591        _cert: &rustls::pki_types::CertificateDer<'_>,
2592        _dss: &rustls::DigitallySignedStruct,
2593    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
2594        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
2595    }
2596
2597    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
2598        // Mirrors reqwest 0.13.4 NoVerifier (src/tls.rs): SHA1-legacy and
2599        // P-521 included, ED448 included.
2600        vec![
2601            rustls::SignatureScheme::RSA_PKCS1_SHA1,
2602            rustls::SignatureScheme::ECDSA_SHA1_Legacy,
2603            rustls::SignatureScheme::RSA_PKCS1_SHA256,
2604            rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
2605            rustls::SignatureScheme::RSA_PKCS1_SHA384,
2606            rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
2607            rustls::SignatureScheme::RSA_PKCS1_SHA512,
2608            rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
2609            rustls::SignatureScheme::RSA_PSS_SHA256,
2610            rustls::SignatureScheme::RSA_PSS_SHA384,
2611            rustls::SignatureScheme::RSA_PSS_SHA512,
2612            rustls::SignatureScheme::ED25519,
2613            rustls::SignatureScheme::ED448,
2614        ]
2615    }
2616}
2617
2618/// Full rustls `ClientConfig` for the CA-less-platform fallback when the
2619/// configured TLS material must be honored: custom CA union store
2620/// (fallback_root_store), optional no-verify mode (insecure /
2621/// verify_peer=false), and mTLS client auth. Returns `Ok(None)` when the
2622/// plain `webpki_root_client_config()` suffices (TLS on, no material,
2623/// verification on) — callers resolve `config.tls` presence before
2624/// calling. Strict mode fails closed on every material failure;
2625/// non-strict warns and degrades per item.
2626fn fallback_client_config(
2627    tls: &crate::config::TlsConfig,
2628) -> Result<Option<rustls::ClientConfig>, CamelError> {
2629    if !tls.enabled {
2630        return Ok(None);
2631    }
2632    let material = tls.ca_cert_path.is_some()
2633        || tls.client_cert_path.is_some()
2634        || tls.client_key_path.is_some();
2635    let verification_disabled = tls.insecure || !tls.verify_peer;
2636    if !material && !verification_disabled {
2637        return Ok(None);
2638    }
2639    // Mirror reqwest's own provider resolution (async_impl/client.rs):
2640    // process-default provider when the host installed one (camel-cli
2641    // installs ring), else the aws-lc-rs default reqwest's `rustls`
2642    // feature falls back to.
2643    let provider = rustls::crypto::CryptoProvider::get_default()
2644        .cloned()
2645        .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
2646    // Computed ALWAYS: strict material validation must happen even when
2647    // the danger verifier would bypass the roots; `Err` propagates only
2648    // under strict by construction of `fallback_root_store`.
2649    let store = fallback_root_store(tls.ca_cert_path.as_deref(), tls.strict)?;
2650    let builder = rustls::ClientConfig::builder_with_provider(provider)
2651        .with_safe_default_protocol_versions()
2652        // Stock rustls providers (ring, aws-lc-rs) always support the
2653        // safe default TLS versions; this config is static, not input- or
2654        // platform-dependent.
2655        .expect("stock rustls provider supports the safe default protocol versions") // allow-unwrap
2656        ;
2657    let builder = if verification_disabled {
2658        builder
2659            .dangerous()
2660            .with_custom_certificate_verifier(std::sync::Arc::new(NoVerifyServerCertVerifier))
2661    } else {
2662        builder.with_root_certificates(store)
2663    };
2664    let config = match (&tls.client_cert_path, &tls.client_key_path) {
2665        (Some(cert_path), Some(key_path)) => {
2666            match (std::fs::read(cert_path), std::fs::read(key_path)) {
2667                (Ok(cert_bytes), Ok(key_bytes)) => {
2668                    let chain: Vec<_> =
2669                        rustls_pemfile::certs(&mut std::io::Cursor::new(&cert_bytes))
2670                            .filter_map(|r| r.ok())
2671                            .collect();
2672                    if chain.is_empty() {
2673                        if tls.strict {
2674                            return Err(CamelError::EndpointCreationFailed(format!(
2675                                "tls.strict/webpki-fallback: configured client certificate \
2676                                 chain '{cert_path}' has no parseable PEM CERTIFICATE section"
2677                            )));
2678                        }
2679                        // log-policy: handler-owned
2680                        tracing::warn!(
2681                            "configured client certificate chain has no parseable PEM \
2682                             CERTIFICATE section — client certificate NOT used"
2683                        );
2684                        builder.with_no_client_auth()
2685                    } else {
2686                        match rustls_pemfile::private_key(&mut std::io::Cursor::new(&key_bytes)) {
2687                            // Clone-on-attempt: rustls consumes the builder
2688                            // in `with_client_auth_cert`, but a rejection
2689                            // must fall back to `with_no_client_auth` on
2690                            // the SAME builder state.
2691                            Ok(Some(key)) => {
2692                                match builder.clone().with_client_auth_cert(chain, key) {
2693                                    Ok(config) => config,
2694                                    Err(e) => {
2695                                        if tls.strict {
2696                                            return Err(CamelError::EndpointCreationFailed(
2697                                                format!(
2698                                                    "tls.strict/webpki-fallback: configured mTLS \
2699                                             identity was rejected by the TLS backend: {e}"
2700                                                ),
2701                                            ));
2702                                        }
2703                                        // log-policy: handler-owned
2704                                        tracing::warn!(
2705                                            error = %e,
2706                                            "configured mTLS identity was rejected by the TLS \
2707                                             backend — client certificate NOT used"
2708                                        );
2709                                        builder.with_no_client_auth()
2710                                    }
2711                                }
2712                            }
2713                            // `Ok(None)` (no private-key section) and `Err`
2714                            // (malformed section) are the same failure for
2715                            // our purposes: no usable key.
2716                            Ok(None) | Err(_) => {
2717                                if tls.strict {
2718                                    return Err(CamelError::EndpointCreationFailed(format!(
2719                                        "tls.strict/webpki-fallback: configured client key \
2720                                         '{key_path}' has no parseable private key section"
2721                                    )));
2722                                }
2723                                // log-policy: handler-owned
2724                                tracing::warn!(
2725                                    "configured client key has no parseable private key \
2726                                     section — client certificate NOT used"
2727                                );
2728                                builder.with_no_client_auth()
2729                            }
2730                        }
2731                    }
2732                }
2733                (cert_r, key_r) => {
2734                    if tls.strict {
2735                        return Err(CamelError::EndpointCreationFailed(
2736                            "tls.strict/webpki-fallback: configured mTLS cert/key files are \
2737                             unreadable"
2738                                .to_string(),
2739                        ));
2740                    }
2741                    // log-policy: handler-owned
2742                    tracing::warn!(
2743                        cert_ok = cert_r.is_ok(),
2744                        key_ok = key_r.is_ok(),
2745                        "configured mTLS cert/key file unreadable — client certificate NOT used"
2746                    );
2747                    builder.with_no_client_auth()
2748                }
2749            }
2750        }
2751        // Half-configured mTLS pair (cert XOR key) — mirror of
2752        // strict_tls_error's half-pair rejection.
2753        (Some(_), None) | (None, Some(_)) => {
2754            if tls.strict {
2755                return Err(CamelError::EndpointCreationFailed(
2756                    "tls.strict/webpki-fallback: mTLS requires BOTH client_cert_path and \
2757                     client_key_path"
2758                        .to_string(),
2759                ));
2760            }
2761            // log-policy: handler-owned
2762            tracing::warn!(
2763                "mTLS requires BOTH client_cert_path and client_key_path — \
2764                 client certificate NOT used"
2765            );
2766            builder.with_no_client_auth()
2767        }
2768        (None, None) => builder.with_no_client_auth(),
2769    };
2770    Ok(Some(config))
2771}
2772
2773/// Last-resort client for the (unreachable-on-reqwest-0.13.4) second
2774/// build error inside [`webpki_fallback_client`]: no proxy, no
2775/// redirects, webpki roots. Reaching the `expect` means the TLS stack
2776/// is broken process-wide, CA store or not — the caller logs
2777/// system-broken before delegating here.
2778fn emergency_webpki_client() -> reqwest::Client {
2779    reqwest::Client::builder()
2780        .no_proxy()
2781        .redirect(reqwest::redirect::Policy::none())
2782        .tls_backend_preconfigured(webpki_root_client_config())
2783        .build()
2784        .expect("preconfigured webpki-rooted client build has no fallible stage") // allow-unwrap
2785}
2786
2787/// Why the webpki fallback was entered: a real platform-verifier
2788/// build failure, or (tests only) a forced entry to exercise the
2789/// fallback path hermetically on hosts where a valid configured CA
2790/// rescues the primary verifier (e_glm adjudication, rc-hl9cn).
2791enum FallbackTrigger {
2792    Platform(reqwest::Error),
2793    #[cfg(test)]
2794    Forced,
2795}
2796
2797impl std::fmt::Display for FallbackTrigger {
2798    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2799        match self {
2800            // Same rendering as the pre-seam `error = %first_error`
2801            // field, so operator-facing logs are unchanged.
2802            FallbackTrigger::Platform(e) => std::fmt::Display::fmt(e, f),
2803            #[cfg(test)]
2804            FallbackTrigger::Forced => f.write_str("forced webpki fallback entry (test)"),
2805        }
2806    }
2807}
2808
2809/// Shared terminal for a failed webpki fallback client rebuild —
2810/// `build_with_backend`'s `Err` arm and its `cfg(test)` seam twin:
2811/// always emits the system-broken error line, then fails closed with a
2812/// typed error under `tls.strict`, or degrades to the material-free
2813/// [`emergency_webpki_client`] otherwise.
2814fn fallback_rebuild_failed(
2815    config: &HttpConfig,
2816    failure: &dyn std::fmt::Display,
2817) -> Result<reqwest::Client, CamelError> {
2818    // log-policy: system-broken
2819    tracing::error!(
2820        error = %failure,
2821        "webpki fallback client build failed — TLS stack broken process-wide"
2822    );
2823    if config.tls.as_ref().is_some_and(|tls| tls.strict) {
2824        Err(CamelError::EndpointCreationFailed(format!(
2825            "tls.strict/webpki-fallback: webpki fallback client rebuild failed — \
2826             refusing material-free emergency client under tls.strict: {failure}"
2827        )))
2828    } else {
2829        Ok(emergency_webpki_client())
2830    }
2831}
2832
2833/// Fallback path when the platform-verifier client build fails (typical
2834/// trigger: a platform without a system CA store at the probed paths,
2835/// e.g. Android/Termux — the eager `HttpComponent::new()` in the BASE
2836/// flavor used to panic there, rc-3j4mq). Startup must never panic on
2837/// CA-less platforms, so the retry swaps the TLS backend for the bundled
2838/// Mozilla root set — and, unlike the preconfigured backend of old, the
2839/// retry HONORS the configured TLS material (custom CA union, mTLS
2840/// identity, no-verify mode) via [`fallback_client_config`]. The
2841/// fallback is ONLY reachable through a failed primary build (or, under
2842/// `cfg(test)`, a [`FallbackTrigger::Forced`] seam entry) — it never
2843/// activates when native roots load.
2844///
2845/// `Err` is typed and strict-only by construction: [`fallback_client_config`]
2846/// fails closed only under `tls.strict`, and the shared second-error
2847/// rebuild terminal ([`fallback_rebuild_failed`]) fails closed the same
2848/// way — so a permissive config always leaves here with a built
2849/// (possibly item-downgraded, possibly material-free emergency) client.
2850fn webpki_fallback_client(
2851    config: &HttpConfig,
2852    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2853    trigger: FallbackTrigger,
2854) -> Result<reqwest::Client, CamelError> {
2855    #[cfg(test)]
2856    BUILD_CLIENT_FALLBACKS.with(|c| c.set(c.get() + 1));
2857
2858    // log-policy: handler-owned
2859    tracing::warn!(
2860        error = %trigger,
2861        "HTTP client build failed on platform TLS roots — the platform CA store \
2862         is missing or unreadable (typical on Android/Termux). Retrying with \
2863         bundled Mozilla root certificates; platform trust settings do not \
2864         apply to this client"
2865    );
2866
2867    // Shared second-error terminal: unreachable on reqwest 0.13.4 (the
2868    // preconfigured backend has no fallible stage — no platform
2869    // verifier, no root-store parse — unless the http3 feature is on,
2870    // which this workspace does not enable). Kept as an honest,
2871    // loudly-logged terminal instead of silently re-panicking: reaching
2872    // it means the TLS stack is broken process-wide, CA store or not.
2873    // Strict-fail-closed: under `tls.strict` the rebuild failure is a
2874    // typed `Err` (`fallback_rebuild_failed`); non-strict keeps the
2875    // material-free emergency-client degrade. The `cfg(test)` seam in
2876    // the `Ok` arm routes the forced-rebuild-failure twin through the
2877    // same terminal so both outcomes stay test-hermetic.
2878    let build_with_backend =
2879        |backend: rustls::ClientConfig| -> Result<reqwest::Client, CamelError> {
2880            match client_builder(config, resolve_override)
2881                .tls_backend_preconfigured(backend)
2882                .build()
2883            {
2884                Ok(client) => {
2885                    #[cfg(test)]
2886                    if FORCE_FALLBACK_REBUILD_FAIL.with(std::cell::Cell::get) {
2887                        return fallback_rebuild_failed(
2888                            config,
2889                            &"forced webpki fallback rebuild failure (test)",
2890                        );
2891                    }
2892                    Ok(client)
2893                }
2894                Err(second_error) => fallback_rebuild_failed(config, &second_error),
2895            }
2896        };
2897
2898    // The strict carry note is accurate only when material is actually
2899    // configured: in the strict + insecure/verify_peer=false + zero-material
2900    // corner the fallback config exists solely for the danger verifier and
2901    // carries nothing.
2902    let material_configured = config.tls.as_ref().is_some_and(|tls| {
2903        tls.ca_cert_path.is_some()
2904            || tls.client_cert_path.is_some()
2905            || tls.client_key_path.is_some()
2906    });
2907
2908    match config.tls.as_ref().map(fallback_client_config) {
2909        // No TLS config, TLS off, or verification-on with no material:
2910        // the plain webpki-rooted backend is the parity configuration.
2911        None | Some(Ok(None)) => build_with_backend(webpki_root_client_config()),
2912        Some(Ok(Some(strict_or_parity_config))) => {
2913            if material_configured && config.tls.as_ref().is_some_and(|tls| tls.strict) {
2914                // log-policy: handler-owned
2915                tracing::info!(
2916                    "platform CA store unavailable — webpki fallback carries \
2917                     configured TLS material"
2918                );
2919            }
2920            build_with_backend(strict_or_parity_config)
2921        }
2922        // Typed fail-closed conflict (strict-only by construction).
2923        Some(Err(e)) => Err(e),
2924    }
2925}
2926
2927/// Build the shared/dns-pinned reqwest client. Primary path is the
2928/// platform verifier; on a platform whose CA store cannot be loaded the
2929/// build retries on bundled Mozilla roots honoring the configured TLS
2930/// material (see [`webpki_fallback_client`]) instead of panicking
2931/// (rc-3j4mq). `Err` is strict-only by construction: it means the
2932/// configured CA/mTLS material failed to load under `tls.strict`, or —
2933/// in the broken-TLS-stack corner — the webpki fallback client rebuild
2934/// failed under `tls.strict`, and the caller must fail closed rather
2935/// than serve with a degraded client.
2936pub(crate) fn build_client(
2937    config: &HttpConfig,
2938    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2939) -> Result<reqwest::Client, CamelError> {
2940    #[cfg(test)]
2941    BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2942
2943    // Test seam (e_glm adjudication, rc-hl9cn): on hosts where a valid
2944    // configured CA rescues the primary verifier build
2945    // (rustls-platform-verifier merges extra roots first), the fallback
2946    // path with VALID material is unreachable through real triggers;
2947    // armed tests enter the fallback directly. Release builds are
2948    // unchanged.
2949    #[cfg(test)]
2950    if FORCE_WEBPKI_FALLBACK.with(std::cell::Cell::get) {
2951        return webpki_fallback_client(config, resolve_override, FallbackTrigger::Forced);
2952    }
2953
2954    match client_builder(config, resolve_override).build() {
2955        Ok(client) => Ok(client),
2956        Err(first_error) => webpki_fallback_client(
2957            config,
2958            resolve_override,
2959            FallbackTrigger::Platform(first_error),
2960        ),
2961    }
2962}
2963
2964/// Eagerly load and parse the configured TLS material when strict mode is
2965/// on (audit 2026-08-31 R3 / rc-ayrwk). Returns the first failure as an
2966/// `EndpointCreationFailed` error; `None` when the material loads, or when
2967/// strict mode is off (the permissive F2-7 fallback with its loud warns
2968/// stays the default for back-compat).
2969///
2970/// Mirrors the four load sites in [`build_client`]: CA unreadable, CA
2971/// unparseable, mTLS cert/key unreadable, mTLS identity unparseable.
2972fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2973    let tls = config.tls.as_ref()?;
2974    if !tls.enabled || !tls.strict {
2975        return None;
2976    }
2977    if let Some(ca_path) = &tls.ca_cert_path {
2978        match std::fs::read(ca_path) {
2979            Ok(ca_bytes) => {
2980                // `reqwest::Certificate::{from_pem,from_der}` defer parsing
2981                // under rustls, and unparseable entries are silently
2982                // skipped at client build — so strict validation must be
2983                // eager AND match what the backend actually enforces:
2984                // a PEM bundle with at least one parseable CERTIFICATE
2985                // section (rustls-pemfile). A raw-DER file is rejected
2986                // outright: the rustls backend never honors lone-DER
2987                // bytes here (they wrap unvalidated and are dropped at
2988                // root-store insertion), so certifying one under strict
2989                // would certify an unenforced config (e_glm stage-4
2990                // finding 1). Operators convert DER bundles to PEM.
2991                let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2992                    .filter(|r| r.is_ok())
2993                    .count();
2994                if pem_sections == 0 {
2995                    return Some(CamelError::EndpointCreationFailed(format!(
2996                        "tls.strict: configured CA certificate '{ca_path}' has no \
2997                         parseable PEM CERTIFICATE section (DER bundles are not \
2998                         enforced by the TLS backend — convert to PEM)"
2999                    )));
3000                }
3001            }
3002            Err(e) => {
3003                return Some(CamelError::EndpointCreationFailed(format!(
3004                    "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
3005                )));
3006            }
3007        }
3008    }
3009    // A half-configured mTLS pair (cert XOR key) previously degraded
3010    // silently to non-mTLS even under strict — reject it (e_glm stage-4
3011    // finding 2).
3012    if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
3013        return Some(CamelError::EndpointCreationFailed(
3014            "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
3015        ));
3016    }
3017    if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
3018        match (std::fs::read(cert_path), std::fs::read(key_path)) {
3019            (Ok(mut cert_bytes), Ok(key_bytes)) => {
3020                cert_bytes.extend_from_slice(&key_bytes);
3021                if reqwest::Identity::from_pem(&cert_bytes).is_err() {
3022                    return Some(CamelError::EndpointCreationFailed(
3023                        "tls.strict: configured mTLS identity failed to parse".to_string(),
3024                    ));
3025                }
3026            }
3027            _ => {
3028                return Some(CamelError::EndpointCreationFailed(
3029                    "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
3030                ));
3031            }
3032        }
3033    }
3034    None
3035}
3036
3037#[cfg(test)]
3038pub(crate) fn build_client_call_count() -> u64 {
3039    BUILD_CLIENT_CALLS.with(|c| c.get())
3040}
3041
3042#[cfg(test)]
3043pub(crate) fn build_client_fallback_count() -> u64 {
3044    BUILD_CLIENT_FALLBACKS.with(|c| c.get())
3045}
3046
3047/// Shared constructor fold: build the client, or — when the strict
3048/// webpki fallback fails closed — pair an emergency webpki client
3049/// (never issues a request; `create_endpoint` fails first via the
3050/// folded error) with the typed build error (rc-hl9cn).
3051fn client_or_emergency(config: &HttpConfig) -> (reqwest::Client, Option<CamelError>) {
3052    match build_client(config, None) {
3053        Ok(c) => (c, None),
3054        Err(e) => (emergency_webpki_client(), Some(e)),
3055    }
3056}
3057
3058impl HttpComponent {
3059    pub fn new() -> Self {
3060        let config = HttpConfig::default();
3061        let strict_err = strict_tls_error(&config);
3062        let (client, build_err) = client_or_emergency(&config);
3063        Self {
3064            client,
3065            config,
3066            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3067                PINNED_CLIENT_TTL,
3068                PINNED_CLIENT_MAX_ENTRIES,
3069            )),
3070            strict_tls_error: strict_err.or(build_err),
3071        }
3072    }
3073
3074    pub fn with_config(config: HttpConfig) -> Self {
3075        let strict_err = strict_tls_error(&config);
3076        let (client, build_err) = client_or_emergency(&config);
3077        Self {
3078            client,
3079            config,
3080            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3081                PINNED_CLIENT_TTL,
3082                PINNED_CLIENT_MAX_ENTRIES,
3083            )),
3084            strict_tls_error: strict_err.or(build_err),
3085        }
3086    }
3087
3088    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
3089        match config {
3090            Some(cfg) => Self::with_config(cfg),
3091            None => Self::new(),
3092        }
3093    }
3094}
3095
3096impl Default for HttpComponent {
3097    fn default() -> Self {
3098        Self::new()
3099    }
3100}
3101
3102impl Component for HttpComponent {
3103    fn scheme(&self) -> &str {
3104        "http"
3105    }
3106
3107    fn metadata(&self) -> ComponentMetadata {
3108        HttpEndpointConfig::metadata()
3109    }
3110
3111    fn create_endpoint(
3112        &self,
3113        uri: &str,
3114        ctx: &dyn camel_component_api::ComponentContext,
3115    ) -> Result<Box<dyn Endpoint>, CamelError> {
3116        if let Some(err) = &self.strict_tls_error {
3117            return Err(err.clone());
3118        }
3119        self.config.validate()?;
3120        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
3121        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
3122        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
3123            server_config.host.clone(),
3124            server_config.port,
3125        )));
3126        self.pinned_cache
3127            .wire(HttpComponentKind::Http, ctx.metrics());
3128        Ok(Box::new(HttpEndpoint {
3129            uri: uri.to_string(),
3130            config,
3131            server_config,
3132            client: self.client.clone(),
3133            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
3134            http_config: self.config.clone(),
3135        }))
3136    }
3137}
3138
3139pub struct HttpsComponent {
3140    config: HttpConfig,
3141    pinned_cache: std::sync::Arc<PinnedClientCache>,
3142    client: reqwest::Client,
3143    /// Set at construction when `tls.strict` is on and the configured
3144    /// material fails to load; surfaced as an endpoint-creation failure
3145    /// (rc-ayrwk).
3146    strict_tls_error: Option<CamelError>,
3147}
3148
3149impl HttpsComponent {
3150    pub fn new() -> Self {
3151        let config = HttpConfig::default();
3152        let strict_err = strict_tls_error(&config);
3153        let (client, build_err) = client_or_emergency(&config);
3154        Self {
3155            client,
3156            config,
3157            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3158                PINNED_CLIENT_TTL,
3159                PINNED_CLIENT_MAX_ENTRIES,
3160            )),
3161            strict_tls_error: strict_err.or(build_err),
3162        }
3163    }
3164
3165    pub fn with_config(config: HttpConfig) -> Self {
3166        let strict_err = strict_tls_error(&config);
3167        let (client, build_err) = client_or_emergency(&config);
3168        Self {
3169            client,
3170            config,
3171            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3172                PINNED_CLIENT_TTL,
3173                PINNED_CLIENT_MAX_ENTRIES,
3174            )),
3175            strict_tls_error: strict_err.or(build_err),
3176        }
3177    }
3178
3179    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
3180        match config {
3181            Some(cfg) => Self::with_config(cfg),
3182            None => Self::new(),
3183        }
3184    }
3185}
3186
3187impl Default for HttpsComponent {
3188    fn default() -> Self {
3189        Self::new()
3190    }
3191}
3192
3193impl Component for HttpsComponent {
3194    fn scheme(&self) -> &str {
3195        "https"
3196    }
3197
3198    fn metadata(&self) -> ComponentMetadata {
3199        // HTTPS shares the same URI option surface and capabilities as HTTP.
3200        // Only the scheme and description differ.
3201        let mut meta = HttpEndpointConfig::metadata();
3202        meta.scheme = "https".to_string();
3203        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
3204        meta
3205    }
3206
3207    fn create_endpoint(
3208        &self,
3209        uri: &str,
3210        ctx: &dyn camel_component_api::ComponentContext,
3211    ) -> Result<Box<dyn Endpoint>, CamelError> {
3212        if let Some(err) = &self.strict_tls_error {
3213            return Err(err.clone());
3214        }
3215        self.config.validate()?;
3216        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
3217        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
3218        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
3219            server_config.host.clone(),
3220            server_config.port,
3221        )));
3222        self.pinned_cache
3223            .wire(HttpComponentKind::Https, ctx.metrics());
3224        Ok(Box::new(HttpEndpoint {
3225            uri: uri.to_string(),
3226            config,
3227            server_config,
3228            client: self.client.clone(),
3229            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
3230            http_config: self.config.clone(),
3231        }))
3232    }
3233}
3234
3235// ---------------------------------------------------------------------------
3236// HttpEndpoint
3237// ---------------------------------------------------------------------------
3238
3239struct HttpEndpoint {
3240    uri: String,
3241    config: HttpEndpointConfig,
3242    server_config: HttpServerConfig,
3243    client: reqwest::Client,
3244    pinned_cache: std::sync::Arc<PinnedClientCache>,
3245    http_config: HttpConfig,
3246}
3247
3248impl Endpoint for HttpEndpoint {
3249    fn uri(&self) -> &str {
3250        &self.uri
3251    }
3252
3253    fn create_consumer(
3254        &self,
3255        rt: Arc<dyn camel_component_api::RuntimeObservability>,
3256    ) -> Result<Box<dyn Consumer>, CamelError> {
3257        // Scheme/config consistency check (spec §5) — uses parsed scheme
3258        // from HttpServerConfig, not a fragile port-443 heuristic.
3259        let scheme_is_https = self.server_config.scheme == "https";
3260        let has_tls = self.server_config.tls_config.is_some();
3261
3262        if scheme_is_https && !has_tls {
3263            return Err(CamelError::EndpointCreationFailed(
3264                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
3265            ));
3266        }
3267        if !scheme_is_https && has_tls {
3268            return Err(CamelError::EndpointCreationFailed(
3269                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
3270            ));
3271        }
3272        // bd rc-ns3yc: fail-closed BEFORE any HttpConsumer is constructed —
3273        // a directly-built HttpServerConfig never passed the parse seam, and
3274        // the inflight semaphore panics above the primitive bound
3275        // (mirror of grpc consumer_concurrency_limit, commit 101327e5).
3276        max_inflight_requests_limit(self.server_config.max_inflight_requests)?;
3277        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
3278    }
3279
3280    fn create_producer(
3281        &self,
3282        rt: Arc<dyn camel_component_api::RuntimeObservability>,
3283        _ctx: &ProducerContext,
3284    ) -> Result<BoxProcessor, CamelError> {
3285        let producer = HttpProducer {
3286            config: Arc::new(self.config.clone()),
3287            client: self.client.clone(),
3288            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
3289            http_config: Arc::new(self.http_config.clone()),
3290            runtime: rt,
3291        };
3292        if let Some(ref provider) = self.config.token_provider {
3293            let layer = BearerTokenLayer::new(Arc::clone(provider));
3294            Ok(BoxProcessor::new(layer.layer(producer)))
3295        } else {
3296            Ok(BoxProcessor::new(producer))
3297        }
3298    }
3299}
3300
3301// ---------------------------------------------------------------------------
3302// HttpProducer
3303// ---------------------------------------------------------------------------
3304
3305#[derive(Clone)]
3306struct HttpProducer {
3307    config: Arc<HttpEndpointConfig>,
3308    client: reqwest::Client,
3309    pinned_cache: std::sync::Arc<PinnedClientCache>,
3310    http_config: Arc<HttpConfig>,
3311    /// Runtime observability handle powering the component-ops facade at
3312    /// the request boundary (`("http","request")`, dashboard-observability
3313    /// Task 4.3). The retained `e:http:accept*` labels are consumer-side
3314    /// (server accept loop) — different boundary, no collision with
3315    /// `e:http:request`.
3316    runtime: Arc<dyn RuntimeObservability>,
3317}
3318
3319impl HttpProducer {
3320    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
3321        if let Some(ref method) = config.http_method {
3322            return method.to_uppercase();
3323        }
3324        if let Some(method) = exchange
3325            .input
3326            .header("CamelHttpMethod")
3327            .and_then(|v| v.as_str())
3328        {
3329            return method.to_uppercase();
3330        }
3331        if !exchange.input.body.is_empty() {
3332            return "POST".to_string();
3333        }
3334        "GET".to_string()
3335    }
3336
3337    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
3338        // bridgeEndpoint=true: exchange URL headers (CamelHttpUri,
3339        // CamelHttpPath, CamelHttpQuery) are ignored per Apache Camel
3340        // bridging semantics. The endpoint's own query still rides: the
3341        // same raw-preserving, consumed-option-filtered query as the
3342        // non-bridge path (bridgeEndpoint itself is a consumed option),
3343        // with programmatic query_params appending absent keys after the
3344        // raw base. This check MUST come before the CamelHttpUri override
3345        // so bridging wins over that header.
3346        if config.bridge_endpoint {
3347            let Some(query) = resolve_endpoint_query(config)? else {
3348                return Ok(config.base_url.clone());
3349            };
3350            // Validation only (rc-ph7z2): a malformed base still errors
3351            // through the redacted-diagnostic path below. The parsed value
3352            // is NEVER re-emitted — assembly is verbatim string
3353            // composition, authored bytes end-to-end: no WHATWG
3354            // normalization (dot-segment collapse, default-port strip,
3355            // scheme/host lowercasing), matching every other arm (Papal
3356            // Direction A).
3357            let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
3358                CamelError::ProcessorError(format!(
3359                    "invalid base URL '{}': {e}",
3360                    redact_url_for_diagnostics(&config.base_url)
3361                ))
3362            })?;
3363            let mut url = config.base_url.clone();
3364            url.push('?');
3365            url.push_str(&query);
3366            return Ok(url);
3367        }
3368
3369        if let Some(uri) = exchange
3370            .input
3371            .header("CamelHttpUri")
3372            .and_then(|v| v.as_str())
3373        {
3374            // Host fence (allowedUriHosts): opt-in, fail-closed. Evaluated
3375            // on the raw override before any path/query assembly; a
3376            // rejection renders the URL only through the diagnostics
3377            // redaction path (ADR-0051).
3378            if let Some(fence) = &config.allowed_uri_hosts
3379                && !uri_host_allowed(uri, fence)?
3380            {
3381                return Err(CamelError::ProcessorError(format!(
3382                    "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
3383                    redact_url_for_diagnostics(uri)
3384                )));
3385            }
3386            // The override replaces the base URL; its own query is the
3387            // higher-precedence source for composition (ADR-0071) — the
3388            // endpoint base query does not ride an override. Split at the
3389            // first `?` so CamelHttpPath applies to the path component
3390            // and the queries merge at pair level, never a second `?`
3391            // marker.
3392            let (base, override_query) = match uri.split_once('?') {
3393                Some((base, query)) => (base, Some(query)),
3394                None => (uri, None),
3395            };
3396            // Resolve-time span validation for the override URI's own query
3397            // (rc-m4xk1): a forbidden byte is a resolve error naming the
3398            // byte, never a verbatim ride that later surfaces as a reqwest
3399            // send error. Covers both downstream arms — the verbatim push
3400            // and merge_header_query, which validates only the header side.
3401            if let Some(query) = override_query {
3402                for (_key, span) in raw_query_pairs(query)? {
3403                    validate_raw_query_span(span)?;
3404                }
3405            }
3406            let mut url = base.to_string();
3407            if let Some(path) = exchange
3408                .input
3409                .header("CamelHttpPath")
3410                .and_then(|v| v.as_str())
3411            {
3412                if !url.ends_with('/') && !path.starts_with('/') {
3413                    url.push('/');
3414                }
3415                url.push_str(path);
3416            }
3417            if let Some(query) = exchange
3418                .input
3419                .header("CamelHttpQuery")
3420                .and_then(|v| v.as_str())
3421            {
3422                if let Some(merged) = merge_header_query(override_query, query)? {
3423                    url.push('?');
3424                    url.push_str(&merged);
3425                }
3426                return Ok(url);
3427            }
3428            if let Some(query) = override_query {
3429                url.push('?');
3430                url.push_str(query);
3431            }
3432            return Ok(url);
3433        }
3434
3435        let mut url = config.base_url.clone();
3436
3437        if let Some(path) = exchange
3438            .input
3439            .header("CamelHttpPath")
3440            .and_then(|v| v.as_str())
3441        {
3442            if !url.ends_with('/') && !path.starts_with('/') {
3443                url.push('/');
3444            }
3445            url.push_str(path);
3446        }
3447
3448        if let Some(query) = exchange
3449            .input
3450            .header("CamelHttpQuery")
3451            .and_then(|v| v.as_str())
3452        {
3453            // Compose: the endpoint query (raw-preserving,
3454            // consumed-option-filtered) comes first and wins collisions;
3455            // header pairs append verbatim for absent keys (ADR-0071).
3456            // An empty header leaves the endpoint query unchanged.
3457            if let Some(merged) =
3458                merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
3459            {
3460                url.push('?');
3461                url.push_str(&merged);
3462            }
3463            return Ok(url);
3464        }
3465
3466        if let Some(query) = resolve_endpoint_query(config)? {
3467            url.push('?');
3468            url.push_str(&query);
3469        }
3470
3471        Ok(url)
3472    }
3473
3474    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
3475        status >= range.0 && status <= range.1
3476    }
3477}
3478
3479/// One allowlist entry of the `CamelHttpUri` host fence (`allowedUriHosts`
3480/// endpoint option). DNS hosts are stored ASCII-lowercased; IPv6 literals
3481/// in bracketed canonical form (the `url` crate's host serialization). A
3482/// `port` of `None` is a host-only entry and permits any port.
3483#[derive(Clone, Debug, PartialEq, Eq)]
3484pub struct AllowedUriHost {
3485    /// Canonical host: lowercased DNS name or bracketed IPv6 literal.
3486    pub host: String,
3487    /// `Some` pins the entry to one effective port; `None` permits any.
3488    pub port: Option<u16>,
3489}
3490
3491/// Parse the `allowedUriHosts` option value: comma-separated `host` or
3492/// `host:port` entries. Bracketed IPv6 is supported (`[::1]:8443`, bare
3493/// `[::1]` host-only). Empty segments are dropped. Segments are parsed
3494/// through the `url` crate (with an `http://` scheme injected) so DNS
3495/// names are lowercased and ports range-checked; anything it rejects is a
3496/// malformed entry. A value yielding zero valid entries is also an error.
3497/// Both failure modes fail endpoint creation (fail-closed).
3498fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
3499    let mut entries = Vec::new();
3500    for segment in raw.split(',') {
3501        let segment = segment.trim();
3502        if segment.is_empty() {
3503            continue;
3504        }
3505        let parsed = url::Url::parse(&format!("http://{segment}"))
3506            .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
3507        // A segment carrying a path or userinfo is a typo'd entry — the
3508        // spec's "any other malformed entry" clause. Silently narrowing it
3509        // to its hostname would widen or skew the fence.
3510        if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
3511            return Err(invalid_allowed_uri_host_entry(segment));
3512        }
3513        let Some(host) = parsed.host_str() else {
3514            return Err(invalid_allowed_uri_host_entry(segment));
3515        };
3516        entries.push(AllowedUriHost {
3517            host: host.to_string(),
3518            port: parsed.port(),
3519        });
3520    }
3521    if entries.is_empty() {
3522        return Err(CamelError::InvalidUri(
3523            "allowedUriHosts declares no valid host entries".to_string(),
3524        ));
3525    }
3526    Ok(entries)
3527}
3528
3529fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
3530    CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
3531}
3532
3533/// Whether `url_str` matches the fence. Parse failure or a host-less URL
3534/// is fail-closed (`Ok(false)`). DNS hosts compare case-insensitively
3535/// (both sides are lowercased by the `url` crate); IPv6 compares in
3536/// bracketed canonical form. A host-only entry permits any port; a
3537/// `host:port` entry matches only the effective port — the explicit port
3538/// or the scheme default (443 for https, 80 for http).
3539pub(crate) fn uri_host_allowed(
3540    url_str: &str,
3541    fence: &[AllowedUriHost],
3542) -> Result<bool, CamelError> {
3543    let Ok(parsed) = url::Url::parse(url_str) else {
3544        return Ok(false);
3545    };
3546    let Some(host) = parsed.host_str() else {
3547        return Ok(false);
3548    };
3549    let effective_port = parsed.port().or(match parsed.scheme() {
3550        "https" => Some(443_u16),
3551        "http" => Some(80),
3552        _ => None,
3553    });
3554    Ok(fence.iter().any(|entry| {
3555        entry.host == host
3556            && match entry.port {
3557                None => true,
3558                Some(port) => effective_port == Some(port),
3559            }
3560    }))
3561}
3562
3563/// Serialize the outbound query for the endpoint base.
3564///
3565/// Authored raw pairs come first, byte-for-byte minus consumed option keys
3566/// (order, separators and authored escapes — including `RAW(...)` text —
3567/// preserved); then programmatic `query_params` entries whose key is absent
3568/// from the authored pairs, in declaration order with minimal RFC-3986
3569/// encoding (`%20`, never `+`). Authored keys always win — no duplication,
3570/// no override.
3571///
3572/// Returns `Ok(None)` when no query component is emitted: no pairs at all,
3573/// or a non-empty raw query whose every pair was consumed. A bare `?`
3574/// marker (`raw_query == Some("")`) always emits the query component.
3575fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
3576    let mut parts: Vec<String> = Vec::new();
3577    let mut authored_keys = std::collections::HashSet::new();
3578
3579    if let Some(raw) = config.raw_query.as_deref() {
3580        for (key, span) in raw_query_pairs(raw)? {
3581            authored_keys.insert(key.clone());
3582            if is_consumed_option(&key) {
3583                continue;
3584            }
3585            validate_raw_query_span(span)?;
3586            parts.push(span.to_string());
3587        }
3588    }
3589
3590    for (key, value) in &config.query_params {
3591        if !authored_keys.contains(key.as_str()) {
3592            parts.push(format!(
3593                "{}={}",
3594                encode_query_component(key),
3595                encode_query_component(value)
3596            ));
3597        }
3598    }
3599
3600    if parts.is_empty() && config.raw_query.as_deref() != Some("") {
3601        return Ok(None);
3602    }
3603    Ok(Some(parts.join("&")))
3604}
3605
3606/// Compose the outbound query when a `CamelHttpQuery` exchange header is
3607/// present (ADR-0071). `higher_precedence` — the endpoint query in the
3608/// base arm, the override URI's own query in the override arm — comes
3609/// first and wins any key collision; header pairs append verbatim for
3610/// absent keys only. An empty header leaves the higher-precedence query
3611/// unchanged (no additional `?` marker). Header spans are validated, not
3612/// re-encoded: a byte forbidden in a query component is a resolve error
3613/// naming the byte (Wave-A law).
3614fn merge_header_query(
3615    higher_precedence: Option<&str>,
3616    header_query: &str,
3617) -> Result<Option<String>, CamelError> {
3618    if header_query.is_empty() {
3619        return Ok(higher_precedence.map(str::to_string));
3620    }
3621    let mut parts: Vec<String> = Vec::new();
3622    let mut higher_keys = std::collections::HashSet::new();
3623    for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
3624        higher_keys.insert(key);
3625        parts.push(span.to_string());
3626    }
3627    for (key, span) in raw_query_pairs(header_query)? {
3628        validate_raw_query_span(span)?;
3629        if !higher_keys.contains(key.as_str()) {
3630            parts.push(span.to_string());
3631        }
3632    }
3633    if parts.is_empty() {
3634        return Ok(None);
3635    }
3636    Ok(Some(parts.join("&")))
3637}
3638
3639/// Bytes that may appear unescaped in a URI query component. RFC 3986
3640/// (`query = *( pchar / "/" / "?" )`) admits unreserved, sub-delims, `:`,
3641/// `@`, `/`, `?`, and `%` — with ONE deliberate exclusion from the RFC set:
3642/// the apostrophe (`'`, 0x27). reqwest's WHATWG URL parser re-encodes 0x27
3643/// to `%27` in the special-query percent-encode set (http/https), so an
3644/// authored apostrophe can never ride the wire verbatim; admitting it would
3645/// silently normalize authored bytes (rc-nmupb). Authors write `%27`
3646/// explicitly when they mean the byte on the wire. The WHATWG set's other
3647/// extras (`"`, `` ` ``, `<`, `>`) are already rejected here — they are not
3648/// RFC 3986 query-legal bytes, so no special exclusion is needed for them.
3649fn is_legal_query_byte(byte: u8) -> bool {
3650    matches!(byte,
3651        b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
3652        | b'-' | b'.' | b'_' | b'~'
3653        | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
3654        | b':' | b'@' | b'/' | b'?'
3655        | b'%')
3656}
3657
3658/// Reject an authored raw pair carrying a byte that is not legal in a query
3659/// component (e.g. literal space, `#`, non-ASCII). The serializer never
3660/// silently re-encodes operator-authored bytes: "byte-for-byte" is bounded
3661/// to wire-legal bytes, and the check fires before the resolved string
3662/// reaches any consumer (SSRF pre-check, diagnostics redaction).
3663fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
3664    for &byte in span.as_bytes() {
3665        if !is_legal_query_byte(byte) {
3666            return Err(CamelError::ProcessorError(format!(
3667                "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
3668            )));
3669        }
3670    }
3671    Ok(())
3672}
3673
3674/// Minimal RFC-3986 percent-encoding for one programmatic query component:
3675/// unreserved bytes pass through, every other byte encodes as uppercase
3676/// hex. A space encodes as `%20`, never `+`.
3677fn encode_query_component(component: &str) -> String {
3678    const HEX: &[u8; 16] = b"0123456789ABCDEF";
3679    let mut out = String::with_capacity(component.len());
3680    for &byte in component.as_bytes() {
3681        match byte {
3682            b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3683                out.push(byte as char);
3684            }
3685            _ => {
3686                out.push('%');
3687                out.push(HEX[(byte >> 4) as usize] as char);
3688                out.push(HEX[(byte & 0x0f) as usize] as char);
3689            }
3690        }
3691    }
3692    out
3693}
3694
3695/// Redact credentials from a URL before it reaches logs or error values
3696/// (ADR-0051 redact-by-construction). Masks userinfo (`user:pass@`) and
3697/// the query string (which commonly carries API keys/tokens). Host and
3698/// path stay visible for diagnosability. Fragments are never echoed: a
3699/// fragment (OAuth2 callback tokens such as `#access_token=...`) is
3700/// dropped and replaced with the `#[redacted]` sentinel in both the
3701/// parsed arm and the unparseable arm. Fail-closed: when the parse fails
3702/// and any authority window contains `@`, only the `[redacted]`
3703/// sentinel is returned. Every authority window is scanned: windows are
3704/// enumerated over maximal runs of `/` and `\` — pure-slash runs of two
3705/// or more characters, backslash-bearing runs only behind an RFC 3986
3706/// scheme prefix (see [`camel_api::redact`] for the canonical window
3707/// rule) — each window starts immediately after the run (so evaders like
3708/// `scheme:////user:pass@evil/` cannot hide a `@` behind a slash run)
3709/// and ends at the next `/`, `?`, or `#`; scanning all windows keeps
3710/// later `//user:pass@` substrings from hiding behind a benign first
3711/// window.
3712///
3713/// The parsed arm keeps `url::Url::parse` (the authority can only be
3714/// judged by the parser) and masks the real authority accessors, then
3715/// delegates wholesale to the canonical string surgery in
3716/// [`camel_api::redact::redact_url`]: rust-url can park later-window
3717/// userinfo bytes in the path (`https://h//user:pass@evil/`), and the
3718/// canonical helper owns window masking, `?`/`#` sentinel composition
3719/// (one per distinct introducer, first-occurrence order), and the
3720/// 256-byte UTF-8 cap. The unparseable arm delegates to
3721/// [`camel_api::redact::redact_url_fail_closed`].
3722pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3723    match url::Url::parse(raw) {
3724        Ok(mut u) => {
3725            // Fail closed when an authority marker was accepted but no
3726            // host was stored: userinfo-shaped bytes can hide in the path
3727            // behind the marker, and empty-host schemes (`file:///us@r/x`,
3728            // `unix:///@socket`) can put a `@` in that window too. Such
3729            // inputs are sentineled wholesale — deliberate fail-closed
3730            // over-redaction per ADR-0051.
3731            if !u.cannot_be_a_base()
3732                && u.host_str().is_none()
3733                && camel_api::redact::window_has_at_sign(raw)
3734            {
3735                return "[redacted]".to_string();
3736            }
3737            if !u.username().is_empty() || u.password().is_some() {
3738                let _ = u.set_username("***");
3739                let _ = u.set_password(None);
3740            }
3741            // Query and fragment stay on the rendered URL; the canonical
3742            // redactor drops them and composes the sentinels.
3743            let s = u.to_string();
3744            camel_api::redact::redact_url(&s)
3745        }
3746        Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3747    }
3748}
3749
3750/// Maximum bytes of an upstream error response body embedded into
3751/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
3752/// malicious or compromised upstream), so it is truncated and lossy-decoded to
3753/// bound log injection / DLQ payload size.
3754const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3755
3756fn truncate_error_body(body: &[u8]) -> String {
3757    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3758        String::from_utf8_lossy(body).into_owned()
3759    } else {
3760        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3761        s.push_str("...[truncated]");
3762        s
3763    }
3764}
3765
3766impl HttpProducer {
3767    /// Whether the HTTP method is entity-enclosing (may carry a request
3768    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
3769    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
3770    /// §9.3.1/§9.3.2).
3771    fn is_entity_enclosing(method: &str) -> bool {
3772        matches!(method, "POST" | "PUT" | "PATCH")
3773    }
3774}
3775
3776impl Service<Exchange> for HttpProducer {
3777    type Response = Exchange;
3778    type Error = CamelError;
3779    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3780
3781    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3782        Poll::Ready(Ok(()))
3783    }
3784
3785    fn call(&mut self, exchange: Exchange) -> Self::Future {
3786        let config = self.config.clone();
3787        let shared_client = self.client.clone();
3788        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3789        let http_config = self.http_config.clone();
3790        let component_metrics = self.runtime.component_metrics();
3791
3792        Box::pin(async move {
3793            let mut exchange = exchange;
3794            let outcome = async {
3795                let method_str = HttpProducer::resolve_method(&exchange, &config);
3796                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
3797                // and PATCH may carry a request body. Any other resolved method
3798                // drops the exchange body before the request is built (Apache
3799                // Camel `HttpMethods` parity).
3800                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3801                let url = HttpProducer::resolve_url(&exchange, &config)?;
3802
3803                // SECURITY: Validate URL for SSRF
3804                ssrf::validate_url_for_ssrf(&url, &config)?;
3805
3806                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
3807                // (L-H2). When the URL uses a domain name and SSRF protection is active,
3808                // reuse the endpoint's cached DNS-pinned client for that validated
3809                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
3810                // repeated requests keep one connection pool without re-resolving DNS.
3811                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
3812                // URLs use the endpoint's unpinned shared client.
3813                let resolved = ssrf::resolve_initial_url_for_ssrf(
3814                    &url,
3815                    config.allow_internal,
3816                    config.allow_cleartext,
3817                )
3818                .await?;
3819                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3820                    // A failed pinned build (strict fail-closed material
3821                    // conflict) propagates into the request error path —
3822                    // no degraded client is built or served.
3823                    pinned_cache
3824                        .get_or_build(host.as_str(), addrs, || {
3825                            build_client(&http_config, Some((host.as_str(), addrs)))
3826                        })
3827                        .await?
3828                } else {
3829                    shared_client.clone()
3830                };
3831
3832                debug!(
3833                    correlation_id = %exchange.correlation_id(),
3834                    method = %method_str,
3835                    url = %redact_url_for_diagnostics(&url),
3836                    "HTTP request"
3837                );
3838
3839                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3840                    CamelError::ProcessorError(format!(
3841                        "Invalid HTTP method '{}': {}",
3842                        method_str, e
3843                    ))
3844                })?;
3845
3846                // Collect headers for potential redirect replay
3847                let mut collected_headers: Vec<(
3848                    reqwest::header::HeaderName,
3849                    reqwest::header::HeaderValue,
3850                )> = Vec::new();
3851
3852                if let Some(user_agent) = &config.user_agent
3853                    && !config.bridge_endpoint
3854                {
3855                    match constructed_header("user-agent", user_agent) {
3856                        Ok((_, val)) => {
3857                            collected_headers.push((reqwest::header::USER_AGENT, val));
3858                        }
3859                        Err(drop) => debug!(
3860                            correlation_id = %exchange.correlation_id(),
3861                            header = %drop.name,
3862                            "outbound header dropped: {}",
3863                            drop.reason
3864                        ),
3865                    }
3866                }
3867
3868                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
3869                #[cfg(feature = "otel")]
3870                let should_inject_otel = !config.bridge_endpoint;
3871                #[cfg(feature = "otel")]
3872                if should_inject_otel {
3873                    let mut otel_headers = HashMap::new();
3874                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3875                    for (k, v) in otel_headers {
3876                        match constructed_header(&k, &v) {
3877                            Ok((name, val)) => collected_headers.push((name, val)),
3878                            Err(drop) => debug!(
3879                                correlation_id = %exchange.correlation_id(),
3880                                header = %drop.name,
3881                                "outbound header dropped: {}",
3882                                drop.reason
3883                            ),
3884                        }
3885                    }
3886                }
3887
3888                let conn_tokens = header_policy::connection_tokens(
3889                    exchange
3890                        .input
3891                        .headers
3892                        .iter()
3893                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3894                        .filter_map(|(_, v)| v.as_str()),
3895                );
3896
3897                let outbound = select_outbound_headers(
3898                    &exchange.input.headers,
3899                    &config.skip_request_headers,
3900                    &conn_tokens,
3901                );
3902                for drop in &outbound.drops {
3903                    if let Some(value_kind) = drop.value_kind {
3904                        debug!(
3905                            correlation_id = %exchange.correlation_id(),
3906                            header = %drop.name,
3907                            value_kind = value_kind,
3908                            "outbound header dropped: {}",
3909                            drop.reason
3910                        );
3911                    } else {
3912                        debug!(
3913                            correlation_id = %exchange.correlation_id(),
3914                            header = %drop.name,
3915                            "outbound header dropped: {}",
3916                            drop.reason
3917                        );
3918                    }
3919                }
3920                collected_headers.extend(outbound.accepted);
3921
3922                // Auth headers
3923                if !config.bridge_endpoint {
3924                    match &config.auth {
3925                        HttpAuth::None => {}
3926                        HttpAuth::Basic { username, password } => {
3927                            use base64::Engine;
3928                            // allow-secret: credentials combined for base64 Basic auth header
3929                            let credentials = format!("{username}:{password}");
3930                            let encoded =
3931                                base64::engine::general_purpose::STANDARD.encode(credentials);
3932                            // Base64 output is always header-safe; the guard is kept
3933                            // for uniformity with Bearer.
3934                            match constructed_header("authorization", &format!("Basic {encoded}")) {
3935                                Ok((_, val)) => {
3936                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3937                                }
3938                                Err(drop) => debug!(
3939                                    correlation_id = %exchange.correlation_id(),
3940                                    header = %drop.name,
3941                                    "outbound header dropped: {}",
3942                                    drop.reason
3943                                ),
3944                            }
3945                        }
3946                        HttpAuth::Bearer { token } => {
3947                            // allow-secret: Bearer token in Authorization header
3948                            let bearer = format!("Bearer {token}");
3949                            match constructed_header("authorization", &bearer) {
3950                                Ok((_, val)) => {
3951                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3952                                }
3953                                Err(drop) => debug!(
3954                                    correlation_id = %exchange.correlation_id(),
3955                                    header = %drop.name,
3956                                    "outbound header dropped: {}",
3957                                    drop.reason
3958                                ),
3959                            }
3960                        }
3961                    }
3962
3963                    if config.connection_close {
3964                        collected_headers.push((
3965                            reqwest::header::CONNECTION,
3966                            reqwest::header::HeaderValue::from_static("close"),
3967                        ));
3968                    }
3969                }
3970
3971                // Materialize body
3972                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3973                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3974                    if suppress_body {
3975                        // A stream body dropped under a non-entity-enclosing
3976                        // method always warns (its emptiness is unknowable) and
3977                        // stays consumed (mem::take). The stream attach arm below
3978                        // still runs its outer flag check, but the inner `if let
3979                        // Body::Stream` re-match fails on the now-Empty body, so
3980                        // no stream is attached and no AlreadyConsumed error can
3981                        // fire.
3982                        std::mem::take(&mut exchange.input.body);
3983                        // log-policy: handler-owned
3984                        tracing::warn!(
3985                            correlation_id = %exchange.correlation_id(),
3986                            method = %method_str,
3987                            "dropping request body for non-entity-enclosing HTTP method"
3988                        );
3989                    }
3990                    None // Streams can't be replayed on redirect
3991                } else {
3992                    let body = std::mem::take(&mut exchange.input.body);
3993                    let bytes = body.into_bytes(config.max_body_size).await?;
3994                    if bytes.is_empty() {
3995                        // Empty body: nothing to send and nothing to warn about.
3996                        None
3997                    } else if suppress_body {
3998                        // log-policy: handler-owned
3999                        tracing::warn!(
4000                            correlation_id = %exchange.correlation_id(),
4001                            method = %method_str,
4002                            "dropping request body for non-entity-enclosing HTTP method"
4003                        );
4004                        None
4005                    } else {
4006                        Some(bytes.to_vec())
4007                    }
4008                };
4009
4010                let response = if config.follow_redirects && !is_stream_body {
4011                    // Use manual redirect loop with per-hop SSRF validation.
4012                    // `client` is the pinned-or-shared binding for the initial
4013                    // request (a hostname initial request keeps its DNS-pinned
4014                    // client); `shared_client` is the unpinned endpoint client
4015                    // reused by IP-literal redirect hops.
4016                    ssrf::send_with_ssrf_safe_redirects(
4017                        &client,
4018                        &shared_client,
4019                        &pinned_cache,
4020                        &http_config,
4021                        &config,
4022                        method,
4023                        &url,
4024                        collected_headers,
4025                        materialized_body,
4026                        config.max_redirects,
4027                        config.response_timeout,
4028                    )
4029                    .await?
4030                } else {
4031                    // Direct send (no redirect following, or streaming body)
4032                    let mut request = client.request(method, &url);
4033
4034                    if let Some(timeout) = config.response_timeout {
4035                        request = request.timeout(timeout);
4036                    }
4037
4038                    for (name, value) in &collected_headers {
4039                        request = request.header(name, value);
4040                    }
4041
4042                    if is_stream_body {
4043                        if let Body::Stream(ref s) = exchange.input.body {
4044                            let mut stream_lock = s.stream.lock().await;
4045                            if let Some(stream) = stream_lock.take() {
4046                                request = request.body(reqwest::Body::wrap_stream(stream));
4047                            } else {
4048                                return Err(CamelError::AlreadyConsumed);
4049                            }
4050                        }
4051                    } else if let Some(ref body_bytes) = materialized_body {
4052                        request = request.body(body_bytes.clone());
4053                    }
4054
4055                    request.send().await.map_err(|e| {
4056                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
4057                    })?
4058                };
4059
4060                let status_code = response.status().as_u16();
4061                let status_text = response
4062                    .status()
4063                    .canonical_reason()
4064                    .unwrap_or("Unknown")
4065                    .to_string();
4066
4067                for (key, value) in response.headers() {
4068                    if config
4069                        .skip_response_headers
4070                        .iter()
4071                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
4072                    {
4073                        continue;
4074                    }
4075                    if let Ok(val_str) = value.to_str() {
4076                        exchange.input.set_header(
4077                            title_case_header(key.as_str()),
4078                            serde_json::Value::String(val_str.to_string()),
4079                        );
4080                    }
4081                }
4082
4083                exchange.input.set_header(
4084                    "CamelHttpResponseCode",
4085                    serde_json::Value::Number(status_code.into()),
4086                );
4087                exchange.input.set_header(
4088                    "CamelHttpResponseText",
4089                    serde_json::Value::String(status_text.clone()),
4090                );
4091
4092                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
4093                let read_timeout = Duration::from_millis(config.read_timeout_ms);
4094                let response_body = tokio::time::timeout(read_timeout, async {
4095                    // Check Content-Length header before allocating
4096                    if let Some(content_len) = response.content_length()
4097                        && content_len > config.max_response_bytes as u64
4098                    {
4099                        return Err(CamelError::ProcessorError(format!(
4100                            "Response body too large: {} bytes exceeds limit of {} bytes",
4101                            content_len, config.max_response_bytes
4102                        )));
4103                    }
4104                    // Use bytes_stream() for lazy streaming with size guard
4105                    use futures::TryStreamExt;
4106                    let mut stream = response.bytes_stream();
4107                    let mut total: usize = 0;
4108                    let mut collected = Vec::new();
4109                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
4110                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
4111                    })? {
4112                        total += chunk.len();
4113                        if total > config.max_response_bytes {
4114                            return Err(CamelError::ProcessorError(format!(
4115                                "Response body too large: {} bytes exceeds limit of {} bytes",
4116                                total, config.max_response_bytes
4117                            )));
4118                        }
4119                        collected.push(chunk);
4120                    }
4121                    let mut result = bytes::BytesMut::with_capacity(total);
4122                    for chunk in collected {
4123                        result.extend_from_slice(&chunk);
4124                    }
4125                    Ok::<bytes::Bytes, CamelError>(result.freeze())
4126                })
4127                .await
4128                .map_err(|_| {
4129                    CamelError::ProcessorError(format!(
4130                        "Read timeout after {}ms",
4131                        config.read_timeout_ms
4132                    ))
4133                })??;
4134
4135                if config.throw_exception_on_failure
4136                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
4137                {
4138                    return Err(CamelError::HttpOperationFailed {
4139                        method: method_str,
4140                        // ADR-0051 redact-by-construction: never embed
4141                        // userinfo/query credentials in the error value.
4142                        url: redact_url_for_diagnostics(&url),
4143                        status_code,
4144                        status_text,
4145                        response_body: Some(truncate_error_body(&response_body)),
4146                    });
4147                }
4148
4149                if !response_body.is_empty() {
4150                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
4151                }
4152
4153                debug!(
4154                    correlation_id = %exchange.correlation_id(),
4155                    status = status_code,
4156                    url = %redact_url_for_diagnostics(&url),
4157                    "HTTP response"
4158                );
4159                Ok(exchange)
4160            }
4161            .await;
4162            // ("http","request") facade (dashboard-observability 4.3): the
4163            // request boundary is the full client round-trip — SSRF checks,
4164            // send, response read, and (with throwExceptionOnFailure) the
4165            // status gate. http runs no retry_async and the producer
4166            // previously emitted nothing, so no label collides with
4167            // e:http:request.
4168            component_metrics.observe("http", "request", outcome.is_err());
4169            outcome
4170        })
4171    }
4172}
4173
4174/// Serializes tests that mutate or depend on the global `ServerRegistry`.
4175///
4176/// `ServerRegistry::global()` is a process-wide singleton that persists
4177/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
4178/// with another test that has a live server on a fixed port (e.g. 9991),
4179/// the registry entry is removed while the OS socket is still bound, so
4180/// the next `get_or_spawn` call on that port fails with "Address already
4181/// in use". This mutex does not give blanket protection by itself. It
4182/// helps only where every participant follows the mutex law: the
4183/// consumer-test readiness helper holds it from `stage_listener` until
4184/// readiness-complete (http-test-harness spec, requirement
4185/// "Registry-mutation serialization during setup"), and each `reset()`
4186/// caller takes it before the reset.
4187#[cfg(test)]
4188pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
4189
4190/// Poison-recovering acquire of REGISTRY_TEST_MUTEX (httpflake).
4191///
4192/// The mutex guards test SERIALIZATION only - the registry own data is
4193/// protected by its inner lock - so a sibling test that panics while
4194/// holding the guard must not poison the mutex and cascade failures
4195/// into every other holder. Recovery via into_inner is therefore safe
4196/// and keeps one failing test failing as ONE test.
4197#[cfg(test)]
4198pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
4199    REGISTRY_TEST_MUTEX
4200        .lock()
4201        .unwrap_or_else(|poisoned| poisoned.into_inner())
4202}
4203
4204/// Serializes tests that mutate (or assert on) the process-global
4205/// SSL_CERT_FILE/SSL_CERT_DIR CA-probe env vars (rc-3j4mq). Poison-
4206/// recovering for the same reason as REGISTRY_TEST_MUTEX.
4207#[cfg(test)]
4208static CA_STORE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
4209
4210#[cfg(test)]
4211pub(crate) fn lock_ca_store_test_mutex() -> std::sync::MutexGuard<'static, ()> {
4212    CA_STORE_TEST_MUTEX
4213        .lock()
4214        .unwrap_or_else(|poisoned| poisoned.into_inner())
4215}
4216
4217/// Map a pipeline error to an HTTP reply.
4218///
4219/// Extracted from the inline `match` in `dispatch_handler` for unit
4220/// testability (rc-1dk4). Client-fault errors map to their 4xx codes
4221/// with a structured JSON error body: `TypeConversionFailed`/
4222/// `ValidationError` → 400, `UnsupportedMediaType` → 415 (media
4223/// negotiation gate, REST lowering), `NotAcceptable` → 406 (same
4224/// gate); `Unauthenticated`/`Unauthorized` keep their `401`/`403`
4225/// mappings; all other errors map to `500 Internal Server Error`.
4226fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
4227    match e {
4228        CamelError::Unauthenticated(msg) => {
4229            tracing::warn!(error = %msg, path = %path, "Authentication failed");
4230            HttpReply {
4231                status: 401,
4232                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
4233                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
4234            }
4235        }
4236        CamelError::Unauthorized(msg) => {
4237            tracing::warn!(error = %msg, path = %path, "Authorization failed");
4238            HttpReply {
4239                status: 403,
4240                headers: vec![],
4241                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
4242            }
4243        }
4244        CamelError::TypeConversionFailed(msg) => {
4245            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
4246            json_error_reply(400, "bad_request", msg)
4247        }
4248        CamelError::ValidationError(msg) => {
4249            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
4250            json_error_reply(400, "validation_error", msg)
4251        }
4252        CamelError::ConsumerStopping => {
4253            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
4254            HttpReply {
4255                status: 503,
4256                headers: vec![],
4257                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
4258            }
4259        }
4260        CamelError::UnsupportedMediaType { consumed, declared } => {
4261            tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
4262            json_error_reply(
4263                415,
4264                "unsupported_media_type",
4265                format!("consumed {consumed}, declared {declared}"),
4266            )
4267        }
4268        CamelError::NotAcceptable { accept, produced } => {
4269            tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
4270            json_error_reply(
4271                406,
4272                "not_acceptable",
4273                format!("accept {accept}, produced {produced}"),
4274            )
4275        }
4276        e => {
4277            // log-policy: handler-owned
4278            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
4279            HttpReply {
4280                status: 500,
4281                headers: vec![],
4282                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
4283            }
4284        }
4285    }
4286}
4287
4288/// Build a JSON error reply with the given status, error code, and message.
4289///
4290/// Shared by the `TypeConversionFailed`/`ValidationError` (400),
4291/// `UnsupportedMediaType` (415), and `NotAcceptable` (406) arms of
4292/// `pipeline_error_to_reply` so the four replies cannot drift apart. The
4293/// `unwrap_or_else(|_| "{}".to_string())` fallback keeps the reply valid
4294/// JSON even if serialization fails.
4295fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
4296    let body = serde_json::to_string(&serde_json::json!({
4297        "error": code,
4298        "message": message,
4299    }))
4300    .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
4301    HttpReply {
4302        status,
4303        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
4304        body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
4305    }
4306}
4307
4308/// Lowercase kind name for a JSON value, used in drop diagnostics so log
4309/// readers see *why* a header had no scalar string form without the value
4310/// itself ever entering diagnostics.
4311const fn json_value_kind(v: &serde_json::Value) -> &'static str {
4312    match v {
4313        serde_json::Value::Null => "null",
4314        serde_json::Value::Bool(_) => "bool",
4315        serde_json::Value::Number(_) => "number",
4316        serde_json::Value::String(_) => "string",
4317        serde_json::Value::Array(_) => "array",
4318        serde_json::Value::Object(_) => "object",
4319    }
4320}
4321
4322/// Scalar string form of a JSON value: strings pass through, `Number` and
4323/// `Bool` are stringified, everything else has no single-value form.
4324/// Shared by the consumer reply finaliser and the producer outbound filter
4325/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
4326fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
4327    match v {
4328        serde_json::Value::String(s) => Some(s.clone()),
4329        serde_json::Value::Number(n) => Some(n.to_string()),
4330        serde_json::Value::Bool(b) => Some(b.to_string()),
4331        _ => None,
4332    }
4333}
4334
4335/// Select the HTTP response headers emitted by the consumer reply finaliser
4336/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
4337/// `dispatch_handler` for unit testability.
4338///
4339/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
4340/// server-owned headers, plus `content-length`/`content-type` (re-derived),
4341/// and any header named by a `Connection` token. Scalar non-string values
4342/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
4343/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
4344/// and arrays have no single-value form and are dropped. Every drop is
4345/// logged at DEBUG with the header name and reason — names only, never
4346/// values, so credentials cannot leak into diagnostics (ADR-0051).
4347/// Appends a single `Content-Type` from `user_content_type` falling back to
4348/// `inferred_content_type` when either is present.
4349fn select_response_headers(
4350    headers: &HashMap<String, serde_json::Value>,
4351    user_content_type: Option<String>,
4352    inferred_content_type: Option<String>,
4353) -> Vec<(String, String)> {
4354    let conn_tokens = header_policy::connection_tokens(
4355        headers
4356            .iter()
4357            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
4358            .filter_map(|(_, v)| v.as_str()),
4359    );
4360    let mut selected: Vec<(String, String)> = Vec::new();
4361    for (k, v) in headers {
4362        if k.starts_with("Camel") {
4363            debug!(header = %k, "reply header dropped: Camel namespace");
4364            continue;
4365        }
4366        if header_policy::excluded_response(k, &conn_tokens) {
4367            debug!(header = %k, "reply header dropped: emission policy");
4368            continue;
4369        }
4370        match scalar_string_form(v) {
4371            Some(s) => selected.push((k.clone(), s)),
4372            None => debug!(
4373                header = %k,
4374                value_kind = json_value_kind(v),
4375                "reply header dropped: no scalar string form"
4376            ),
4377        }
4378    }
4379    if let Some(ct) = user_content_type.or(inferred_content_type) {
4380        selected.push(("Content-Type".to_string(), ct));
4381    }
4382    selected
4383}
4384
4385/// One outbound header drop: the exchange header name, a stable reason
4386/// string, and — when the drop was caused by the value having no scalar
4387/// string form — the JSON value kind. Names and kinds only, never values
4388/// (ADR-0051).
4389#[derive(Debug)]
4390struct OutboundHeaderDrop<'a> {
4391    name: &'a str,
4392    reason: &'static str,
4393    value_kind: Option<&'static str>,
4394}
4395
4396/// Outbound exchange-header selection result: headers accepted for the
4397/// wire plus drop records for call-site DEBUG logging.
4398struct OutboundHeaderSelection<'a> {
4399    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
4400    drops: Vec<OutboundHeaderDrop<'a>>,
4401}
4402
4403/// Select the exchange headers the HTTP producer forwards on the outbound
4404/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
4405/// `HttpProducer::call` for unit testability.
4406///
4407/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
4408/// hop-by-hop/framing and connection-token-named headers excluded by the
4409/// outbound emission policy, and headers whose name or stringified value
4410/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
4411/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
4412/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
4413/// and arrays have no single-value form and are dropped. Drops are returned
4414/// rather than logged so the call site can attach the correlation id; log
4415/// consumers see names and kinds only, never values (ADR-0051).
4416fn select_outbound_headers<'a>(
4417    headers: &'a HashMap<String, serde_json::Value>,
4418    skip_request_headers: &[String],
4419    conn_tokens: &[String],
4420) -> OutboundHeaderSelection<'a> {
4421    let mut accepted = Vec::new();
4422    let mut drops = Vec::new();
4423    for (key, value) in headers {
4424        if key.starts_with("Camel") {
4425            drops.push(OutboundHeaderDrop {
4426                name: key,
4427                reason: "Camel namespace",
4428                value_kind: None,
4429            });
4430            continue;
4431        }
4432        if skip_request_headers
4433            .iter()
4434            .any(|h| h.eq_ignore_ascii_case(key))
4435        {
4436            drops.push(OutboundHeaderDrop {
4437                name: key,
4438                reason: "skip_request_headers",
4439                value_kind: None,
4440            });
4441            continue;
4442        }
4443        if header_policy::excluded_outbound(key, conn_tokens) {
4444            drops.push(OutboundHeaderDrop {
4445                name: key,
4446                reason: "outbound emission policy",
4447                value_kind: None,
4448            });
4449            continue;
4450        }
4451        let Some(val_str) = scalar_string_form(value) else {
4452            drops.push(OutboundHeaderDrop {
4453                name: key,
4454                reason: "no scalar string form",
4455                value_kind: Some(json_value_kind(value)),
4456            });
4457            continue;
4458        };
4459        match constructed_header(key, &val_str) {
4460            Ok((name, val)) => accepted.push((name, val)),
4461            Err(drop) => drops.push(drop),
4462        }
4463    }
4464    OutboundHeaderSelection { accepted, drops }
4465}
4466
4467/// Construct a wire-ready `(HeaderName, HeaderValue)` pair for one outbound
4468/// header, or a drop record when the name or value fails construction
4469/// (rc-jbs1v). Drop records carry name and reason only, never values
4470/// (ADR-0051).
4471fn constructed_header<'a>(
4472    name: &'a str,
4473    value: &str,
4474) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
4475    let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
4476        Ok(header_name) => header_name,
4477        Err(_) => {
4478            return Err(OutboundHeaderDrop {
4479                name,
4480                reason: "invalid header name",
4481                value_kind: None,
4482            });
4483        }
4484    };
4485    let header_value = match reqwest::header::HeaderValue::from_str(value) {
4486        Ok(header_value) => header_value,
4487        Err(_) => {
4488            return Err(OutboundHeaderDrop {
4489                name,
4490                reason: "invalid header value",
4491                value_kind: None,
4492            });
4493        }
4494    };
4495    Ok((header_name, header_value))
4496}
4497
4498#[cfg(test)]
4499mod tests {
4500    use camel_component_api::test_support::NoopRuntimeObservability;
4501
4502    #[test]
4503    fn test_metadata_authmethod_enum_covers_runtime_vocab() {
4504        // The lint's R-URI-known kind check validates `authMethod` values
4505        // against the metadata Enum variants; the runtime accepts `none`
4506        // (case-insensitive, parse_auth_from_params → HttpAuth::None), so
4507        // the variant list must cover it or camel lint false-positives on
4508        // runtime-legal routes (rc-68q6 review finding).
4509        use camel_api::component_metadata::OptionKind;
4510        let meta = super::HttpEndpointConfig::metadata();
4511        let auth = meta
4512            .uri_options
4513            .iter()
4514            .find(|o| o.name == "authMethod")
4515            .expect("authMethod option must exist");
4516        let OptionKind::Enum(variants) = &auth.kind else {
4517            panic!("authMethod kind must be Enum; got {:?}", auth.kind);
4518        };
4519        for value in ["None", "Basic", "Bearer"] {
4520            assert!(
4521                variants.iter().any(|v| v.eq_ignore_ascii_case(value)),
4522                "metadata authMethod variants must cover runtime-accepted `{value}`; got {variants:?}"
4523            );
4524        }
4525    }
4526
4527    // Producer/consumer tests drive the component-ops facade on every
4528    // call (dashboard-observability 4.3), so even non-observability tests
4529    // must supply a collector-returning runtime — Noop everywhere.
4530    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4531        std::sync::Arc::new(NoopRuntimeObservability)
4532    }
4533    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4534        std::sync::Arc::new(NoopRuntimeObservability)
4535    }
4536    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4537        std::sync::Arc::new(NoopRuntimeObservability)
4538    }
4539
4540    use super::*;
4541    use crate::config::TlsConfig;
4542    use crate::rest_match::PathSegment;
4543    use crate::tls_harness::*;
4544    use camel_component_api::{Message, NoOpComponentContext};
4545    use std::sync::Arc;
4546    use std::time::Duration;
4547
4548    fn test_producer_ctx() -> ProducerContext {
4549        ProducerContext::new()
4550    }
4551
4552    // -----------------------------------------------------------------------
4553    // Security: credential redaction (audit 2026-08-31, finding F3-1)
4554    // -----------------------------------------------------------------------
4555
4556    /// ADR-0076: bare `host` log fields route through the canonical
4557    /// [`camel_api::redact::redact_host`] (bd rc-8bxeo promoted the
4558    /// crate-local twin — `redact_url_for_diagnostics` never opens an
4559    /// authority window on a base-less string). Thin local pin — the
4560    /// full matrix lives in camel-api's
4561    /// `redact_host_masks_userinfo_keeps_clean_hosts`.
4562    #[test]
4563    fn canonical_redact_host_pinned() {
4564        assert_eq!(
4565            camel_api::redact::redact_host("host.example:8080"),
4566            "host.example:8080"
4567        );
4568        assert_eq!(camel_api::redact::redact_host("a@b@c"), "***@c");
4569    }
4570
4571    #[test]
4572    fn redact_url_drops_oauth2_fragment_access_token() {
4573        let redacted =
4574            redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
4575        assert!(
4576            !redacted.contains("SECRET"),
4577            "fragment access token leaked: {redacted}"
4578        );
4579        assert!(
4580            !redacted.contains("access_token"),
4581            "fragment key leaked: {redacted}"
4582        );
4583        assert!(
4584            redacted.ends_with("#[redacted]"),
4585            "fragment must be replaced with the sentinel: {redacted}"
4586        );
4587    }
4588
4589    #[test]
4590    fn redact_url_drops_oauth2_fragment_id_token() {
4591        let redacted =
4592            redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
4593        assert!(
4594            !redacted.contains("eyJhbG"),
4595            "id token payload leaked: {redacted}"
4596        );
4597        assert!(
4598            !redacted.contains("id_token"),
4599            "id token key leaked: {redacted}"
4600        );
4601        assert!(
4602            !redacted.contains("SECRET"),
4603            "id token signature leaked: {redacted}"
4604        );
4605        assert!(
4606            redacted.ends_with("#[redacted]"),
4607            "fragment must be replaced with the sentinel: {redacted}"
4608        );
4609    }
4610
4611    #[test]
4612    fn redact_url_drops_generic_fragment_kv() {
4613        let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
4614        assert!(
4615            !redacted.contains("abc123"),
4616            "fragment value leaked: {redacted}"
4617        );
4618        assert!(
4619            !redacted.contains("session="),
4620            "fragment key leaked: {redacted}"
4621        );
4622        assert!(
4623            redacted.contains("#[redacted]"),
4624            "fragment must be replaced with the sentinel: {redacted}"
4625        );
4626    }
4627
4628    #[test]
4629    fn redact_url_query_and_fragment_sentinels_compose() {
4630        let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
4631        assert_eq!(
4632            redacted, "https://h.example/p?[redacted]#[redacted]",
4633            "query and fragment sentinels must compose: {redacted}"
4634        );
4635    }
4636
4637    #[test]
4638    fn redact_url_drops_benign_fragment_too() {
4639        // Fragments never reach the wire, so nothing in them is diagnostic:
4640        // strictest-wins drops benign fragments too.
4641        let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
4642        assert_eq!(
4643            redacted, "https://h.example/docs#[redacted]",
4644            "benign fragment must still be dropped: {redacted}"
4645        );
4646    }
4647
4648    #[test]
4649    fn redact_url_unparseable_fragment_credentials_dropped() {
4650        let raw = "ht tps://app.example/cb#access_token=SECRET";
4651        assert!(
4652            url::Url::parse(raw).is_err(),
4653            "fixture must be unparseable: {raw}"
4654        );
4655        let redacted = redact_url_for_diagnostics(raw);
4656        assert!(
4657            !redacted.contains("SECRET"),
4658            "unparseable fragment token leaked: {redacted}"
4659        );
4660        assert!(
4661            !redacted.contains("access_token"),
4662            "unparseable fragment bytes leaked: {redacted}"
4663        );
4664        assert!(
4665            redacted.contains("#[redacted]"),
4666            "unparseable fragment must end in the sentinel: {redacted}"
4667        );
4668    }
4669
4670    #[test]
4671    fn redact_url_double_slash_evader_sentinel() {
4672        // url::Url::parse accepts this (empty host allowed for non-special
4673        // schemes), parking userinfo-shaped bytes in the opaque path.
4674        let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
4675        assert_eq!(
4676            redacted, "[redacted]",
4677            "double-slash evader must fail closed: {redacted}"
4678        );
4679    }
4680
4681    #[test]
4682    fn redact_url_triple_slash_evader_sentinel() {
4683        let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
4684        assert_eq!(
4685            redacted, "[redacted]",
4686            "triple-slash evader must fail closed: {redacted}"
4687        );
4688    }
4689
4690    #[test]
4691    fn redact_url_bare_protocol_relative_userinfo_sentinel() {
4692        let redacted = redact_url_for_diagnostics("//user:pass@evil");
4693        assert_eq!(
4694            redacted, "[redacted]",
4695            "protocol-relative userinfo must fail closed: {redacted}"
4696        );
4697    }
4698
4699    #[test]
4700    fn redact_url_empty_host_userinfo_sentinel() {
4701        // url::Url::parse rejects this with EmptyHost; the failure arm must
4702        // fail closed without panicking on the empty host.
4703        let redacted = redact_url_for_diagnostics("scheme://user@");
4704        assert_eq!(
4705            redacted, "[redacted]",
4706            "empty-host userinfo must fail closed: {redacted}"
4707        );
4708    }
4709
4710    #[test]
4711    fn redact_url_unparseable_slash_run_evader_sentinel() {
4712        // Unlike `scheme:////user:pass@evil/` (parses Ok, host=None, and
4713        // hits the parsed-arm guard), the space in the scheme forces the
4714        // parse to fail, driving the failure arm's slash-run skip directly.
4715        let raw = "schem e:////user:pass@evil/";
4716        assert!(
4717            url::Url::parse(raw).is_err(),
4718            "fixture must be unparseable: {raw}"
4719        );
4720        let redacted = redact_url_for_diagnostics(raw);
4721        assert_eq!(
4722            redacted, "[redacted]",
4723            "unparseable slash-run evader must fail closed: {redacted}"
4724        );
4725    }
4726
4727    #[test]
4728    fn redact_url_unparseable_later_window_userinfo_sentinel() {
4729        // The first `//` window ("ho st") carries no `@`, but a later
4730        // `//user:pass@evil/` window does. The scan must consider every
4731        // `//` window, not just the first, or the credentials echo.
4732        let raw = "http://ho st/a//user:pass@evil/";
4733        assert!(
4734            url::Url::parse(raw).is_err(),
4735            "fixture must be unparseable: {raw}"
4736        );
4737        let redacted = redact_url_for_diagnostics(raw);
4738        assert_eq!(
4739            redacted, "[redacted]",
4740            "userinfo in a later // window must fail closed: {redacted}"
4741        );
4742    }
4743
4744    #[test]
4745    fn redact_url_parsed_later_window_userinfo_masked() {
4746        // rust-url accepts this with host `h` and parks the userinfo bytes
4747        // in the path, so the accessor mask never fires. The parsed arm
4748        // must apply the same window-masking surgery as the string-based
4749        // redactors or the later window renders verbatim.
4750        let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4751        assert!(
4752            !redacted.contains("user:pass"),
4753            "parsed later-window userinfo leaked: {redacted}"
4754        );
4755        assert!(
4756            redacted.contains("h//***@evil/"),
4757            "later window must be masked in place: {redacted}"
4758        );
4759    }
4760
4761    #[test]
4762    fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4763        // Real userinfo is masked by the accessor step; the window surgery
4764        // on the rendered string must not double-mask it (`***@h` stays),
4765        // and the later `x@y` path window must still be masked.
4766        let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4767        assert!(
4768            redacted.contains("***@h"),
4769            "accessor mask must survive the window surgery: {redacted}"
4770        );
4771        assert!(
4772            !redacted.contains("user:pass"),
4773            "real userinfo leaked: {redacted}"
4774        );
4775        assert!(
4776            !redacted.contains("x@y"),
4777            "later path window leaked: {redacted}"
4778        );
4779    }
4780
4781    #[test]
4782    fn redact_url_backslash_authority_ruling() {
4783        // Probe outcome: url::Url::parse accepts this input. http is a
4784        // special scheme, so backslashes normalize to slashes and the
4785        // credentials land in real userinfo
4786        // (`http://user:pass@evil/path`). The parsed arm must mask them
4787        // like any other userinfo.
4788        let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4789        assert!(
4790            redacted.contains("***@"),
4791            "backslash authority must be userinfo-masked: {redacted}"
4792        );
4793        assert!(
4794            !redacted.contains("user:pass"),
4795            "backslash authority must not leak credentials: {redacted}"
4796        );
4797    }
4798
4799    #[test]
4800    fn non_special_backslash_authority_masked() {
4801        // Non-special scheme: the url crate does not normalize the
4802        // backslashes, so the string carries no `//` run — the
4803        // scheme-prefixed backslash window must still suppress the
4804        // credentials.
4805        let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4806        assert!(
4807            !redacted.contains("user:pass"),
4808            "non-special backslash authority leaked: {redacted}"
4809        );
4810        assert!(
4811            !redacted.contains("pass"),
4812            "non-special backslash authority leaked a credential byte: {redacted}"
4813        );
4814        // Clean sibling stays visible (spec scenario's second given).
4815        assert_eq!(
4816            redact_url_for_diagnostics("foo:\\clean/path"),
4817            "foo:\\clean/path"
4818        );
4819    }
4820
4821    #[test]
4822    fn one_char_scheme_credential_content_masked() {
4823        // Single backslash after the one-character scheme `x:` with
4824        // credential-shaped window content (`:` before the last `@`).
4825        let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4826        assert!(
4827            !redacted.contains("user:pass"),
4828            "one-char-scheme backslash authority leaked: {redacted}"
4829        );
4830        assert!(
4831            !redacted.contains("pass"),
4832            "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4833        );
4834    }
4835
4836    #[test]
4837    fn drive_and_unc_inputs_stay_visible() {
4838        // Drive path: single backslash after a one-character scheme, no
4839        // `:` in the candidate window — no qualifying backslash window.
4840        // The parse-success arm lowercases the scheme (`C:` → `c:`); the
4841        // diagnostic content must stay visible with no sentinel and no
4842        // mask (spec scenario: query-redaction/cap rules only).
4843        let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4844        assert!(
4845            !drive.contains("[redacted]"),
4846            "drive path must not be sentineled: {drive}"
4847        );
4848        assert!(
4849            !drive.contains("***"),
4850            "drive path must not be masked: {drive}"
4851        );
4852        assert!(
4853            drive.contains("x@corp"),
4854            "drive path keeps its at-sign content visible: {drive}"
4855        );
4856        // UNC path: no scheme prefix before the backslash run; the
4857        // unparseable arm renders it byte-identically.
4858        let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4859        assert_eq!(unc, "\\\\server\\x@y");
4860        assert!(
4861            !unc.contains("[redacted]"),
4862            "UNC path must not be sentineled: {unc}"
4863        );
4864    }
4865
4866    #[test]
4867    fn redact_url_masks_userinfo_and_query() {
4868        let redacted =
4869            redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4870        assert!(
4871            !redacted.contains("secretpass"),
4872            "password must be masked: {redacted}"
4873        );
4874        assert!(
4875            !redacted.contains("token=abc123"),
4876            "query must be masked: {redacted}"
4877        );
4878        assert!(
4879            !redacted.contains("user@"),
4880            "username must be masked: {redacted}"
4881        );
4882        assert!(
4883            redacted.contains("internal.example"),
4884            "host stays visible: {redacted}"
4885        );
4886        assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4887    }
4888
4889    #[test]
4890    fn redact_url_keeps_clean_urls_visible() {
4891        let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4892        assert_eq!(redacted, "https://api.example.com/v1/items");
4893    }
4894
4895    #[test]
4896    fn redact_url_masks_password_only_userinfo() {
4897        let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4898        assert!(
4899            !redacted.contains("pwsecret"),
4900            "password-only userinfo leaked: {redacted}"
4901        );
4902        assert_eq!(redacted, "http://***@host.example/");
4903
4904        let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4905        assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4906        assert_eq!(redacted, "http://***@host.example/api");
4907
4908        let redacted = redact_url_for_diagnostics("http://host.example/api");
4909        assert_eq!(redacted, "http://host.example/api");
4910    }
4911
4912    #[test]
4913    fn redact_url_truncates_unparseable() {
4914        let long = "x".repeat(1000);
4915        let redacted = redact_url_for_diagnostics(&long);
4916        assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4917    }
4918
4919    /// e_gpt stage-4: the 256-byte cap must not split an appended sentinel.
4920    /// The base is truncated at `256 - sentinel_len` BEFORE the sentinel is
4921    /// appended, so the sentinel always renders intact and the total stays
4922    /// ≤ 256. Both arms (parsed and unparseable) are exercised.
4923    #[test]
4924    fn redact_url_keeps_sentinels_intact_under_256_cap() {
4925        // Parsed arm: base (scheme+host+path) is 250 bytes, so byte 256
4926        // lands inside the appended `?[redacted]` (starts at 250) pre-fix.
4927        let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4928        assert!(
4929            url::Url::parse(&parsed).is_ok(),
4930            "fixture must parse: {parsed}"
4931        );
4932        let redacted = redact_url_for_diagnostics(&parsed);
4933        assert!(redacted.len() <= 256, "len={}", redacted.len());
4934        assert!(
4935            redacted.ends_with("?[redacted]"),
4936            "parsed-arm sentinel must render intact: {redacted}"
4937        );
4938
4939        // Unparseable arm: base is 249 bytes, so byte 256 lands inside the
4940        // appended `?[redacted]` (starts at 249) pre-fix.
4941        let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4942        assert!(
4943            url::Url::parse(&unparseable).is_err(),
4944            "fixture must not parse: {unparseable}"
4945        );
4946        let redacted = redact_url_for_diagnostics(&unparseable);
4947        assert!(redacted.len() <= 256, "len={}", redacted.len());
4948        assert!(
4949            redacted.ends_with("?[redacted]"),
4950            "unparseable-arm sentinel must render intact: {redacted}"
4951        );
4952    }
4953
4954    #[test]
4955    fn redact_url_suppresses_unparseable_authority_credentials() {
4956        let fixtures = [
4957            "http://u:secretpw@/x",
4958            "http://u:secretpw@host:99999/x",
4959            "http://u:secretpw@host:99999",
4960            "//u:secretpw@h/x",
4961        ];
4962        for fixture in fixtures {
4963            assert!(
4964                url::Url::parse(fixture).is_err(),
4965                "fixture must be unparseable: {fixture}"
4966            );
4967            let redacted = redact_url_for_diagnostics(fixture);
4968            assert_eq!(
4969                redacted, "[redacted]",
4970                "credential-bearing authority must be suppressed: {fixture}"
4971            );
4972        }
4973    }
4974
4975    #[test]
4976    fn redact_url_bd_repro_never_leaks_credentials() {
4977        let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4978        assert!(
4979            !redacted.contains("user:pa%ss"),
4980            "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4981        );
4982        assert!(
4983            !redacted.contains("pa%ss"),
4984            "bd rc-2i5c5 repro leaked password: {redacted}"
4985        );
4986    }
4987
4988    #[test]
4989    fn redact_url_unparseable_query_redacted_short_and_long() {
4990        let short = "http://host:99999/path?token=shortsecret";
4991        assert!(
4992            url::Url::parse(short).is_err(),
4993            "fixture must be unparseable: {short}"
4994        );
4995        let redacted = redact_url_for_diagnostics(short);
4996        assert_eq!(
4997            redacted, "http://host:99999/path?[redacted]",
4998            "short unparseable query must end with the suffix: {redacted}"
4999        );
5000
5001        let mut long = String::from("http://host:99999/");
5002        long.push_str(&"a".repeat(300));
5003        long.push_str("?token=longsecret");
5004        assert!(
5005            url::Url::parse(&long).is_err(),
5006            "fixture must be unparseable: {long}"
5007        );
5008        let redacted = redact_url_for_diagnostics(&long);
5009        assert!(
5010            !redacted.contains("longsecret"),
5011            "long unparseable query leaked a query byte: {redacted}"
5012        );
5013        assert!(
5014            redacted.len() <= 256,
5015            "long unparseable query must be capped: {} bytes",
5016            redacted.len()
5017        );
5018    }
5019
5020    #[test]
5021    fn redact_url_unparseable_sentinels_compose_both() {
5022        // Compose-both rule: one sentinel per distinct introducer found in
5023        // the raw string, in first-occurrence order.
5024        let raw = "ht tp://h.example/p?a=1#tok=x";
5025        assert!(
5026            url::Url::parse(raw).is_err(),
5027            "fixture must be unparseable: {raw}"
5028        );
5029        assert_eq!(
5030            redact_url_for_diagnostics(raw),
5031            "ht tp://h.example/p?[redacted]#[redacted]",
5032            "query and fragment sentinels must compose: {raw}"
5033        );
5034    }
5035
5036    #[test]
5037    fn redact_url_unparseable_sentinels_compose_fragment_first() {
5038        let raw = "ht tp://h.example/p#tok=x?a=1";
5039        assert!(
5040            url::Url::parse(raw).is_err(),
5041            "fixture must be unparseable: {raw}"
5042        );
5043        assert_eq!(
5044            redact_url_for_diagnostics(raw),
5045            "ht tp://h.example/p#[redacted]?[redacted]",
5046            "sentinels must follow the introducers' first-occurrence order: {raw}"
5047        );
5048    }
5049
5050    #[test]
5051    fn redact_url_unparseable_utf8_straddle_no_panic() {
5052        let fixture = format!("a{}", "é".repeat(200));
5053        let redacted = redact_url_for_diagnostics(&fixture);
5054        assert!(
5055            redacted.len() <= 256,
5056            "straddle fixture must be capped: {} bytes",
5057            redacted.len()
5058        );
5059        assert!(
5060            redacted.len() >= 253,
5061            "straddle fixture must not over-truncate: {} bytes",
5062            redacted.len()
5063        );
5064        assert!(
5065            fixture.is_char_boundary(redacted.len()),
5066            "cut must land on a UTF-8 char boundary: {} bytes",
5067            redacted.len()
5068        );
5069    }
5070
5071    #[test]
5072    fn redact_url_at_sign_outside_authority_window_visible() {
5073        let at_sign_in_path = "http://host:99999/x@y";
5074        assert!(
5075            url::Url::parse(at_sign_in_path).is_err(),
5076            "fixture must be unparseable: {at_sign_in_path}"
5077        );
5078        assert_eq!(
5079            redact_url_for_diagnostics(at_sign_in_path),
5080            at_sign_in_path,
5081            "at-sign in path must not be suppressed"
5082        );
5083        // mailto parses as a cannot-be-a-base URL (no is_err precondition).
5084        assert_eq!(
5085            redact_url_for_diagnostics("mailto:user@example.com"),
5086            "mailto:user@example.com",
5087            "at-sign in mailto must round-trip byte-identically"
5088        );
5089    }
5090
5091    #[test]
5092    fn parse_success_fragment_composes() {
5093        // Parsed arm: the fragment stays on the rendered URL and the
5094        // canonical redactor drops it and appends the sentinel.
5095        assert_eq!(
5096            redact_url_for_diagnostics("https://h/p#access_token=x"),
5097            "https://h/p#[redacted]"
5098        );
5099        // A `?` inside the fragment composes both sentinels, in
5100        // first-occurrence order (# before ?).
5101        assert_eq!(
5102            redact_url_for_diagnostics("https://h/cb#f?state=x"),
5103            "https://h/cb#[redacted]?[redacted]"
5104        );
5105    }
5106
5107    #[test]
5108    fn err_arm_delegation_pin() {
5109        // Unparseable (port 99999) with userinfo in the authority window:
5110        // the Err arm delegates wholesale to the fail-closed canonical
5111        // redactor — nothing of the URL is rendered.
5112        assert_eq!(
5113            redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
5114            "[redacted]"
5115        );
5116        // Cross-surface fixture: same unparseable port without userinfo —
5117        // drop at `?`, append the query sentinel.
5118        assert_eq!(
5119            redact_url_for_diagnostics("http://h:99999/p?token=secret"),
5120            "http://h:99999/p?[redacted]"
5121        );
5122    }
5123
5124    #[test]
5125    fn truncate_error_body_caps_attacker_body() {
5126        let big = vec![b'A'; 10 * 1024 * 1024];
5127        let truncated = truncate_error_body(&big);
5128        assert!(
5129            truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
5130            "body must be capped near {} bytes, got {}",
5131            MAX_ERROR_RESPONSE_BODY_BYTES,
5132            truncated.len()
5133        );
5134        assert!(truncated.ends_with("...[truncated]"));
5135    }
5136
5137    #[test]
5138    fn truncate_error_body_keeps_small_body() {
5139        assert_eq!(truncate_error_body(b"boom"), "boom");
5140    }
5141
5142    #[test]
5143    fn test_http_config_defaults() {
5144        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
5145        assert_eq!(config.base_url, "http://localhost:8080/api");
5146        assert!(config.http_method.is_none());
5147        assert!(config.throw_exception_on_failure);
5148        assert_eq!(config.ok_status_code_range, (200, 299));
5149        assert!(config.response_timeout.is_none());
5150        assert!(matches!(config.auth, HttpAuth::None));
5151        assert!(!config.bridge_endpoint);
5152        assert!(!config.connection_close);
5153    }
5154
5155    #[test]
5156    fn test_http_config_scheme() {
5157        // UriConfig trait method returns "http" as primary scheme
5158        assert_eq!(HttpEndpointConfig::scheme(), "http");
5159    }
5160
5161    #[test]
5162    fn test_http_config_from_components() {
5163        // Test from_components directly (trait method)
5164        let components = camel_component_api::UriComponents {
5165            scheme: "https".to_string(),
5166            path: "//api.example.com/v1".to_string(),
5167            params: std::collections::HashMap::from([(
5168                "httpMethod".to_string(),
5169                "POST".to_string(),
5170            )]),
5171            raw_query: None,
5172        };
5173        let config = HttpEndpointConfig::from_components(components).unwrap();
5174        assert_eq!(config.base_url, "https://api.example.com/v1");
5175        assert_eq!(config.http_method, Some("POST".to_string()));
5176    }
5177
5178    #[test]
5179    fn test_http_config_with_options() {
5180        let config = HttpEndpointConfig::from_uri(
5181            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
5182        ).unwrap();
5183        assert_eq!(config.base_url, "https://api.example.com/v1");
5184        assert_eq!(config.http_method, Some("PUT".to_string()));
5185        assert!(!config.throw_exception_on_failure);
5186        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
5187    }
5188
5189    #[test]
5190    fn test_http_endpoint_config_auth_and_headers_options() {
5191        let config = HttpEndpointConfig::from_uri(
5192            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
5193        )
5194        .unwrap();
5195
5196        assert!(matches!(
5197            config.auth,
5198            HttpAuth::Basic { username, password } if username == "u" && password == "p"
5199        ));
5200        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
5201        assert!(config.bridge_endpoint);
5202        assert!(config.connection_close);
5203        assert_eq!(
5204            config.skip_request_headers,
5205            vec!["authorization".to_string(), "x-secret".to_string()]
5206        );
5207        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
5208    }
5209
5210    #[test]
5211    fn test_http_endpoint_config_bearer_auth() {
5212        let config = HttpEndpointConfig::from_uri(
5213            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
5214        )
5215        .unwrap();
5216        assert!(matches!(
5217            config.auth,
5218            HttpAuth::Bearer { token } if token == "t"
5219        ));
5220    }
5221
5222    #[test]
5223    fn rejects_cookie_handling_inmemory() {
5224        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
5225        match result {
5226            Err(CamelError::InvalidUri(msg)) => {
5227                assert!(
5228                    msg.contains("cookieHandling is not supported"),
5229                    "expected rejection message, got: {msg}"
5230                );
5231            }
5232            other => panic!("expected InvalidUri error, got: {other:?}"),
5233        }
5234    }
5235
5236    #[test]
5237    fn rejects_cookie_handling_disabled() {
5238        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
5239        match result {
5240            Err(CamelError::InvalidUri(msg)) => {
5241                assert!(
5242                    msg.contains("cookieHandling is not supported"),
5243                    "expected rejection message, got: {msg}"
5244                );
5245            }
5246            other => panic!("expected InvalidUri error, got: {other:?}"),
5247        }
5248    }
5249
5250    #[test]
5251    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
5252        let config = HttpConfig::default()
5253            .with_response_timeout_ms(999)
5254            .with_allow_internal(true)
5255            .with_blocked_hosts(vec!["evil.com".to_string()])
5256            .with_max_body_size(12345);
5257        let endpoint =
5258            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
5259        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
5260        assert!(endpoint.allow_internal);
5261        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
5262        assert_eq!(endpoint.max_body_size, 12345);
5263    }
5264
5265    #[test]
5266    fn test_from_uri_with_defaults_uri_overrides_config() {
5267        let config = HttpConfig::default()
5268            .with_response_timeout_ms(999)
5269            .with_allow_internal(true)
5270            .with_blocked_hosts(vec!["evil.com".to_string()])
5271            .with_max_body_size(12345);
5272        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
5273            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
5274            &config,
5275        )
5276        .unwrap();
5277        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
5278        assert!(!endpoint.allow_internal);
5279        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
5280        assert_eq!(endpoint.max_body_size, 99);
5281    }
5282
5283    #[test]
5284    fn test_http_config_ok_status_range() {
5285        let config =
5286            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
5287        assert_eq!(config.ok_status_code_range, (200, 204));
5288    }
5289
5290    #[test]
5291    fn test_http_config_wrong_scheme() {
5292        let result = HttpEndpointConfig::from_uri("file:/tmp");
5293        assert!(result.is_err());
5294    }
5295
5296    #[test]
5297    fn test_http_component_scheme() {
5298        let component = HttpComponent::new();
5299        assert_eq!(component.scheme(), "http");
5300    }
5301
5302    // -----------------------------------------------------------------------
5303    // tls.strict — fail-closed knob (audit 2026-08-31 R3 / rc-ayrwk).
5304    // Default stays permissive (F2-7 warns); strict fails endpoint creation
5305    // on any CA/mTLS load failure.
5306    // -----------------------------------------------------------------------
5307
5308    #[test]
5309    fn tls_strict_defaults_false_on_deserialize() {
5310        let tls: TlsConfig = serde_json::from_value(serde_json::json!({
5311            "enabled": true
5312        }))
5313        .unwrap();
5314        assert!(!tls.strict, "absent strict must default to false");
5315    }
5316
5317    fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
5318        HttpConfig {
5319            tls: Some(TlsConfig {
5320                enabled: true,
5321                strict,
5322                ca_cert_path: ca_path.map(|p| p.to_string()),
5323                ..TlsConfig::default()
5324            }),
5325            ..HttpConfig::default()
5326        }
5327    }
5328
5329    #[test]
5330    fn strict_tls_missing_ca_fails_endpoint_creation() {
5331        let component =
5332            HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
5333        let err = component
5334            .create_endpoint("http://localhost/api", &NoOpComponentContext)
5335            .err()
5336            .expect("strict + missing CA must fail endpoint creation");
5337        assert!(
5338            err.to_string().contains("tls.strict"),
5339            "must name the strict knob: {err}"
5340        );
5341        assert!(
5342            err.to_string().contains("unreadable"),
5343            "must name the failure class: {err}"
5344        );
5345    }
5346
5347    #[test]
5348    fn strict_tls_unparseable_ca_fails_endpoint_creation() {
5349        let path = camel_component_api::test_support::tls::write_pem_tmp(
5350            "strict-bad-ca.pem",
5351            "not a certificate",
5352        );
5353        let component =
5354            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
5355        let err = component
5356            .create_endpoint("http://localhost/api", &NoOpComponentContext)
5357            .err()
5358            .expect("strict + unparseable CA must fail endpoint creation");
5359        assert!(
5360            err.to_string()
5361                .contains("no parseable PEM CERTIFICATE section"),
5362            "must name the failure class: {err}"
5363        );
5364    }
5365
5366    #[test]
5367    fn strict_tls_der_file_rejected_not_certified() {
5368        // e_glm stage-4 finding 1: a DER-looking file (first byte 0x30 =
5369        // ASCII '0') must NOT pass strict — the rustls backend never
5370        // enforces lone-DER bundles, so certifying one would certify an
5371        // unenforced config.
5372        let path = camel_component_api::test_support::tls::write_pem_tmp(
5373            "strict-der-ca.pem",
5374            "00garbage-bytes",
5375        );
5376        let component =
5377            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
5378        let err = component
5379            .create_endpoint("http://localhost/api", &NoOpComponentContext)
5380            .err()
5381            .expect("strict + DER file must fail endpoint creation");
5382        assert!(
5383            err.to_string().contains("convert to PEM"),
5384            "must tell the operator to convert: {err}"
5385        );
5386    }
5387
5388    #[test]
5389    fn strict_tls_half_mtls_pair_rejected() {
5390        // e_glm stage-4 finding 2: cert XOR key must fail under strict,
5391        // not silently degrade to non-mTLS.
5392        let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
5393        let component = HttpComponent::with_config(cfg);
5394        let err = component
5395            .create_endpoint("http://localhost/api", &NoOpComponentContext)
5396            .err()
5397            .expect("strict + half mTLS pair must fail endpoint creation");
5398        assert!(
5399            err.to_string().contains("BOTH"),
5400            "must name the pair requirement: {err}"
5401        );
5402    }
5403
5404    #[test]
5405    fn strict_tls_valid_material_allows_endpoint_creation() {
5406        let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
5407        let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
5408        let component =
5409            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
5410        assert!(
5411            component
5412                .create_endpoint("http://localhost/api", &NoOpComponentContext)
5413                .is_ok(),
5414            "valid CA under strict must create the endpoint"
5415        );
5416    }
5417
5418    #[test]
5419    fn permissive_missing_ca_keeps_back_compat() {
5420        // strict absent (false): the F2-7 warn-and-fallback behavior stays;
5421        // endpoint creation succeeds.
5422        let component =
5423            HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
5424        assert!(
5425            component
5426                .create_endpoint("http://localhost/api", &NoOpComponentContext)
5427                .is_ok(),
5428            "permissive mode must keep the back-compat fallback"
5429        );
5430    }
5431
5432    fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
5433        HttpConfig {
5434            tls: Some(TlsConfig {
5435                enabled: true,
5436                strict: true,
5437                client_cert_path: cert_path.map(|p| p.to_string()),
5438                client_key_path: key_path.map(|p| p.to_string()),
5439                ..TlsConfig::default()
5440            }),
5441            ..HttpConfig::default()
5442        }
5443    }
5444
5445    #[test]
5446    fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
5447        // Key present, cert file missing: a half-readable mTLS pair must
5448        // fail creation under strict, not silently drop the identity.
5449        let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
5450        let key_path =
5451            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
5452        let component = HttpComponent::with_config(strict_mtls_config(
5453            Some("/nonexistent/cert.pem"),
5454            Some(key_path.to_str().unwrap()),
5455        ));
5456        let err = component
5457            .create_endpoint("http://localhost/api", &NoOpComponentContext)
5458            .err()
5459            .expect("strict + unreadable mTLS pair must fail endpoint creation");
5460        assert!(
5461            err.to_string().contains("tls.strict"),
5462            "must name the strict knob: {err}"
5463        );
5464        assert!(
5465            err.to_string().contains("unreadable"),
5466            "must name the failure class: {err}"
5467        );
5468    }
5469
5470    #[test]
5471    fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
5472        let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
5473        let cert_path =
5474            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
5475        let key_path =
5476            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
5477        let component = HttpComponent::with_config(strict_mtls_config(
5478            Some(cert_path.to_str().unwrap()),
5479            Some(key_path.to_str().unwrap()),
5480        ));
5481        assert!(
5482            component
5483                .create_endpoint("http://localhost/api", &NoOpComponentContext)
5484                .is_ok(),
5485            "valid mTLS pair under strict must create the endpoint"
5486        );
5487    }
5488
5489    #[test]
5490    fn test_https_component_scheme() {
5491        let component = HttpsComponent::new();
5492        assert_eq!(component.scheme(), "https");
5493    }
5494
5495    #[test]
5496    fn test_http_endpoint_creates_consumer() {
5497        let component = HttpComponent::new();
5498        let ctx = NoOpComponentContext;
5499        let endpoint = component
5500            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
5501            .unwrap();
5502        assert!(endpoint.create_consumer(rt()).is_ok());
5503    }
5504
5505    #[test]
5506    fn test_https_endpoint_creates_consumer_errors_without_tls() {
5507        let component = HttpsComponent::new();
5508        let ctx = NoOpComponentContext;
5509        let endpoint = component
5510            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
5511            .unwrap();
5512        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
5513        assert!(endpoint.create_consumer(rt()).is_err());
5514    }
5515
5516    #[test]
5517    fn test_http_endpoint_creates_producer() {
5518        let ctx = test_producer_ctx();
5519        let component = HttpComponent::new();
5520        let endpoint_ctx = NoOpComponentContext;
5521        let endpoint = component
5522            .create_endpoint("http://localhost/api", &endpoint_ctx)
5523            .unwrap();
5524        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
5525    }
5526
5527    // -----------------------------------------------------------------------
5528    // Producer tests
5529    // -----------------------------------------------------------------------
5530
5531    #[tokio::test]
5532    async fn test_producer_with_token_provider() {
5533        use camel_auth::oauth2::TokenProvider;
5534        use tower::ServiceExt;
5535
5536        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
5537            Arc::new(std::sync::Mutex::new(None));
5538        let captured_clone = Arc::clone(&captured_auth);
5539
5540        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5541        let port = listener.local_addr().unwrap().port();
5542
5543        let _handle = tokio::spawn(async move {
5544            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5545            if let Ok((mut stream, _)) = listener.accept().await {
5546                let mut buf = vec![0u8; 8192];
5547                let n = stream.read(&mut buf).await.unwrap_or(0);
5548                let request = String::from_utf8_lossy(&buf[..n]).to_string();
5549                let auth = request
5550                    .lines()
5551                    .find(|l| l.to_lowercase().starts_with("authorization:"))
5552                    .map(|l| {
5553                        l.split(':')
5554                            .nth(1)
5555                            .map(|s| s.trim().to_string())
5556                            .unwrap_or_default()
5557                    });
5558                *captured_clone.lock().unwrap() = auth;
5559                let body = r#"{"echo":"ok"}"#;
5560                let resp = format!(
5561                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5562                    body.len(),
5563                    body
5564                );
5565                let _ = stream.write_all(resp.as_bytes()).await;
5566            }
5567        });
5568
5569        #[derive(Debug)]
5570        struct StaticProvider;
5571        #[async_trait::async_trait]
5572        impl TokenProvider for StaticProvider {
5573            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
5574                Ok("injected-token".into())
5575            }
5576        }
5577
5578        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
5579        let ctx = test_producer_ctx();
5580        let component = HttpComponent::new();
5581        let endpoint_ctx = NoOpComponentContext;
5582        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
5583        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5584
5585        let exchange = Exchange::new(Message::new("hello"));
5586
5587        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
5588        let mut layered = layer.layer(producer);
5589        let result = layered.ready().await.unwrap().call(exchange).await;
5590        assert!(result.is_ok(), "producer call failed: {:?}", result);
5591
5592        tokio::time::sleep(Duration::from_millis(100)).await;
5593        let auth = captured_auth.lock().unwrap().take();
5594        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
5595    }
5596
5597    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
5598        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5599        let addr = listener.local_addr().unwrap();
5600        let url = format!("http://127.0.0.1:{}", addr.port());
5601
5602        let handle = tokio::spawn(async move {
5603            loop {
5604                if let Ok((mut stream, _)) = listener.accept().await {
5605                    tokio::spawn(async move {
5606                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5607                        let mut buf = vec![0u8; 4096];
5608                        let n = stream.read(&mut buf).await.unwrap_or(0);
5609                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
5610
5611                        let method = request.split_whitespace().next().unwrap_or("GET");
5612
5613                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
5614                        let response = format!(
5615                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
5616                            body.len(),
5617                            body
5618                        );
5619                        let _ = stream.write_all(response.as_bytes()).await;
5620                    });
5621                }
5622            }
5623        });
5624
5625        (url, handle)
5626    }
5627
5628    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
5629        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5630        let addr = listener.local_addr().unwrap();
5631        let url = format!("http://127.0.0.1:{}", addr.port());
5632
5633        let handle = tokio::spawn(async move {
5634            loop {
5635                if let Ok((mut stream, _)) = listener.accept().await {
5636                    let status = status;
5637                    tokio::spawn(async move {
5638                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5639                        let mut buf = vec![0u8; 4096];
5640                        let _ = stream.read(&mut buf).await;
5641
5642                        let status_text = match status {
5643                            404 => "Not Found",
5644                            500 => "Internal Server Error",
5645                            _ => "Error",
5646                        };
5647                        let body = "error body";
5648                        let response = format!(
5649                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
5650                            status,
5651                            status_text,
5652                            body.len(),
5653                            body
5654                        );
5655                        let _ = stream.write_all(response.as_bytes()).await;
5656                    });
5657                }
5658            }
5659        });
5660
5661        (url, handle)
5662    }
5663
5664    async fn start_request_capturing_server() -> (
5665        String,
5666        Arc<std::sync::Mutex<Option<String>>>,
5667        tokio::task::JoinHandle<()>,
5668    ) {
5669        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5670        let port = listener.local_addr().unwrap().port();
5671        let url = format!("http://127.0.0.1:{port}");
5672        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
5673        let captured_clone = Arc::clone(&captured);
5674        let handle = tokio::spawn(async move {
5675            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5676            if let Ok((mut stream, _)) = listener.accept().await {
5677                let mut buf = vec![0u8; 16384];
5678                let n = stream.read(&mut buf).await.unwrap_or(0);
5679                let request = String::from_utf8_lossy(&buf[..n]).to_string();
5680                if request.contains("\r\n\r\n") {
5681                    *captured_clone.lock().unwrap() = Some(request);
5682                }
5683                let body = r#"{"echo":"ok"}"#;
5684                let resp = format!(
5685                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5686                    body.len(),
5687                    body
5688                );
5689                let _ = stream.write_all(resp.as_bytes()).await;
5690            }
5691        });
5692        (url, captured, handle)
5693    }
5694
5695    #[tokio::test]
5696    async fn test_http_producer_get_request() {
5697        use tower::ServiceExt;
5698
5699        let (url, _handle) = start_test_server().await;
5700        let ctx = test_producer_ctx();
5701
5702        let component = HttpComponent::new();
5703        let endpoint_ctx = NoOpComponentContext;
5704        let endpoint = component
5705            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5706            .unwrap();
5707        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5708
5709        let exchange = Exchange::new(Message::default());
5710        let result = producer.oneshot(exchange).await.unwrap();
5711
5712        let status = result
5713            .input
5714            .header("CamelHttpResponseCode")
5715            .and_then(|v| v.as_u64())
5716            .unwrap();
5717        assert_eq!(status, 200);
5718
5719        assert!(!result.input.body.is_empty());
5720    }
5721
5722    #[tokio::test]
5723    async fn producer_excludes_host_and_framing() {
5724        use tower::ServiceExt;
5725
5726        let (url, captured, _handle) = start_request_capturing_server().await;
5727        let ctx = test_producer_ctx();
5728        let component = HttpComponent::new();
5729        let endpoint_ctx = NoOpComponentContext;
5730        let endpoint = component
5731            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5732            .unwrap();
5733        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5734
5735        let mut exchange = Exchange::new(Message::default());
5736        exchange.input.set_header("Host", "localhost");
5737        exchange.input.set_header("Content-Length", "42");
5738        exchange.input.set_header("Connection", "keep-alive");
5739        exchange.input.set_header("Upgrade", "h2c");
5740
5741        let result = producer.oneshot(exchange).await;
5742        assert!(result.is_ok(), "producer call failed: {:?}", result);
5743
5744        tokio::time::sleep(Duration::from_millis(100)).await;
5745        let request = captured
5746            .lock()
5747            .unwrap()
5748            .take()
5749            .expect("no outbound request captured");
5750        let lower = request.to_ascii_lowercase();
5751        assert!(
5752            !lower.contains("\r\nhost: localhost"),
5753            "forwarded Host: localhost must be stripped\n{request}"
5754        );
5755        assert!(
5756            !lower.contains("content-length: 42"),
5757            "exchange Content-Length must not be copied\n{request}"
5758        );
5759        assert!(
5760            !lower.lines().any(|l| l.starts_with("connection:")),
5761            "Connection header must not be forwarded\n{request}"
5762        );
5763        assert!(
5764            !lower.lines().any(|l| l.starts_with("upgrade:")),
5765            "Upgrade header must not be forwarded\n{request}"
5766        );
5767        let host_header = lower
5768            .lines()
5769            .find(|l| l.starts_with("host:"))
5770            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5771            .expect("outbound Host header must be set by reqwest");
5772        assert!(
5773            host_header.starts_with("127.0.0.1:"),
5774            "outbound Host '{host_header}' must match the capture-server address"
5775        );
5776    }
5777
5778    #[tokio::test]
5779    async fn producer_forwards_request_only_headers() {
5780        use tower::ServiceExt;
5781
5782        let (url, captured, _handle) = start_request_capturing_server().await;
5783        let ctx = test_producer_ctx();
5784        let component = HttpComponent::new();
5785        let endpoint_ctx = NoOpComponentContext;
5786        let endpoint = component
5787            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5788            .unwrap();
5789        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5790
5791        let mut exchange = Exchange::new(Message::default());
5792        exchange.input.set_header("Accept", "application/json");
5793        exchange.input.set_header("User-Agent", "myclient/1.0");
5794
5795        let result = producer.oneshot(exchange).await;
5796        assert!(result.is_ok(), "producer call failed: {:?}", result);
5797
5798        tokio::time::sleep(Duration::from_millis(100)).await;
5799        let request = captured
5800            .lock()
5801            .unwrap()
5802            .take()
5803            .expect("no outbound request captured");
5804        let lower = request.to_ascii_lowercase();
5805        assert!(
5806            lower.contains("accept: application/json"),
5807            "request-only Accept header must be forwarded\n{request}"
5808        );
5809        assert!(
5810            lower.contains("user-agent: myclient/1.0"),
5811            "request-only User-Agent header must be forwarded\n{request}"
5812        );
5813    }
5814
5815    // -----------------------------------------------------------------------
5816    // Configured-header construction failures are surfaced, never silent
5817    // (rc-jbs1v)
5818    // -----------------------------------------------------------------------
5819
5820    /// Build an endpoint whose URI parses normally but whose `user_agent`
5821    /// and `auth` are then overridden programmatically, so CRLF-bearing
5822    /// test values never pass through URI parsing.
5823    fn endpoint_with_config_overrides(
5824        base_url: &str,
5825        user_agent: Option<String>,
5826        auth: HttpAuth,
5827    ) -> HttpEndpoint {
5828        let uri = format!("{base_url}/api/test?allowInternal=true");
5829        let mut config =
5830            HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5831        config.user_agent = user_agent;
5832        config.auth = auth;
5833        HttpEndpoint {
5834            uri: uri.clone(),
5835            config,
5836            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5837            client: plain_http_test_client(),
5838            pinned_cache: Arc::new(PinnedClientCache::new(
5839                PINNED_CLIENT_TTL,
5840                PINNED_CLIENT_MAX_ENTRIES,
5841            )),
5842            http_config: HttpConfig::default(),
5843        }
5844    }
5845
5846    /// A configured user-agent / bearer token that fails `HeaderValue`
5847    /// construction must be dropped with a DEBUG record (name + reason
5848    /// only, never the value — ADR-0051) and reach the wire absent, while
5849    /// a valid config passes through unchanged.
5850    #[tracing_test::traced_test]
5851    #[tokio::test]
5852    async fn producer_invalid_configured_headers_surfaced() {
5853        use tower::ServiceExt;
5854
5855        let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5856        let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5857        let ctx = test_producer_ctx();
5858
5859        let bad_producer = endpoint_with_config_overrides(
5860            &bad_url,
5861            Some("bad\r\nua".to_string()),
5862            HttpAuth::Bearer {
5863                token: "tok\r\nen".to_string(),
5864            },
5865        )
5866        .create_producer(rt(), &ctx)
5867        .unwrap();
5868        let ok_producer = endpoint_with_config_overrides(
5869            &ok_url,
5870            Some("httpsweep-ok/1".to_string()),
5871            HttpAuth::Bearer {
5872                token: "valid-token".to_string(),
5873            },
5874        )
5875        .create_producer(rt(), &ctx)
5876        .unwrap();
5877
5878        let bad_exchange = Exchange::new(Message::default());
5879        let ok_exchange = Exchange::new(Message::default());
5880        let bad_cid = bad_exchange.correlation_id().to_string();
5881        let ok_cid = ok_exchange.correlation_id().to_string();
5882
5883        let bad_result = bad_producer.oneshot(bad_exchange).await;
5884        assert!(
5885            bad_result.is_ok(),
5886            "invalid-config producer call failed: {bad_result:?}"
5887        );
5888        let ok_result = ok_producer.oneshot(ok_exchange).await;
5889        assert!(
5890            ok_result.is_ok(),
5891            "valid-config producer call failed: {ok_result:?}"
5892        );
5893
5894        tokio::time::sleep(Duration::from_millis(100)).await;
5895        let bad_request = bad_captured
5896            .lock()
5897            .unwrap()
5898            .take()
5899            .expect("no outbound request captured");
5900        let ok_request = ok_captured
5901            .lock()
5902            .unwrap()
5903            .take()
5904            .expect("no outbound request captured");
5905
5906        // Invalid config: neither header reaches the wire. Value-absence,
5907        // not "any UA" — reqwest may inject a default user-agent.
5908        let bad_lower = bad_request.to_ascii_lowercase();
5909        assert!(
5910            !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5911            "invalid Bearer token must not reach the wire\n{bad_request}"
5912        );
5913        assert!(
5914            !bad_request.contains("bad\r\nua"),
5915            "invalid configured user-agent must not reach the wire\n{bad_request}"
5916        );
5917
5918        logs_assert(|lines: &[&str]| {
5919            let drops: Vec<&&str> = lines
5920                .iter()
5921                .filter(|l| {
5922                    l.contains("outbound header dropped")
5923                        && l.contains(&format!("correlation_id={bad_cid}"))
5924                })
5925                .collect();
5926            if drops.len() != 2 {
5927                return Err(format!(
5928                    "expected exactly 2 drop records for {bad_cid}, found {}",
5929                    drops.len()
5930                ));
5931            }
5932            let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5933            let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5934            let reason_ok = drops
5935                .iter()
5936                .all(|l| l.contains("outbound header dropped: invalid header value"));
5937            match (has_ua, has_auth, reason_ok) {
5938                (true, true, true) => Ok(()),
5939                _ => Err(format!(
5940                    "drop records mismatched: user-agent={has_ua} \
5941                     authorization={has_auth} reason-ok={reason_ok}"
5942                )),
5943            }
5944        });
5945        logs_assert(|lines: &[&str]| {
5946            if lines
5947                .iter()
5948                .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5949            {
5950                Err("sentinel CRLF values leaked into logs".to_string())
5951            } else {
5952                Ok(())
5953            }
5954        });
5955
5956        // Valid config: both headers reach the wire exactly as configured,
5957        // with zero drop records.
5958        let ok_lower = ok_request.to_ascii_lowercase();
5959        assert!(
5960            ok_lower.contains("user-agent: httpsweep-ok/1"),
5961            "valid configured user-agent must reach the wire\n{ok_request}"
5962        );
5963        assert!(
5964            ok_lower.contains("authorization: bearer valid-token"),
5965            "valid Bearer token must reach the wire\n{ok_request}"
5966        );
5967        logs_assert(|lines: &[&str]| {
5968            let hits = lines
5969                .iter()
5970                .filter(|l| {
5971                    l.contains("outbound header dropped")
5972                        && l.contains(&format!("correlation_id={ok_cid}"))
5973                })
5974                .count();
5975            match hits {
5976                0 => Ok(()),
5977                n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5978            }
5979        });
5980    }
5981
5982    #[tokio::test]
5983    async fn producer_honours_skip_request_headers() {
5984        use tower::ServiceExt;
5985
5986        let (url, captured, _handle) = start_request_capturing_server().await;
5987        let ctx = test_producer_ctx();
5988        let component = HttpComponent::new();
5989        let endpoint_ctx = NoOpComponentContext;
5990        let endpoint = component
5991            .create_endpoint(
5992                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5993                &endpoint_ctx,
5994            )
5995            .unwrap();
5996        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5997
5998        let mut exchange = Exchange::new(Message::default());
5999        exchange.input.set_header("Authorization", "Bearer x");
6000
6001        let result = producer.oneshot(exchange).await;
6002        assert!(result.is_ok(), "producer call failed: {:?}", result);
6003
6004        tokio::time::sleep(Duration::from_millis(100)).await;
6005        let request = captured
6006            .lock()
6007            .unwrap()
6008            .take()
6009            .expect("no outbound request captured");
6010        assert!(
6011            !request.to_ascii_lowercase().contains("authorization"),
6012            "Authorization must be stripped by skipRequestHeaders\n{request}"
6013        );
6014    }
6015
6016    #[tokio::test]
6017    async fn producer_stringifies_scalar_header_values_on_wire() {
6018        use tower::ServiceExt;
6019
6020        let (url, captured, _handle) = start_request_capturing_server().await;
6021        let ctx = test_producer_ctx();
6022        let component = HttpComponent::new();
6023        let endpoint_ctx = NoOpComponentContext;
6024        let endpoint = component
6025            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
6026            .unwrap();
6027        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6028
6029        let mut exchange = Exchange::new(Message::default());
6030        exchange.input.set_header("X-Retries", serde_json::json!(3));
6031        exchange
6032            .input
6033            .set_header("X-Enabled", serde_json::json!(true));
6034        exchange
6035            .input
6036            .set_header("X-Obj", serde_json::json!({"a": 1}));
6037
6038        let result = producer.oneshot(exchange).await;
6039        assert!(result.is_ok(), "producer call failed: {:?}", result);
6040
6041        tokio::time::sleep(Duration::from_millis(100)).await;
6042        let request = captured
6043            .lock()
6044            .unwrap()
6045            .take()
6046            .expect("no outbound request captured");
6047        let lower = request.to_ascii_lowercase();
6048        assert!(
6049            lower.contains("x-retries: 3"),
6050            "numeric header must reach the wire stringified\n{request}"
6051        );
6052        assert!(
6053            lower.contains("x-enabled: true"),
6054            "bool header must reach the wire stringified\n{request}"
6055        );
6056        assert!(
6057            !lower.contains("x-obj:"),
6058            "object header has no single-value form and must not reach the wire\n{request}"
6059        );
6060    }
6061
6062    #[tokio::test]
6063    async fn test_http_producer_post_with_body() {
6064        use tower::ServiceExt;
6065
6066        let (url, _handle) = start_test_server().await;
6067        let ctx = test_producer_ctx();
6068
6069        let component = HttpComponent::new();
6070        let endpoint_ctx = NoOpComponentContext;
6071        let endpoint = component
6072            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
6073            .unwrap();
6074        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6075
6076        let exchange = Exchange::new(Message::new("request body"));
6077        let result = producer.oneshot(exchange).await.unwrap();
6078
6079        let status = result
6080            .input
6081            .header("CamelHttpResponseCode")
6082            .and_then(|v| v.as_u64())
6083            .unwrap();
6084        assert_eq!(status, 200);
6085    }
6086
6087    #[tokio::test]
6088    async fn test_http_producer_method_from_header() {
6089        use tower::ServiceExt;
6090
6091        let (url, _handle) = start_test_server().await;
6092        let ctx = test_producer_ctx();
6093
6094        let component = HttpComponent::new();
6095        let endpoint_ctx = NoOpComponentContext;
6096        let endpoint = component
6097            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6098            .unwrap();
6099        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6100
6101        let mut exchange = Exchange::new(Message::default());
6102        exchange.input.set_header(
6103            "CamelHttpMethod",
6104            serde_json::Value::String("DELETE".to_string()),
6105        );
6106
6107        let result = producer.oneshot(exchange).await.unwrap();
6108        let status = result
6109            .input
6110            .header("CamelHttpResponseCode")
6111            .and_then(|v| v.as_u64())
6112            .unwrap();
6113        assert_eq!(status, 200);
6114    }
6115
6116    #[tokio::test]
6117    async fn test_http_producer_forced_method() {
6118        use tower::ServiceExt;
6119
6120        let (url, _handle) = start_test_server().await;
6121        let ctx = test_producer_ctx();
6122
6123        let component = HttpComponent::new();
6124        let endpoint_ctx = NoOpComponentContext;
6125        let endpoint = component
6126            .create_endpoint(
6127                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
6128                &endpoint_ctx,
6129            )
6130            .unwrap();
6131        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6132
6133        let exchange = Exchange::new(Message::default());
6134        let result = producer.oneshot(exchange).await.unwrap();
6135
6136        let status = result
6137            .input
6138            .header("CamelHttpResponseCode")
6139            .and_then(|v| v.as_u64())
6140            .unwrap();
6141        assert_eq!(status, 200);
6142    }
6143
6144    #[tokio::test]
6145    async fn test_http_producer_throw_exception_on_failure() {
6146        use tower::ServiceExt;
6147
6148        let (url, _handle) = start_status_server(404).await;
6149        let ctx = test_producer_ctx();
6150
6151        let component = HttpComponent::new();
6152        let endpoint_ctx = NoOpComponentContext;
6153        let endpoint = component
6154            .create_endpoint(
6155                &format!("{url}/not-found?allowInternal=true"),
6156                &endpoint_ctx,
6157            )
6158            .unwrap();
6159        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6160
6161        let exchange = Exchange::new(Message::default());
6162        let result = producer.oneshot(exchange).await;
6163        assert!(result.is_err());
6164
6165        match result.unwrap_err() {
6166            CamelError::HttpOperationFailed { status_code, .. } => {
6167                assert_eq!(status_code, 404);
6168            }
6169            e => panic!("Expected HttpOperationFailed, got: {e}"),
6170        }
6171    }
6172
6173    #[tokio::test]
6174    async fn test_http_producer_no_throw_on_failure() {
6175        use tower::ServiceExt;
6176
6177        let (url, _handle) = start_status_server(500).await;
6178        let ctx = test_producer_ctx();
6179
6180        let component = HttpComponent::new();
6181        let endpoint_ctx = NoOpComponentContext;
6182        let endpoint = component
6183            .create_endpoint(
6184                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
6185                &endpoint_ctx,
6186            )
6187            .unwrap();
6188        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6189
6190        let exchange = Exchange::new(Message::default());
6191        let result = producer.oneshot(exchange).await.unwrap();
6192
6193        let status = result
6194            .input
6195            .header("CamelHttpResponseCode")
6196            .and_then(|v| v.as_u64())
6197            .unwrap();
6198        assert_eq!(status, 500);
6199    }
6200
6201    #[tokio::test]
6202    async fn test_http_producer_uri_override() {
6203        use tower::ServiceExt;
6204
6205        let (url, _handle) = start_test_server().await;
6206        let ctx = test_producer_ctx();
6207
6208        let component = HttpComponent::new();
6209        let endpoint_ctx = NoOpComponentContext;
6210        let endpoint = component
6211            .create_endpoint(
6212                "http://localhost:1/does-not-exist?allowInternal=true",
6213                &endpoint_ctx,
6214            )
6215            .unwrap();
6216        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6217
6218        let mut exchange = Exchange::new(Message::default());
6219        exchange.input.set_header(
6220            "CamelHttpUri",
6221            serde_json::Value::String(format!("{url}/api")),
6222        );
6223
6224        let result = producer.oneshot(exchange).await.unwrap();
6225        let status = result
6226            .input
6227            .header("CamelHttpResponseCode")
6228            .and_then(|v| v.as_u64())
6229            .unwrap();
6230        assert_eq!(status, 200);
6231    }
6232
6233    #[tokio::test]
6234    async fn test_http_producer_response_headers_mapped() {
6235        use tower::ServiceExt;
6236
6237        let (url, _handle) = start_test_server().await;
6238        let ctx = test_producer_ctx();
6239
6240        let component = HttpComponent::new();
6241        let endpoint_ctx = NoOpComponentContext;
6242        let endpoint = component
6243            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6244            .unwrap();
6245        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6246
6247        let exchange = Exchange::new(Message::default());
6248        let result = producer.oneshot(exchange).await.unwrap();
6249
6250        assert!(
6251            result.input.header("Content-Type").is_some(),
6252            "Response should have Content-Type header"
6253        );
6254        assert!(result.input.header("CamelHttpResponseText").is_some());
6255    }
6256
6257    // -----------------------------------------------------------------------
6258    // Bug fix tests: Client configuration per-endpoint
6259    // -----------------------------------------------------------------------
6260
6261    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
6262        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6263        let addr = listener.local_addr().unwrap();
6264        let url = format!("http://127.0.0.1:{}", addr.port());
6265
6266        let handle = tokio::spawn(async move {
6267            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6268            loop {
6269                if let Ok((mut stream, _)) = listener.accept().await {
6270                    tokio::spawn(async move {
6271                        let mut buf = vec![0u8; 4096];
6272                        let n = stream.read(&mut buf).await.unwrap_or(0);
6273                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
6274
6275                        // Check if this is a request to /final
6276                        if request.contains("GET /final") {
6277                            let body = r#"{"status":"final"}"#;
6278                            let response = format!(
6279                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
6280                                body.len(),
6281                                body
6282                            );
6283                            let _ = stream.write_all(response.as_bytes()).await;
6284                        } else {
6285                            // Redirect to /final
6286                            // Connection: close stops the client pooling the
6287                            // connection the server drops right after this
6288                            // response (pooled-race, rc-u3aw class).
6289                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6290                            let _ = stream.write_all(response.as_bytes()).await;
6291                        }
6292                    });
6293                }
6294            }
6295        });
6296
6297        (url, handle)
6298    }
6299
6300    struct CapturedRequest {
6301        method: String,
6302        path: String,
6303        body: Vec<u8>,
6304        content_length: Option<String>,
6305        transfer_encoding: Option<String>,
6306    }
6307
6308    /// Parse a request head plus its Content-Length-driven body from a freshly
6309    /// accepted connection. Returns `None` if the client closes before sending
6310    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
6311    /// keep-alive connections and never sends FIN) and does NOT rely on a
6312    /// single fixed-size read (a segmented small body would flake).
6313    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
6314        use tokio::io::AsyncReadExt;
6315
6316        // Read the request head (up to and including the terminating CRLF CRLF).
6317        let mut buf: Vec<u8> = Vec::new();
6318        let mut chunk = [0u8; 4096];
6319        let head_end: usize;
6320        loop {
6321            let n = stream.read(&mut chunk).await.unwrap_or(0);
6322            if n == 0 {
6323                return None;
6324            }
6325            buf.extend_from_slice(&chunk[..n]);
6326            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
6327                head_end = pos + 4;
6328                break;
6329            }
6330        }
6331
6332        // Parse the request head.
6333        let head = String::from_utf8_lossy(&buf[..head_end]);
6334        let mut lines = head.split("\r\n");
6335        let request_line = lines.next().unwrap_or("");
6336        let mut parts = request_line.split_whitespace();
6337        let method = parts.next().unwrap_or("").to_string();
6338        let path = parts.next().unwrap_or("").to_string();
6339
6340        let mut content_length: Option<String> = None;
6341        let mut transfer_encoding: Option<String> = None;
6342        for line in lines {
6343            if let Some((name, value)) = line.split_once(':') {
6344                let name = name.trim().to_ascii_lowercase();
6345                let value = value.trim().to_string();
6346                if name == "content-length" {
6347                    content_length = Some(value);
6348                } else if name == "transfer-encoding" {
6349                    transfer_encoding = Some(value);
6350                }
6351            }
6352        }
6353
6354        // Content-Length-driven exact read. A missing header means a 0-length body.
6355        let body_len: usize = content_length
6356            .as_deref()
6357            .and_then(|v| v.parse::<usize>().ok())
6358            .unwrap_or(0);
6359
6360        let mut body: Vec<u8> = buf[head_end..].to_vec();
6361        while body.len() < body_len {
6362            let n = stream.read(&mut chunk).await.unwrap_or(0);
6363            if n == 0 {
6364                break;
6365            }
6366            body.extend_from_slice(&chunk[..n]);
6367        }
6368        body.truncate(body_len);
6369
6370        Some(CapturedRequest {
6371            method,
6372            path,
6373            body,
6374            content_length,
6375            transfer_encoding,
6376        })
6377    }
6378
6379    /// A raw-TCP capture server. Each connection parses the request head, then
6380    /// performs a Content-Length-driven exact read of the body (see
6381    /// [`capture_request`]). Each connection is dropped after the response so
6382    /// every hop opens a fresh connection.
6383    async fn start_capture_server() -> (
6384        String,
6385        tokio::task::JoinHandle<()>,
6386        Arc<Mutex<Vec<CapturedRequest>>>,
6387    ) {
6388        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6389        let addr = listener.local_addr().unwrap();
6390        let url = format!("http://127.0.0.1:{}", addr.port());
6391
6392        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
6393        let captured_for_return = Arc::clone(&captured);
6394
6395        let handle = tokio::spawn(async move {
6396            use tokio::io::AsyncWriteExt;
6397            loop {
6398                // Per-iteration deadline (lintwiden D4.2, idle-relay
6399                // re-arm): every caller drops the returned handle, so
6400                // this accept loop is a detached server for the test
6401                // process's lifetime and quiet gaps between client
6402                // requests are normal — a lapsed deadline re-arms (as
6403                // does a transient accept error, prior behavior).
6404                // Process exit reaps the task.
6405                if let Ok(Ok((mut stream, _))) =
6406                    tokio::time::timeout(Duration::from_secs(10), listener.accept()).await
6407                {
6408                    let captured = Arc::clone(&captured);
6409                    tokio::spawn(async move {
6410                        // Per-connection deadline (lintwiden D4.2): a client
6411                        // that connects but never completes a request must
6412                        // not pin the handler task — drop the connection.
6413                        let Some(req) = tokio::time::timeout(
6414                            Duration::from_secs(10),
6415                            capture_request(&mut stream),
6416                        )
6417                        .await
6418                        .ok()
6419                        .flatten() else {
6420                            return;
6421                        };
6422                        captured.lock().unwrap().push(req);
6423
6424                        // 200 OK with Content-Length: 0 and no body, then drop
6425                        // the stream so the client opens a fresh connection.
6426                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
6427                        let _ = stream.write_all(response.as_bytes()).await;
6428                    });
6429                }
6430            }
6431        });
6432
6433        (url, handle, captured_for_return)
6434    }
6435
6436    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
6437    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
6438    /// whose `/final` path answers `200 OK` with an empty body. Every hop
6439    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
6440    /// the connection after responding so each hop is a fresh connection.
6441    async fn start_redirect_capture_server() -> (
6442        String,
6443        tokio::task::JoinHandle<()>,
6444        Arc<Mutex<Vec<CapturedRequest>>>,
6445    ) {
6446        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6447        let addr = listener.local_addr().unwrap();
6448        let url = format!("http://127.0.0.1:{}", addr.port());
6449
6450        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
6451        let captured_for_return = Arc::clone(&captured);
6452
6453        let handle = tokio::spawn(async move {
6454            use tokio::io::AsyncWriteExt;
6455            loop {
6456                // Per-iteration deadline (lintwiden D4.2, idle-relay
6457                // re-arm): every caller drops the returned handle, so
6458                // this accept loop is a detached server for the test
6459                // process's lifetime and quiet gaps between client
6460                // requests are normal — a lapsed deadline re-arms (as
6461                // does a transient accept error, prior behavior).
6462                // Process exit reaps the task.
6463                if let Ok(Ok((mut stream, _))) =
6464                    tokio::time::timeout(Duration::from_secs(10), listener.accept()).await
6465                {
6466                    let captured = Arc::clone(&captured);
6467                    tokio::spawn(async move {
6468                        // Per-connection deadline (lintwiden D4.2): a client
6469                        // that connects but never completes a request must
6470                        // not pin the handler task — drop the connection.
6471                        let Some(req) = tokio::time::timeout(
6472                            Duration::from_secs(10),
6473                            capture_request(&mut stream),
6474                        )
6475                        .await
6476                        .ok()
6477                        .flatten() else {
6478                            return;
6479                        };
6480                        let path = req.path.clone();
6481                        captured.lock().unwrap().push(req);
6482
6483                        let (status_line, location) = match path.as_str() {
6484                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
6485                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
6486                            "/final" => ("HTTP/1.1 200 OK", None),
6487                            _ => ("HTTP/1.1 404 Not Found", None),
6488                        };
6489
6490                        let response = match location {
6491                            // Connection: close stops the client pooling the
6492                            // connection this handler drops right after the
6493                            // response (pooled-race, rc-u3aw class).
6494                            Some(loc) => format!(
6495                                "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
6496                            ),
6497                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
6498                        };
6499                        let _ = stream.write_all(response.as_bytes()).await;
6500                    });
6501                }
6502            }
6503        });
6504
6505        (url, handle, captured_for_return)
6506    }
6507
6508    #[tokio::test]
6509    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
6510        use tower::ServiceExt;
6511
6512        let (url, _handle, captured) = start_capture_server().await;
6513        let ctx = test_producer_ctx();
6514
6515        let component = HttpComponent::with_config(HttpConfig::default());
6516        let endpoint_ctx = NoOpComponentContext;
6517        let endpoint = component
6518            .create_endpoint(
6519                &format!("{url}?httpMethod=GET&allowInternal=true"),
6520                &endpoint_ctx,
6521            )
6522            .unwrap();
6523        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6524
6525        let mut exchange = Exchange::new(Message::default());
6526        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6527
6528        let result = producer.oneshot(exchange).await.unwrap();
6529
6530        let status = result
6531            .input
6532            .header("CamelHttpResponseCode")
6533            .and_then(|v| v.as_u64())
6534            .unwrap();
6535        assert_eq!(status, 200);
6536
6537        let captured = captured.lock().unwrap();
6538        assert_eq!(captured.len(), 1, "expected exactly one captured request");
6539        let req = &captured[0];
6540        assert_eq!(req.method, "GET");
6541        // `httpMethod`/`allowInternal` are URI options, not request-target
6542        // query params, so the origin-form target is just "/".
6543        assert_eq!(req.path, "/");
6544        assert!(req.body.is_empty(), "GET must not carry a body");
6545        assert!(
6546            req.content_length.is_none(),
6547            "suppressed request must not carry Content-Length"
6548        );
6549        assert!(
6550            req.transfer_encoding.is_none(),
6551            "suppressed request must not carry Transfer-Encoding"
6552        );
6553
6554        // The exchange body is consumed by the producer (std::mem::take).
6555        assert!(
6556            result.input.body.is_empty(),
6557            "exchange body must be consumed"
6558        );
6559    }
6560
6561    #[tokio::test]
6562    async fn test_head_with_body_suppressed_via_header() {
6563        use tower::ServiceExt;
6564
6565        let (url, _handle, captured) = start_capture_server().await;
6566        let ctx = test_producer_ctx();
6567
6568        let component = HttpComponent::with_config(HttpConfig::default());
6569        let endpoint_ctx = NoOpComponentContext;
6570        let endpoint = component
6571            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6572            .unwrap();
6573        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6574
6575        let mut exchange = Exchange::new(Message::default());
6576        exchange.input.set_header(
6577            "CamelHttpMethod",
6578            serde_json::Value::String("HEAD".to_string()),
6579        );
6580        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6581
6582        let result = producer.oneshot(exchange).await.unwrap();
6583        let status = result
6584            .input
6585            .header("CamelHttpResponseCode")
6586            .and_then(|v| v.as_u64())
6587            .unwrap();
6588        assert_eq!(status, 200);
6589
6590        let captured = captured.lock().unwrap();
6591        assert_eq!(captured.len(), 1);
6592        let req = &captured[0];
6593        assert_eq!(req.method, "HEAD");
6594        assert!(req.body.is_empty(), "HEAD must not carry a body");
6595    }
6596
6597    #[tokio::test]
6598    async fn test_delete_options_trace_with_body_suppressed() {
6599        use tower::ServiceExt;
6600
6601        let (url, _handle, captured) = start_capture_server().await;
6602        let ctx = test_producer_ctx();
6603        let component = HttpComponent::with_config(HttpConfig::default());
6604        let endpoint_ctx = NoOpComponentContext;
6605
6606        for method in ["DELETE", "OPTIONS", "TRACE"] {
6607            let endpoint = component
6608                .create_endpoint(
6609                    &format!("{url}?httpMethod={method}&allowInternal=true"),
6610                    &endpoint_ctx,
6611                )
6612                .unwrap();
6613            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6614
6615            let mut exchange = Exchange::new(Message::default());
6616            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6617
6618            let result = producer.oneshot(exchange).await.unwrap();
6619            let status = result
6620                .input
6621                .header("CamelHttpResponseCode")
6622                .and_then(|v| v.as_u64())
6623                .unwrap();
6624            assert_eq!(status, 200, "method {method} should succeed");
6625        }
6626
6627        let captured = captured.lock().unwrap();
6628        assert_eq!(captured.len(), 3, "expected three captured requests");
6629        for method in ["DELETE", "OPTIONS", "TRACE"] {
6630            let req = captured
6631                .iter()
6632                .find(|r| r.method == method)
6633                .unwrap_or_else(|| panic!("missing captured request for {method}"));
6634            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
6635        }
6636    }
6637
6638    #[tokio::test]
6639    async fn test_post_put_patch_with_body_still_sent() {
6640        use tower::ServiceExt;
6641
6642        let (url, _handle, captured) = start_capture_server().await;
6643        let ctx = test_producer_ctx();
6644        let component = HttpComponent::with_config(HttpConfig::default());
6645        let endpoint_ctx = NoOpComponentContext;
6646
6647        for method in ["POST", "PUT", "PATCH"] {
6648            let endpoint = component
6649                .create_endpoint(
6650                    &format!("{url}?httpMethod={method}&allowInternal=true"),
6651                    &endpoint_ctx,
6652                )
6653                .unwrap();
6654            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6655
6656            let payload = format!("body-for-{method}");
6657            let mut exchange = Exchange::new(Message::default());
6658            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
6659
6660            let result = producer.oneshot(exchange).await.unwrap();
6661            let status = result
6662                .input
6663                .header("CamelHttpResponseCode")
6664                .and_then(|v| v.as_u64())
6665                .unwrap();
6666            assert_eq!(status, 200, "method {method} should succeed");
6667        }
6668
6669        let captured = captured.lock().unwrap();
6670        assert_eq!(captured.len(), 3, "expected three captured requests");
6671        for method in ["POST", "PUT", "PATCH"] {
6672            let req = captured
6673                .iter()
6674                .find(|r| r.method == method)
6675                .unwrap_or_else(|| panic!("missing captured request for {method}"));
6676            let expected = format!("body-for-{method}");
6677            assert!(!req.body.is_empty(), "{method} must still carry its body");
6678            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
6679        }
6680    }
6681
6682    /// A GET with a stream body must not attach the stream: the entity-enclosing
6683    /// gate drops the stream (mem::take) before the request is built, leaving
6684    /// the exchange body Empty instead of a partially-consumed Body::Stream.
6685    #[tokio::test]
6686    async fn test_stream_body_under_get_not_attached() {
6687        use tower::ServiceExt;
6688
6689        let (url, _handle, captured) = start_capture_server().await;
6690        let ctx = test_producer_ctx();
6691
6692        let component = HttpComponent::with_config(HttpConfig::default());
6693        let endpoint_ctx = NoOpComponentContext;
6694        let endpoint = component
6695            .create_endpoint(
6696                &format!("{url}?httpMethod=GET&allowInternal=true"),
6697                &endpoint_ctx,
6698            )
6699            .unwrap();
6700        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6701
6702        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6703            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
6704        let stream = Box::pin(futures::stream::iter(chunks));
6705        let mut exchange = Exchange::new(Message::default());
6706        exchange.input.body = Body::Stream(StreamBody {
6707            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6708            metadata: StreamMetadata::default(),
6709        });
6710
6711        let result = producer.oneshot(exchange).await.unwrap();
6712
6713        let status = result
6714            .input
6715            .header("CamelHttpResponseCode")
6716            .and_then(|v| v.as_u64())
6717            .unwrap();
6718        assert_eq!(status, 200);
6719
6720        let captured = captured.lock().unwrap();
6721        assert_eq!(captured.len(), 1, "expected exactly one captured request");
6722        assert!(
6723            captured[0].body.is_empty(),
6724            "GET must not carry a stream body"
6725        );
6726        assert!(
6727            captured[0].transfer_encoding.is_none(),
6728            "suppressed request must not carry Transfer-Encoding"
6729        );
6730        assert!(
6731            captured[0].content_length.is_none(),
6732            "suppressed request must not carry Content-Length"
6733        );
6734        assert!(
6735            result.input.body.is_empty(),
6736            "exchange body must be consumed to Empty, not left as a stream"
6737        );
6738    }
6739
6740    /// A suppressed body must never be replayed across 307/308 redirect hops:
6741    /// the gate empties `materialized_body` before the redirect loop runs, so
6742    /// neither the first hop nor the final hop carries the body.
6743    #[tokio::test]
6744    async fn test_redirect_hops_never_replay_suppressed_body() {
6745        use tower::ServiceExt;
6746
6747        let (url, _handle, captured) = start_redirect_capture_server().await;
6748        let ctx = test_producer_ctx();
6749
6750        let component =
6751            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6752        let endpoint_ctx = NoOpComponentContext;
6753
6754        for path in ["/hop307", "/hop308"] {
6755            let endpoint = component
6756                .create_endpoint(
6757                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
6758                    &endpoint_ctx,
6759                )
6760                .unwrap();
6761            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6762
6763            let mut exchange = Exchange::new(Message::default());
6764            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6765
6766            let result = producer.oneshot(exchange).await.unwrap();
6767            let status = result
6768                .input
6769                .header("CamelHttpResponseCode")
6770                .and_then(|v| v.as_u64())
6771                .unwrap();
6772            assert_eq!(
6773                status, 200,
6774                "redirect chain for {path} should end at /final"
6775            );
6776        }
6777
6778        // Two chains (307 and 308), each with two hops (redirect + final).
6779        let captured = captured.lock().unwrap();
6780        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6781        for req in captured.iter() {
6782            assert!(
6783                req.body.is_empty(),
6784                "hop {} {} must not carry a body",
6785                req.method,
6786                req.path
6787            );
6788        }
6789    }
6790
6791    /// The warn! emitted on a suppressed body renders three distinguishable
6792    /// substrings in the log line (tracing-subscriber default field format):
6793    ///   - the message:       "dropping request body ..."
6794    ///   - `method = %method_str`            → `method=GET`
6795    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
6796    /// The closure matches all three so exactly one warn per suppressed
6797    /// request is required (the "HTTP request" debug! also carries
6798    /// `method=GET` and the same `correlation_id=`, but not the message).
6799    #[tracing_test::traced_test]
6800    #[tokio::test]
6801    async fn test_suppressed_body_logs_exactly_one_warn() {
6802        use tower::ServiceExt;
6803
6804        let (url, _handle, _captured) = start_capture_server().await;
6805        let ctx = test_producer_ctx();
6806
6807        let component = HttpComponent::with_config(HttpConfig::default());
6808        let endpoint_ctx = NoOpComponentContext;
6809        let endpoint = component
6810            .create_endpoint(
6811                &format!("{url}?httpMethod=GET&allowInternal=true"),
6812                &endpoint_ctx,
6813            )
6814            .unwrap();
6815        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6816
6817        let mut exchange = Exchange::new(Message::default());
6818        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6819        let correlation_id = exchange.correlation_id().to_string();
6820
6821        let result = producer.oneshot(exchange).await.unwrap();
6822        let status = result
6823            .input
6824            .header("CamelHttpResponseCode")
6825            .and_then(|v| v.as_u64())
6826            .unwrap();
6827        assert_eq!(status, 200);
6828
6829        logs_assert(|lines: &[&str]| {
6830            let hits = lines
6831                .iter()
6832                .filter(|l| {
6833                    l.contains("dropping request body")
6834                        && l.contains("method=GET")
6835                        && l.contains(&format!("correlation_id={correlation_id}"))
6836                })
6837                .count();
6838            match hits {
6839                1 => Ok(()),
6840                n => Err(format!("expected exactly one body-drop warn, found {n}")),
6841            }
6842        });
6843    }
6844
6845    #[tracing_test::traced_test]
6846    #[tokio::test]
6847    async fn test_empty_body_get_emits_no_warn() {
6848        use tower::ServiceExt;
6849
6850        let (url, _handle, _captured) = start_capture_server().await;
6851        let ctx = test_producer_ctx();
6852
6853        let component = HttpComponent::with_config(HttpConfig::default());
6854        let endpoint_ctx = NoOpComponentContext;
6855        let endpoint = component
6856            .create_endpoint(
6857                &format!("{url}?httpMethod=GET&allowInternal=true"),
6858                &endpoint_ctx,
6859            )
6860            .unwrap();
6861        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6862
6863        let exchange = Exchange::new(Message::default());
6864        let result = producer.oneshot(exchange).await.unwrap();
6865        let status = result
6866            .input
6867            .header("CamelHttpResponseCode")
6868            .and_then(|v| v.as_u64())
6869            .unwrap();
6870        assert_eq!(status, 200);
6871
6872        logs_assert(|lines: &[&str]| {
6873            let hits = lines
6874                .iter()
6875                .filter(|l| l.contains("dropping request body"))
6876                .count();
6877            match hits {
6878                0 => Ok(()),
6879                n => Err(format!("expected no body-drop warn, found {n}")),
6880            }
6881        });
6882    }
6883
6884    #[tokio::test]
6885    async fn test_follow_redirects_false_does_not_follow() {
6886        use tower::ServiceExt;
6887
6888        let (url, _handle) = start_redirect_server().await;
6889        let ctx = test_producer_ctx();
6890
6891        let component =
6892            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6893        let endpoint_ctx = NoOpComponentContext;
6894        let endpoint = component
6895            .create_endpoint(
6896                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6897                &endpoint_ctx,
6898            )
6899            .unwrap();
6900        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6901
6902        let exchange = Exchange::new(Message::default());
6903        let result = producer.oneshot(exchange).await.unwrap();
6904
6905        // Should get 302, NOT follow redirect to 200
6906        let status = result
6907            .input
6908            .header("CamelHttpResponseCode")
6909            .and_then(|v| v.as_u64())
6910            .unwrap();
6911        assert_eq!(
6912            status, 302,
6913            "Should NOT follow redirect when followRedirects=false"
6914        );
6915    }
6916
6917    #[tokio::test]
6918    async fn test_follow_redirects_true_follows_redirect() {
6919        use tower::ServiceExt;
6920
6921        let (url, _handle) = start_redirect_server().await;
6922        let ctx = test_producer_ctx();
6923
6924        let component =
6925            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6926        let endpoint_ctx = NoOpComponentContext;
6927        let endpoint = component
6928            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6929            .unwrap();
6930        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6931
6932        let exchange = Exchange::new(Message::default());
6933        let result = producer.oneshot(exchange).await.unwrap();
6934
6935        // Should follow redirect and get 200
6936        let status = result
6937            .input
6938            .header("CamelHttpResponseCode")
6939            .and_then(|v| v.as_u64())
6940            .unwrap();
6941        assert_eq!(
6942            status, 200,
6943            "Should follow redirect when followRedirects=true"
6944        );
6945    }
6946
6947    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
6948    /// This verifies the manual redirect loop executes correctly.
6949    #[tokio::test]
6950    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6951        use tower::ServiceExt;
6952
6953        // Use the existing redirect server which redirects to /final on the same server
6954        let (url, _handle) = start_redirect_server().await;
6955        let ctx = test_producer_ctx();
6956
6957        let component =
6958            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6959        let endpoint_ctx = NoOpComponentContext;
6960        let endpoint = component
6961            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6962            .unwrap();
6963        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6964
6965        let exchange = Exchange::new(Message::default());
6966        let result = producer.oneshot(exchange).await;
6967
6968        // With allowInternal=true, the redirect should succeed
6969        assert!(
6970            result.is_ok(),
6971            "Redirect should succeed with allowInternal=true, got: {:?}",
6972            result
6973        );
6974        let exchange = result.unwrap();
6975        let status = exchange
6976            .input
6977            .header("CamelHttpResponseCode")
6978            .and_then(|v| v.as_u64())
6979            .unwrap();
6980        assert_eq!(status, 200, "Should follow redirect to /final");
6981    }
6982
6983    /// With allowInternal=true, redirects to private IPs should be followed.
6984    #[tokio::test]
6985    async fn test_redirect_to_private_ip_allowed_when_configured() {
6986        use tower::ServiceExt;
6987
6988        // Start a server that redirects to /final on the same server (127.0.0.1)
6989        let (url, _handle) = start_redirect_server().await;
6990        let ctx = test_producer_ctx();
6991
6992        let component =
6993            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6994        let endpoint_ctx = NoOpComponentContext;
6995        let endpoint = component
6996            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6997            .unwrap();
6998        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6999
7000        let exchange = Exchange::new(Message::default());
7001        let result = producer.oneshot(exchange).await.unwrap();
7002
7003        let status = result
7004            .input
7005            .header("CamelHttpResponseCode")
7006            .and_then(|v| v.as_u64())
7007            .unwrap();
7008        assert_eq!(
7009            status, 200,
7010            "Should follow redirect to private IP when allowInternal=true"
7011        );
7012    }
7013
7014    /// Integration test: with allowInternal=false (default), a redirect to a
7015    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
7016    #[tokio::test]
7017    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
7018        use tower::ServiceExt;
7019
7020        // Server that redirects to the AWS metadata endpoint (link-local private IP)
7021        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7022        let addr = listener.local_addr().unwrap();
7023        let url = format!("http://127.0.0.1:{}", addr.port());
7024
7025        let handle = tokio::spawn(async move {
7026            use tokio::io::{AsyncReadExt, AsyncWriteExt};
7027            loop {
7028                if let Ok((mut stream, _)) = listener.accept().await {
7029                    tokio::spawn(async move {
7030                        let mut buf = vec![0u8; 4096];
7031                        let _ = stream.read(&mut buf).await;
7032                        // Always redirect to the metadata endpoint
7033                        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";
7034                        let _ = stream.write_all(response.as_bytes()).await;
7035                    });
7036                }
7037            }
7038        });
7039
7040        let ctx = test_producer_ctx();
7041        let component =
7042            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
7043        let endpoint_ctx = NoOpComponentContext;
7044        // allowInternal=false is the default — do NOT set it
7045        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
7046        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7047
7048        let exchange = Exchange::new(Message::default());
7049        let result = producer.oneshot(exchange).await;
7050
7051        // Must be an error — SSRF guard blocks the redirect target
7052        assert!(
7053            result.is_err(),
7054            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
7055        );
7056        let err = result.unwrap_err().to_string();
7057        assert!(
7058            err.contains("blocked IP")
7059                || err.contains("private IP")
7060                || err.contains("SSRF")
7061                || err.contains("not allowed"),
7062            "Error should mention SSRF/IP blocking, got: {err}"
7063        );
7064
7065        handle.abort();
7066    }
7067
7068    /// Integration test: exceeding maxRedirects produces a clear error.
7069    #[tokio::test]
7070    async fn test_too_many_redirects_returns_error() {
7071        use tower::ServiceExt;
7072
7073        // Server that always redirects to itself (infinite loop)
7074        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7075        let addr = listener.local_addr().unwrap();
7076        let url = format!("http://127.0.0.1:{}", addr.port());
7077
7078        let handle = tokio::spawn(async move {
7079            use tokio::io::{AsyncReadExt, AsyncWriteExt};
7080            loop {
7081                if let Ok((mut stream, _)) = listener.accept().await {
7082                    tokio::spawn(async move {
7083                        let mut buf = vec![0u8; 4096];
7084                        let _ = stream.read(&mut buf).await;
7085                        // Always redirect to /loop
7086                        // Connection: close stops the client pooling the
7087                        // connection the server drops right after this
7088                        // response (pooled-race, rc-u3aw).
7089                        let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
7090                        let _ = stream.write_all(response.as_bytes()).await;
7091                    });
7092                }
7093            }
7094        });
7095
7096        let ctx = test_producer_ctx();
7097        let component =
7098            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
7099        let endpoint_ctx = NoOpComponentContext;
7100        let endpoint = component
7101            .create_endpoint(
7102                &format!("{url}?allowInternal=true&maxRedirects=2"),
7103                &endpoint_ctx,
7104            )
7105            .unwrap();
7106        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7107
7108        let exchange = Exchange::new(Message::default());
7109        let result = producer.oneshot(exchange).await;
7110
7111        // With the fix, exceeding max redirects returns the redirect response
7112        // as-is instead of erroring. The 302 redirect response is returned
7113        // after followRedirects exhausts the allowed redirect count (2).
7114        // Disable throwExceptionOnFailure to inspect the raw response status.
7115        //
7116        // Old behavior: Err("Too many redirects (max 2)")
7117        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
7118        match result {
7119            Err(e) => {
7120                // If throw_exception_on_failure is on, we get HttpOperationFailed
7121                let msg = e.to_string();
7122                assert!(
7123                    msg.contains("HTTP operation failed") || msg.contains("302"),
7124                    "expected redirect-after-exhaustion error, got: {msg}"
7125                );
7126            }
7127            Ok(ex) => {
7128                let response_code = ex
7129                    .input
7130                    .header("CamelHttpResponseCode")
7131                    .and_then(|v| v.as_u64());
7132                assert_eq!(
7133                    response_code,
7134                    Some(302),
7135                    "expected 302 after exhausting redirects"
7136                );
7137            }
7138        }
7139
7140        handle.abort();
7141    }
7142
7143    #[tokio::test]
7144    async fn test_query_params_forwarded_to_http_request() {
7145        use tower::ServiceExt;
7146
7147        let (url, _handle) = start_test_server().await;
7148        let ctx = test_producer_ctx();
7149
7150        let component = HttpComponent::new();
7151        let endpoint_ctx = NoOpComponentContext;
7152        // apiKey is NOT a Camel option, should be forwarded as query param
7153        let endpoint = component
7154            .create_endpoint(
7155                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
7156                &endpoint_ctx,
7157            )
7158            .unwrap();
7159        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7160
7161        let exchange = Exchange::new(Message::default());
7162        let result = producer.oneshot(exchange).await.unwrap();
7163
7164        // The test server returns the request info in response
7165        // We just verify it succeeds (the query param was sent)
7166        let status = result
7167            .input
7168            .header("CamelHttpResponseCode")
7169            .and_then(|v| v.as_u64())
7170            .unwrap();
7171        assert_eq!(status, 200);
7172    }
7173
7174    #[test]
7175    fn test_non_camel_query_params_are_forwarded() {
7176        // Authored pairs ride raw_query (the sole carrier); query_params is
7177        // programmatic-only (http-query-wire-fidelity).
7178        let config = HttpEndpointConfig::from_uri(
7179            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
7180        )
7181        .unwrap();
7182
7183        // apiKey and token are NOT camel-http options: the authored bytes
7184        // (including the interleaved httpMethod) ride raw_query verbatim.
7185        assert_eq!(
7186            config.raw_query.as_deref(),
7187            Some("apiKey=secret123&httpMethod=GET&token=abc456")
7188        );
7189        assert!(config.query_params.is_empty());
7190    }
7191
7192    #[test]
7193    fn test_authored_query_bytes_survive_resolve_url() {
7194        let config =
7195            HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
7196        let exchange = Exchange::new(Message::default());
7197
7198        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7199
7200        // Authored bytes ride verbatim: `%20` stays `%20` (never re-encoded
7201        // to `+` or double-encoded) and `+` stays `+`.
7202        assert!(url.contains("q=hello%20world"), "url was: {url}");
7203        assert!(url.contains("tag=a+b"), "url was: {url}");
7204    }
7205
7206    // -----------------------------------------------------------------------
7207    // Timeout tests (HTTP-004)
7208    // -----------------------------------------------------------------------
7209
7210    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
7211        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7212        let addr = listener.local_addr().unwrap();
7213        let url = format!("http://127.0.0.1:{}", addr.port());
7214
7215        let handle = tokio::spawn(async move {
7216            loop {
7217                if let Ok((mut stream, _)) = listener.accept().await {
7218                    let delay = delay_ms;
7219                    tokio::spawn(async move {
7220                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
7221                        let mut buf = vec![0u8; 4096];
7222                        let _ = stream.read(&mut buf).await;
7223                        // Send headers immediately (no Content-Length → chunked)
7224                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
7225                        let _ = stream.write_all(headers.as_bytes()).await;
7226                        // Delay before sending body chunk
7227                        tokio::time::sleep(Duration::from_millis(delay)).await;
7228                        let body = r#"{"status":"slow"}"#;
7229                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
7230                        let _ = stream.write_all(chunk.as_bytes()).await;
7231                    });
7232                }
7233            }
7234        });
7235
7236        (url, handle)
7237    }
7238
7239    #[tokio::test]
7240    async fn test_http_producer_timeout() {
7241        use tower::ServiceExt;
7242
7243        // Server delays 500ms, client timeout is 100ms → should timeout
7244        let (url, _handle) = start_slow_server(500).await;
7245        let ctx = test_producer_ctx();
7246
7247        let component = HttpComponent::with_config(
7248            HttpConfig::default()
7249                .with_read_timeout_ms(100)
7250                .with_response_timeout_ms(30_000), // generous response timeout
7251        );
7252        let endpoint_ctx = NoOpComponentContext;
7253        let endpoint = component
7254            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
7255            .unwrap();
7256        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7257
7258        let exchange = Exchange::new(Message::default());
7259        let result = producer.oneshot(exchange).await;
7260
7261        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
7262        let err = result.unwrap_err().to_string();
7263        assert!(
7264            err.contains("Read timeout") || err.contains("timeout"),
7265            "Error should mention timeout, got: {}",
7266            err
7267        );
7268    }
7269
7270    #[tokio::test]
7271    async fn test_http_producer_no_timeout_when_fast() {
7272        use tower::ServiceExt;
7273
7274        let (url, _handle) = start_test_server().await;
7275        let ctx = test_producer_ctx();
7276
7277        let component =
7278            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
7279        let endpoint_ctx = NoOpComponentContext;
7280        let endpoint = component
7281            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7282            .unwrap();
7283        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7284
7285        let exchange = Exchange::new(Message::default());
7286        let result = producer.oneshot(exchange).await.unwrap();
7287
7288        let status = result
7289            .input
7290            .header("CamelHttpResponseCode")
7291            .and_then(|v| v.as_u64())
7292            .unwrap();
7293        assert_eq!(status, 200);
7294    }
7295
7296    // -----------------------------------------------------------------------
7297    // SSRF Protection tests
7298    // -----------------------------------------------------------------------
7299
7300    #[tokio::test]
7301    async fn test_http_producer_blocks_metadata_endpoint() {
7302        use tower::ServiceExt;
7303
7304        let ctx = test_producer_ctx();
7305        let component = HttpComponent::new();
7306        let endpoint_ctx = NoOpComponentContext;
7307        let endpoint = component
7308            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
7309            .unwrap();
7310        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7311
7312        let mut exchange = Exchange::new(Message::default());
7313        exchange.input.set_header(
7314            "CamelHttpUri",
7315            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
7316        );
7317
7318        let result = producer.oneshot(exchange).await;
7319        assert!(result.is_err(), "Should block AWS metadata endpoint");
7320
7321        let err = result.unwrap_err();
7322        assert!(
7323            err.to_string().contains("Private IP"),
7324            "Error should mention private IP blocking, got: {}",
7325            err
7326        );
7327    }
7328
7329    #[test]
7330    fn test_ssrf_config_defaults() {
7331        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
7332        assert!(
7333            !config.allow_internal,
7334            "Private IPs should be blocked by default"
7335        );
7336        assert!(
7337            config.blocked_hosts.is_empty(),
7338            "Blocked hosts should be empty by default"
7339        );
7340    }
7341
7342    #[test]
7343    fn test_ssrf_config_allow_internal() {
7344        let config =
7345            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
7346        assert!(
7347            config.allow_internal,
7348            "Private IPs should be allowed when explicitly set"
7349        );
7350    }
7351
7352    #[test]
7353    fn test_uri_option_allow_cleartext_parses() {
7354        let config =
7355            HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=true").unwrap();
7356        assert!(
7357            config.allow_cleartext,
7358            "allowCleartext=true must parse into the endpoint config"
7359        );
7360
7361        let plain = HttpEndpointConfig::from_uri("http://example.com/").unwrap();
7362        assert!(
7363            !plain.allow_cleartext,
7364            "cleartext consent must default to false"
7365        );
7366
7367        let err =
7368            HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=banana").unwrap_err();
7369        assert!(
7370            matches!(&err, CamelError::InvalidUri(msg) if msg.contains("allowCleartext")),
7371            "bad allowCleartext value must yield InvalidUri naming the option, got: {err:?}"
7372        );
7373    }
7374
7375    /// ADR-0081: a CamelHttpUri override to a public cleartext target is
7376    /// gated by the endpoint's `allowCleartext` consent — override URLs go
7377    /// through the same `validate_url_for_ssrf` as the base URL.
7378    #[test]
7379    fn test_camel_http_uri_override_public_cleartext_follows_endpoint_flags() {
7380        let endpoint =
7381            HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=false").unwrap();
7382        let err = crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint)
7383            .expect_err("public cleartext override must be rejected without consent");
7384        assert!(
7385            err.to_string().contains("allowCleartext"),
7386            "error must name the remedy, got: {err}"
7387        );
7388
7389        let endpoint =
7390            HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=true").unwrap();
7391        assert!(
7392            crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint).is_ok(),
7393            "endpoint consent must admit a public cleartext override"
7394        );
7395    }
7396
7397    #[test]
7398    fn test_ssrf_config_blocked_hosts() {
7399        let config = HttpEndpointConfig::from_uri(
7400            "http://example.com/api?blockedHosts=evil.com,malware.net",
7401        )
7402        .unwrap();
7403        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
7404    }
7405
7406    #[tokio::test]
7407    async fn test_http_producer_blocks_localhost() {
7408        use tower::ServiceExt;
7409
7410        let ctx = test_producer_ctx();
7411        let component = HttpComponent::new();
7412        let endpoint_ctx = NoOpComponentContext;
7413        let endpoint = component
7414            .create_endpoint("http://example.com/api", &endpoint_ctx)
7415            .unwrap();
7416        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7417
7418        let mut exchange = Exchange::new(Message::default());
7419        exchange.input.set_header(
7420            "CamelHttpUri",
7421            serde_json::Value::String("http://localhost:8080/internal".to_string()),
7422        );
7423
7424        let result = producer.oneshot(exchange).await;
7425        assert!(result.is_err(), "Should block localhost");
7426    }
7427
7428    #[tokio::test]
7429    async fn test_http_producer_blocks_loopback_ip() {
7430        use tower::ServiceExt;
7431
7432        let ctx = test_producer_ctx();
7433        let component = HttpComponent::new();
7434        let endpoint_ctx = NoOpComponentContext;
7435        let endpoint = component
7436            .create_endpoint("http://example.com/api", &endpoint_ctx)
7437            .unwrap();
7438        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7439
7440        let mut exchange = Exchange::new(Message::default());
7441        exchange.input.set_header(
7442            "CamelHttpUri",
7443            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
7444        );
7445
7446        let result = producer.oneshot(exchange).await;
7447        assert!(result.is_err(), "Should block loopback IP");
7448    }
7449
7450    #[tokio::test]
7451    async fn test_http_producer_allows_private_ip_when_enabled() {
7452        use tower::ServiceExt;
7453
7454        let ctx = test_producer_ctx();
7455        let component = HttpComponent::new();
7456        let endpoint_ctx = NoOpComponentContext;
7457        // With allowInternal=true, the validation should pass
7458        // (actual connection will fail, but that's expected)
7459        let endpoint = component
7460            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
7461            .unwrap();
7462        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7463
7464        let exchange = Exchange::new(Message::default());
7465
7466        // The request will fail because we can't connect, but it should NOT fail
7467        // due to SSRF protection
7468        let result = producer.oneshot(exchange).await;
7469        // We expect connection error, not SSRF error
7470        if let Err(ref e) = result {
7471            let err_str = e.to_string();
7472            assert!(
7473                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
7474                "Should not be SSRF error, got: {}",
7475                err_str
7476            );
7477        }
7478    }
7479
7480    // -----------------------------------------------------------------------
7481    // HttpServerConfig tests
7482    // -----------------------------------------------------------------------
7483
7484    #[test]
7485    fn test_http_server_config_parse() {
7486        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
7487        assert_eq!(cfg.host, "0.0.0.0");
7488        assert_eq!(cfg.port, 8080);
7489        assert_eq!(cfg.path, "/orders");
7490        assert_eq!(cfg.max_inflight_requests, 1024);
7491    }
7492
7493    #[test]
7494    fn test_http_server_config_scheme() {
7495        // UriConfig trait method returns "http" as primary scheme
7496        assert_eq!(HttpServerConfig::scheme(), "http");
7497    }
7498
7499    #[test]
7500    fn test_http_server_config_from_components() {
7501        // Test from_components directly (trait method)
7502        let components = camel_component_api::UriComponents {
7503            scheme: "https".to_string(),
7504            path: "//0.0.0.0:8443/api".to_string(),
7505            params: std::collections::HashMap::from([
7506                ("maxRequestBody".to_string(), "5242880".to_string()),
7507                ("maxInflightRequests".to_string(), "7".to_string()),
7508            ]),
7509            raw_query: None,
7510        };
7511        let cfg = HttpServerConfig::from_components(components).unwrap();
7512        assert_eq!(cfg.host, "0.0.0.0");
7513        assert_eq!(cfg.port, 8443);
7514        assert_eq!(cfg.path, "/api");
7515        assert_eq!(cfg.max_request_body, 5242880);
7516        assert_eq!(cfg.max_inflight_requests, 7);
7517    }
7518
7519    #[test]
7520    fn test_http_server_config_default_path() {
7521        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
7522        assert_eq!(cfg.path, "/");
7523    }
7524
7525    #[test]
7526    fn test_http_server_config_wrong_scheme() {
7527        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
7528    }
7529
7530    #[test]
7531    fn test_http_server_config_invalid_port() {
7532        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
7533    }
7534
7535    #[test]
7536    fn test_http_server_config_default_port_by_scheme() {
7537        // HTTP without explicit port should default to 80
7538        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
7539        assert_eq!(cfg_http.port, 80);
7540
7541        // HTTPS without explicit port should default to 443
7542        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
7543        assert_eq!(cfg_https.port, 443);
7544    }
7545
7546    #[test]
7547    fn test_request_envelope_and_reply_are_send() {
7548        fn assert_send<T: Send>() {}
7549        assert_send::<RequestEnvelope>();
7550        assert_send::<HttpReply>();
7551    }
7552
7553    // -----------------------------------------------------------------------
7554    // ServerRegistry tests
7555    // -----------------------------------------------------------------------
7556
7557    #[test]
7558    fn test_server_registry_global_is_singleton() {
7559        let r1 = ServerRegistry::global();
7560        let r2 = ServerRegistry::global();
7561        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
7562    }
7563
7564    #[allow(clippy::await_holding_lock)]
7565    #[tokio::test]
7566    async fn test_concurrent_get_or_spawn_returns_same_registry() {
7567        let _guard = lock_registry_test_mutex();
7568        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7569        let port = listener.local_addr().unwrap().port();
7570        drop(listener);
7571
7572        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
7573            Arc::new(std::sync::Mutex::new(Vec::new()));
7574
7575        let mut handles = Vec::new();
7576        for _ in 0..4 {
7577            let results = results.clone();
7578            handles.push(tokio::spawn(async move {
7579                let registry = ServerRegistry::global()
7580                    .get_or_spawn(
7581                        "127.0.0.1",
7582                        port,
7583                        2 * 1024 * 1024,
7584                        10 * 1024 * 1024,
7585                        1024,
7586                        test_rt(),
7587                        "test-route".into(),
7588                        None,
7589                    )
7590                    .await
7591                    .unwrap();
7592                results.lock().unwrap().push(registry);
7593            }));
7594        }
7595
7596        for h in handles {
7597            h.await.unwrap();
7598        }
7599
7600        let registries = results.lock().unwrap();
7601        assert_eq!(registries.len(), 4);
7602        for i in 1..registries.len() {
7603            assert!(
7604                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
7605                "all concurrent callers should get same route registry"
7606            );
7607        }
7608    }
7609
7610    #[test]
7611    fn test_server_registry_distinguishes_host_and_port() {
7612        let _guard = lock_registry_test_mutex();
7613        let rt = tokio::runtime::Runtime::new().expect("runtime");
7614        rt.block_on(async {
7615            let registry = ServerRegistry::global();
7616            // Use two distinct host values with same configured port key.
7617            // Port 0 is acceptable here because the registry key uses the configured
7618            // tuple, not the OS-assigned ephemeral port.
7619            let d1 = registry
7620                .get_or_spawn(
7621                    "127.0.0.1",
7622                    0,
7623                    1024 * 1024,
7624                    10 * 1024 * 1024,
7625                    1024,
7626                    test_rt(),
7627                    "test-route-1".into(),
7628                    None,
7629                )
7630                .await;
7631            let d2 = registry
7632                .get_or_spawn(
7633                    "0.0.0.0",
7634                    0,
7635                    1024 * 1024,
7636                    10 * 1024 * 1024,
7637                    1024,
7638                    test_rt(),
7639                    "test-route-2".into(),
7640                    None,
7641                )
7642                .await;
7643            assert!(d1.is_ok());
7644            assert!(d2.is_ok());
7645            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
7646        });
7647    }
7648
7649    #[allow(clippy::await_holding_lock)]
7650    #[tokio::test]
7651    async fn test_shared_server_max_request_body_policy_is_deterministic() {
7652        let _guard = lock_registry_test_mutex();
7653        let registry = ServerRegistry::global();
7654        // First registration: maxRequestBody = 1 MB
7655        let d1 = registry
7656            .get_or_spawn(
7657                "127.0.0.1",
7658                9991,
7659                1024 * 1024,
7660                10 * 1024 * 1024,
7661                1024,
7662                test_rt(),
7663                "test-route".into(),
7664                None,
7665            )
7666            .await;
7667        assert!(d1.is_ok());
7668
7669        // Second registration on same (host,port): maxRequestBody = 2 MB
7670        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
7671        let d2 = registry
7672            .get_or_spawn(
7673                "127.0.0.1",
7674                9991,
7675                2 * 1024 * 1024,
7676                10 * 1024 * 1024,
7677                1024,
7678                test_rt(),
7679                "test-route-2".into(),
7680                None,
7681            )
7682            .await;
7683        assert!(d2.is_err());
7684        let err = d2.unwrap_err();
7685        assert!(
7686            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
7687            "Expected incompatible maxRequestBody error, got: {}",
7688            err
7689        );
7690    }
7691
7692    #[test]
7693    fn test_server_registry_reset_clears_entries() {
7694        let _guard = lock_registry_test_mutex();
7695        let rt = tokio::runtime::Runtime::new().expect("runtime");
7696        rt.block_on(async {
7697            // Register something on a unique port
7698            let d1 = ServerRegistry::global()
7699                .get_or_spawn(
7700                    "127.0.0.1",
7701                    9992,
7702                    1024 * 1024,
7703                    10 * 1024 * 1024,
7704                    1024,
7705                    test_rt(),
7706                    "test-route".into(),
7707                    None,
7708                )
7709                .await;
7710            assert!(d1.is_ok());
7711
7712            // Verify entry exists
7713            let guard = ServerRegistry::global().inner.lock().expect("lock");
7714            assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
7715            drop(guard);
7716
7717            // Reset
7718            ServerRegistry::reset();
7719
7720            // Verify cleared
7721            let guard = ServerRegistry::global().inner.lock().expect("lock");
7722            assert!(
7723                guard.entries.is_empty(),
7724                "registry should be empty after reset, has {} entries",
7725                guard.entries.len()
7726            );
7727        });
7728    }
7729
7730    #[allow(clippy::await_holding_lock)]
7731    #[tokio::test]
7732    async fn registry_rejects_tls_on_plain_port() {
7733        // httpflake: this reset previously ran WITHOUT the registry test
7734        // mutex, so it could wipe another test's freshly staged entry
7735        // mid-window (traced 2026-09-14) — spec law: every reset caller
7736        // holds REGISTRY_TEST_MUTEX.
7737        let _guard = lock_registry_test_mutex();
7738        ServerRegistry::reset();
7739        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7740
7741        // First route: plain HTTP
7742        let _r1 = ServerRegistry::global()
7743            .get_or_spawn(
7744                "127.0.0.1",
7745                0,
7746                1024,
7747                1024,
7748                16,
7749                Arc::clone(&rt),
7750                "route-1".into(),
7751                None, // plain
7752            )
7753            .await;
7754
7755        // Second route: TLS on same port → must fail
7756        let result = ServerRegistry::global()
7757            .get_or_spawn(
7758                "127.0.0.1",
7759                0,
7760                1024,
7761                1024,
7762                16,
7763                Arc::clone(&rt),
7764                "route-2".into(),
7765                Some(crate::config::ServerTlsConfig {
7766                    cert_path: "/x.pem".into(),
7767                    key_path: "/y.pem".into(),
7768                }),
7769            )
7770            .await;
7771        assert!(result.is_err(), "must reject TLS on plain port");
7772    }
7773
7774    // -----------------------------------------------------------------------
7775    // D-L10: HTTP server is process-lifetime — it survives consumer
7776    // unregister (no refcount; dead servers are evicted on next spawn)
7777    // -----------------------------------------------------------------------
7778
7779    #[allow(clippy::await_holding_lock)]
7780    #[tokio::test]
7781    async fn test_unregister_last_http_route_keeps_server_alive() {
7782        let _guard = lock_registry_test_mutex();
7783        ServerRegistry::reset();
7784        let registry = ServerRegistry::global();
7785
7786        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7787        let port = listener.local_addr().unwrap().port();
7788        drop(listener); // Release — ServerRegistry will rebind
7789        let rt = test_rt();
7790
7791        // Register 2 routes on the same (host, port) — OnceCell returns the
7792        // same ServerHandle.
7793        let _r1 = registry
7794            .get_or_spawn(
7795                "127.0.0.1",
7796                port,
7797                1024 * 1024,
7798                10 * 1024 * 1024,
7799                16,
7800                rt.clone(),
7801                "test-route-1".into(),
7802                None,
7803            )
7804            .await
7805            .unwrap();
7806        let _r2 = registry
7807            .get_or_spawn(
7808                "127.0.0.1",
7809                port,
7810                1024 * 1024,
7811                10 * 1024 * 1024,
7812                16,
7813                rt,
7814                "test-route-2".into(),
7815                None,
7816            )
7817            .await
7818            .unwrap();
7819
7820        let key = ("127.0.0.1".to_string(), port);
7821        let cell = {
7822            let guard = registry.inner.lock().expect("lock");
7823            guard.entries.get(&key).expect("entry should exist").clone()
7824        };
7825
7826        // Unregister first route -> monitor still alive (count = 1).
7827        registry.unregister("127.0.0.1", port).await;
7828        {
7829            let handle = cell
7830                .get()
7831                .expect("handle should still exist after first unregister");
7832            assert!(
7833                !handle.monitor_task.is_finished(),
7834                "monitor task should still be alive after first unregister"
7835            );
7836        }
7837
7838        // Unregister second route -> server stays alive (process-lifetime).
7839        registry.unregister("127.0.0.1", port).await;
7840        tokio::time::sleep(Duration::from_millis(20)).await;
7841        {
7842            let handle = cell
7843                .get()
7844                .expect("handle should still exist after last unregister");
7845            assert!(
7846                !handle.monitor_task.is_finished(),
7847                "monitor task should still be alive — server is process-lifetime"
7848            );
7849        }
7850
7851        // Entry stays in registry for potential restart.
7852        {
7853            let guard = registry.inner.lock().expect("lock");
7854            assert!(
7855                guard.entries.contains_key(&key),
7856                "entry should remain in registry — server kept alive for restart"
7857            );
7858        }
7859    }
7860
7861    // -----------------------------------------------------------------------
7862    // Staged listeners (itest-bound-ports Task 1)
7863    // -----------------------------------------------------------------------
7864
7865    /// CLONE-FIXTURE: bind a std listener on `127.0.0.1:0`, retain a blocking
7866    /// std clone (`probe`) so the port stays reserved, and hand the original
7867    /// socket to tokio as a non-blocking listener. `tokio::net::TcpListener`
7868    /// has no `try_clone`, so clones come from the std handle.
7869    async fn clone_fixture_listener() -> (
7870        tokio::net::TcpListener,
7871        std::net::TcpListener,
7872        std::net::SocketAddr,
7873    ) {
7874        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7875        let probe = l.try_clone().expect("clone probe");
7876        l.set_nonblocking(true).expect("set_nonblocking");
7877        let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7878        let addr = listener.local_addr().expect("local_addr");
7879        (listener, probe, addr)
7880    }
7881
7882    /// Default-limit constants the existing registry tests in this file use.
7883    fn staged_limits() -> (usize, usize, usize) {
7884        (1024 * 1024, 10 * 1024 * 1024, 1024)
7885    }
7886
7887    #[allow(clippy::await_holding_lock)]
7888    #[tokio::test]
7889    async fn staged_listener_first_spawn_serves_without_second_bind() {
7890        let _guard = lock_registry_test_mutex();
7891        ServerRegistry::reset();
7892        let registry = ServerRegistry::global();
7893        let (listener, _probe, addr) = clone_fixture_listener().await;
7894        let port = addr.port();
7895        registry
7896            .stage_listener(listener)
7897            .await
7898            .expect("stage listener");
7899
7900        let (max_req, max_res, max_inflight) = staged_limits();
7901        let routes = registry
7902            .get_or_spawn(
7903                "127.0.0.1",
7904                port,
7905                max_req,
7906                max_res,
7907                max_inflight,
7908                test_rt(),
7909                "staged-first-spawn".into(),
7910                None,
7911            )
7912            .await
7913            .expect("spawn from staged listener must succeed");
7914
7915        assert_eq!(
7916            registry.bound_addr("127.0.0.1", port),
7917            Some(addr),
7918            "served socket must be the staged listener's addr"
7919        );
7920        // The probe clone shares the socket, so service is proven by an HTTP
7921        // response, not by accepting on the probe.
7922        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7923            .await
7924            .expect("http request against staged listener must connect");
7925        assert!(
7926            resp.status().as_u16() >= 200,
7927            "any status proves the staged socket serves"
7928        );
7929        drop(routes);
7930    }
7931
7932    #[allow(clippy::await_holding_lock)]
7933    #[tokio::test]
7934    async fn staged_entry_reused_by_second_caller() {
7935        let _guard = lock_registry_test_mutex();
7936        ServerRegistry::reset();
7937        let registry = ServerRegistry::global();
7938        let (listener, _probe, addr) = clone_fixture_listener().await;
7939        let port = addr.port();
7940        registry
7941            .stage_listener(listener)
7942            .await
7943            .expect("stage listener");
7944
7945        let (max_req, max_res, max_inflight) = staged_limits();
7946        let first = registry
7947            .get_or_spawn(
7948                "127.0.0.1",
7949                port,
7950                max_req,
7951                max_res,
7952                max_inflight,
7953                test_rt(),
7954                "staged-reuse-1".into(),
7955                None,
7956            )
7957            .await
7958            .expect("first spawn from staged listener");
7959        let second = registry
7960            .get_or_spawn(
7961                "127.0.0.1",
7962                port,
7963                max_req,
7964                max_res,
7965                max_inflight,
7966                test_rt(),
7967                "staged-reuse-2".into(),
7968                None,
7969            )
7970            .await
7971            .expect("second caller must reuse the entry");
7972        assert_eq!(
7973            registry.bound_addr("127.0.0.1", port),
7974            Some(addr),
7975            "entry reused — bound addr unchanged, no second bind"
7976        );
7977        drop(first);
7978        drop(second);
7979    }
7980
7981    #[allow(clippy::await_holding_lock)]
7982    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7983    async fn staged_race_two_callers_single_resolver() {
7984        let _guard = lock_registry_test_mutex();
7985        ServerRegistry::reset();
7986        let registry = ServerRegistry::global();
7987        let (listener, _probe, addr) = clone_fixture_listener().await;
7988        let port = addr.port();
7989        registry
7990            .stage_listener(listener)
7991            .await
7992            .expect("stage listener");
7993
7994        // Two racing callers for the exact staged key: the staged listener
7995        // must be consumed by the single cell-init winner and served to
7996        // both — never leave the winner binding a port the loser still
7997        // holds (EADDRINUSE).
7998        let (max_req, max_res, max_inflight) = staged_limits();
7999        let (first, second) = tokio::join!(
8000            registry.get_or_spawn(
8001                "127.0.0.1",
8002                port,
8003                max_req,
8004                max_res,
8005                max_inflight,
8006                test_rt(),
8007                "staged-race-1".into(),
8008                None,
8009            ),
8010            registry.get_or_spawn(
8011                "127.0.0.1",
8012                port,
8013                max_req,
8014                max_res,
8015                max_inflight,
8016                test_rt(),
8017                "staged-race-2".into(),
8018                None,
8019            ),
8020        );
8021        let first = first.expect("first racing caller must succeed");
8022        let second = second.expect("second racing caller must succeed");
8023        assert_eq!(
8024            registry.bound_addr("127.0.0.1", port),
8025            Some(addr),
8026            "single entry must be served from the staged socket — no EADDRINUSE path"
8027        );
8028        drop(first);
8029        drop(second);
8030    }
8031
8032    #[allow(clippy::await_holding_lock)]
8033    #[tokio::test]
8034    async fn unstaged_spawn_binds_legacy() {
8035        let _guard = lock_registry_test_mutex();
8036        ServerRegistry::reset();
8037        let registry = ServerRegistry::global();
8038        // Fresh port P2: reserve then release — the legacy path rebinds.
8039        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
8040        let port = probe.local_addr().expect("local addr").port();
8041        drop(probe);
8042
8043        let (max_req, max_res, max_inflight) = staged_limits();
8044        registry
8045            .get_or_spawn(
8046                "127.0.0.1",
8047                port,
8048                max_req,
8049                max_res,
8050                max_inflight,
8051                test_rt(),
8052                "legacy-bind".into(),
8053                None,
8054            )
8055            .await
8056            .expect("legacy bind spawn");
8057        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
8058            .await
8059            .expect("connect to freshly bound port must succeed");
8060        assert!(resp.status().as_u16() >= 200);
8061        assert_eq!(
8062            registry.bound_addr("127.0.0.1", port),
8063            Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
8064            "bound addr must be the legacy bound (host, port)"
8065        );
8066    }
8067
8068    #[allow(clippy::await_holding_lock)]
8069    #[tokio::test]
8070    async fn wrong_host_staged_port_fails_deterministically() {
8071        let _guard = lock_registry_test_mutex();
8072        ServerRegistry::reset();
8073        let registry = ServerRegistry::global();
8074        let (listener, _probe, addr) = clone_fixture_listener().await;
8075        let port = addr.port();
8076        registry
8077            .stage_listener(listener)
8078            .await
8079            .expect("stage listener under 127.0.0.1");
8080
8081        let (max_req, max_res, max_inflight) = staged_limits();
8082        let err = registry
8083            .get_or_spawn(
8084                "localhost",
8085                port,
8086                max_req,
8087                max_res,
8088                max_inflight,
8089                test_rt(),
8090                "conflict-probe".into(),
8091                None,
8092            )
8093            .await
8094            .expect_err("wrong host on staged port must fail deterministically");
8095        assert!(
8096            err.to_string().contains("staged listener conflict on port"),
8097            "unexpected error: {err}"
8098        );
8099
8100        // Slot untouched by the failed call: the correct host now consumes it.
8101        registry
8102            .get_or_spawn(
8103                "127.0.0.1",
8104                port,
8105                max_req,
8106                max_res,
8107                max_inflight,
8108                test_rt(),
8109                "conflict-after".into(),
8110                None,
8111            )
8112            .await
8113            .expect("correct host must serve the staged listener");
8114        assert_eq!(
8115            registry.bound_addr("127.0.0.1", port),
8116            Some(addr),
8117            "staged slot must be untouched by the conflicting call"
8118        );
8119    }
8120
8121    #[allow(clippy::await_holding_lock)]
8122    #[tokio::test]
8123    async fn duplicate_stage_same_key_rejected() {
8124        let _guard = lock_registry_test_mutex();
8125        ServerRegistry::reset();
8126        let registry = ServerRegistry::global();
8127        let (listener, probe, addr) = clone_fixture_listener().await;
8128        registry
8129            .stage_listener(listener)
8130            .await
8131            .expect("stage listener A");
8132
8133        // Second tokio handle to the SAME socket: clone the std probe handle.
8134        let dup = probe.try_clone().expect("clone2");
8135        dup.set_nonblocking(true).expect("set_nonblocking2");
8136        let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
8137
8138        let err = registry
8139            .stage_listener(b)
8140            .await
8141            .expect_err("duplicate stage must be rejected");
8142        assert!(
8143            err.to_string().contains("listener already staged"),
8144            "unexpected error: {err}"
8145        );
8146
8147        let (max_req, max_res, max_inflight) = staged_limits();
8148        registry
8149            .get_or_spawn(
8150                "127.0.0.1",
8151                addr.port(),
8152                max_req,
8153                max_res,
8154                max_inflight,
8155                test_rt(),
8156                "dup-stage-after".into(),
8157                None,
8158            )
8159            .await
8160            .expect("spawn from first staged listener");
8161        assert_eq!(
8162            registry.bound_addr("127.0.0.1", addr.port()),
8163            Some(addr),
8164            "first staged listener retained"
8165        );
8166    }
8167
8168    #[allow(clippy::await_holding_lock)]
8169    #[tokio::test]
8170    async fn distinct_keys_stage_independently() {
8171        let _guard = lock_registry_test_mutex();
8172        ServerRegistry::reset();
8173        let registry = ServerRegistry::global();
8174        let (l1, _p1, addr1) = clone_fixture_listener().await;
8175        let (l2, _p2, addr2) = clone_fixture_listener().await;
8176        registry.stage_listener(l1).await.expect("stage P1");
8177        registry.stage_listener(l2).await.expect("stage P2");
8178
8179        let (max_req, max_res, max_inflight) = staged_limits();
8180        registry
8181            .get_or_spawn(
8182                "127.0.0.1",
8183                addr1.port(),
8184                max_req,
8185                max_res,
8186                max_inflight,
8187                test_rt(),
8188                "distinct-1".into(),
8189                None,
8190            )
8191            .await
8192            .expect("spawn P1");
8193        registry
8194            .get_or_spawn(
8195                "127.0.0.1",
8196                addr2.port(),
8197                max_req,
8198                max_res,
8199                max_inflight,
8200                test_rt(),
8201                "distinct-2".into(),
8202                None,
8203            )
8204            .await
8205            .expect("spawn P2");
8206        assert_eq!(
8207            registry.bound_addr("127.0.0.1", addr1.port()),
8208            Some(addr1),
8209            "P1 bound addr must be its own listener"
8210        );
8211        assert_eq!(
8212            registry.bound_addr("127.0.0.1", addr2.port()),
8213            Some(addr2),
8214            "P2 bound addr must be its own listener"
8215        );
8216        let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
8217            .await
8218            .expect("connect P1");
8219        assert!(r1.status().as_u16() >= 200);
8220        let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
8221            .await
8222            .expect("connect P2");
8223        assert!(r2.status().as_u16() >= 200);
8224    }
8225
8226    #[allow(clippy::await_holding_lock)]
8227    #[tokio::test]
8228    async fn tls_prebound_listener_served() {
8229        use camel_component_api::test_support::tls;
8230
8231        // Install rustls crypto provider (aws-lc-rs — matches the existing
8232        // TLS registry tests).
8233        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
8234
8235        let _guard = lock_registry_test_mutex();
8236        ServerRegistry::reset();
8237        let registry = ServerRegistry::global();
8238        let (listener, _probe, addr) = clone_fixture_listener().await;
8239        let port = addr.port();
8240
8241        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
8242        let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
8243        let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
8244        let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
8245
8246        let (max_req, max_res, max_inflight) = staged_limits();
8247        let routes = registry
8248            .get_or_spawn_with_listener(
8249                listener,
8250                max_req,
8251                max_res,
8252                max_inflight,
8253                test_rt(),
8254                "staged-tls".into(),
8255                Some(crate::config::ServerTlsConfig {
8256                    cert_path: cert_path.to_string_lossy().into_owned(),
8257                    key_path: key_path.to_string_lossy().into_owned(),
8258                }),
8259            )
8260            .await
8261            .expect("spawn TLS server from pre-bound listener");
8262
8263        // Client with CA cert — REAL verification (no danger_accept_invalid),
8264        // same helper pattern as the existing TLS registry tests.
8265        let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
8266        let client = reqwest::Client::builder()
8267            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
8268            .build()
8269            .expect("build tls client");
8270
8271        let resp = client
8272            .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
8273            .send()
8274            .await
8275            .expect("TLS handshake + request must succeed");
8276        assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
8277        assert_eq!(
8278            registry.bound_addr("127.0.0.1", port),
8279            Some(addr),
8280            "bound addr equals the pre-bound listener addr"
8281        );
8282        drop(routes);
8283    }
8284
8285    #[allow(clippy::await_holding_lock)]
8286    #[tokio::test]
8287    async fn with_listener_direct_spawn_keyed_by_actual_addr() {
8288        let _guard = lock_registry_test_mutex();
8289        ServerRegistry::reset();
8290        let registry = ServerRegistry::global();
8291        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
8292            .await
8293            .expect("bind un-staged listener");
8294        let addr = listener.local_addr().expect("local addr");
8295        let port = addr.port();
8296
8297        let (max_req, max_res, max_inflight) = staged_limits();
8298        registry
8299            .get_or_spawn_with_listener(
8300                listener,
8301                max_req,
8302                max_res,
8303                max_inflight,
8304                test_rt(),
8305                "with-listener".into(),
8306                None,
8307            )
8308            .await
8309            .expect("direct spawn from un-staged listener");
8310        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
8311            .await
8312            .expect("connect on actual port");
8313        assert!(resp.status().as_u16() >= 200);
8314        assert_eq!(
8315            registry.bound_addr("127.0.0.1", port),
8316            Some(addr),
8317            "registry key is the listener's actual port"
8318        );
8319
8320        registry
8321            .get_or_spawn(
8322                "127.0.0.1",
8323                port,
8324                max_req,
8325                max_res,
8326                max_inflight,
8327                test_rt(),
8328                "with-listener-reuse".into(),
8329                None,
8330            )
8331            .await
8332            .expect("legacy caller must reuse the entry");
8333        assert_eq!(
8334            registry.bound_addr("127.0.0.1", port),
8335            Some(addr),
8336            "entry reused — no second bind"
8337        );
8338    }
8339
8340    // -----------------------------------------------------------------------
8341    // Axum dispatch handler tests
8342    // -----------------------------------------------------------------------
8343
8344    #[tokio::test]
8345    async fn test_dispatch_handler_returns_404_for_unknown_path() {
8346        let registry = HttpRouteRegistry::new();
8347        // Nothing registered in route registry
8348        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8349        let port = listener.local_addr().unwrap().port();
8350        tokio::spawn(run_axum_server(
8351            listener,
8352            registry,
8353            2 * 1024 * 1024,
8354            10 * 1024 * 1024,
8355            Arc::new(tokio::sync::Semaphore::new(1024)),
8356            test_rt(),
8357            "test-route".into(),
8358        ));
8359
8360        // Wait for server to start
8361        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8362
8363        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
8364            .await
8365            .unwrap();
8366        assert_eq!(resp.status().as_u16(), 404);
8367    }
8368
8369    // -----------------------------------------------------------------------
8370    // HttpConsumer tests
8371    // -----------------------------------------------------------------------
8372
8373    #[tokio::test]
8374    async fn test_http_consumer_start_registers_path() {
8375        use camel_component_api::ConsumerContext;
8376
8377        // Get an OS-assigned free port
8378        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8379        let port = listener.local_addr().unwrap().port();
8380        drop(listener); // Release port — ServerRegistry will rebind it
8381
8382        let consumer_cfg = HttpServerConfig {
8383            scheme: "http".to_string(),
8384            host: "127.0.0.1".to_string(),
8385            port,
8386            path: "/ping".to_string(),
8387            max_request_body: 2 * 1024 * 1024,
8388            max_response_body: 10 * 1024 * 1024,
8389            max_inflight_requests: 1024,
8390            method: None,
8391            tls_config: None,
8392        };
8393        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8394
8395        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8396        let token = tokio_util::sync::CancellationToken::new();
8397        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8398
8399        tokio::spawn(async move {
8400            consumer.start(ctx).await.unwrap();
8401        });
8402
8403        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8404
8405        let client = plain_http_test_client();
8406        let resp_future = client
8407            .post(format!("http://127.0.0.1:{port}/ping"))
8408            .body("hello world")
8409            .send();
8410
8411        let (http_result, _) = tokio::join!(resp_future, async {
8412            if let Some(mut envelope) = rx.recv().await {
8413                // Set a custom status code
8414                envelope.exchange.input.set_header(
8415                    "CamelHttpResponseCode",
8416                    serde_json::Value::Number(201.into()),
8417                );
8418                if let Some(reply_tx) = envelope.reply_tx {
8419                    let _ = reply_tx.send(Ok(envelope.exchange));
8420                }
8421            }
8422        });
8423
8424        let resp = http_result.unwrap();
8425        assert_eq!(resp.status().as_u16(), 201);
8426
8427        token.cancel();
8428    }
8429
8430    /// rc-nftni (drainclaim): the raw-sender dispatch path mints a claim at
8431    /// the acceptance dequeue and carries it on the envelope. Exact totals:
8432    /// 1 while the envelope is held, 0 after release. No wall-clock sleeps —
8433    /// readiness is the startup signal, the recv IS the barrier.
8434    #[tokio::test]
8435    async fn http_consumer_raw_dispatch_carries_in_flight_claim() {
8436        use camel_component_api::ConsumerContext;
8437        use camel_component_api::StartupSignal;
8438
8439        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8440        let port = listener.local_addr().unwrap().port();
8441        drop(listener);
8442
8443        let consumer_cfg = HttpServerConfig {
8444            scheme: "http".to_string(),
8445            host: "127.0.0.1".to_string(),
8446            port,
8447            path: "/claim".to_string(),
8448            max_request_body: 2 * 1024 * 1024,
8449            max_response_body: 10 * 1024 * 1024,
8450            max_inflight_requests: 1024,
8451            method: None,
8452            tls_config: None,
8453        };
8454        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8455
8456        let counter = std::sync::Arc::new(camel_api::InFlightGauge::new());
8457        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8458        let token = tokio_util::sync::CancellationToken::new();
8459        let (signal, startup_rx) = StartupSignal::pair();
8460        let ctx = ConsumerContext::new(tx, token.clone(), "http-claim-route".to_string())
8461            .with_startup(signal)
8462            .with_in_flight_counter(std::sync::Arc::clone(&counter));
8463
8464        tokio::spawn(async move {
8465            consumer.start(ctx).await.unwrap();
8466        });
8467
8468        // Deterministic readiness: the consumer marks ready only after the
8469        // listener is bound and the path registered.
8470        tokio::time::timeout(std::time::Duration::from_secs(5), startup_rx.await_ready())
8471            .await
8472            .expect("startup must resolve within 5s")
8473            .expect("startup must be Ok");
8474
8475        let client = plain_http_test_client();
8476        let (http_result, claim) = tokio::join!(
8477            client
8478                .post(format!("http://127.0.0.1:{port}/claim"))
8479                .body("hello")
8480                .send(),
8481            async {
8482                let mut envelope = rx.recv().await.expect("envelope must arrive");
8483                let claim = envelope
8484                    .in_flight_claim
8485                    .take()
8486                    .expect("raw dispatch must carry an acceptance-minted claim");
8487                assert_eq!(
8488                    counter.total(),
8489                    1,
8490                    "exact total: the acceptance mint is the only live claim"
8491                );
8492                // Materialized reply body: echoing the request's Stream body
8493                // back would tie the response to the request-body stream
8494                // (not what this test exercises).
8495                let reply_tx = envelope.reply_tx.take().expect("reply channel must be set");
8496                let reply_exchange = Exchange::new(camel_component_api::Message::new(
8497                    camel_component_api::Body::Text("done".to_string()),
8498                ));
8499                reply_tx
8500                    .send(Ok(reply_exchange))
8501                    .expect("reply must be taken");
8502                claim
8503            },
8504        );
8505
8506        let resp = http_result.expect("http roundtrip must complete");
8507        assert_eq!(resp.status().as_u16(), 200);
8508
8509        drop(claim);
8510        assert_eq!(
8511            counter.total(),
8512            0,
8513            "release exactly once when the holder drops"
8514        );
8515        token.cancel();
8516    }
8517
8518    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
8519    /// dispatcher's inflight semaphore so the semaphore stays the single
8520    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
8521    #[test]
8522    fn test_envelope_channel_capacity_follows_max_inflight() {
8523        assert_eq!(envelope_channel_capacity(0), 1);
8524        assert_eq!(envelope_channel_capacity(1), 1);
8525        assert_eq!(envelope_channel_capacity(7), 7);
8526        assert_eq!(envelope_channel_capacity(64), 64);
8527        assert_eq!(envelope_channel_capacity(1024), 1024);
8528    }
8529
8530    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
8531    /// configuration. Consumer start must not panic on it (the channel guard)
8532    /// and every request must get 503 from the empty semaphore.
8533    #[tokio::test]
8534    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
8535        use camel_component_api::ConsumerContext;
8536
8537        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8538        let port = listener.local_addr().unwrap().port();
8539        drop(listener);
8540
8541        let consumer_cfg = HttpServerConfig {
8542            scheme: "http".to_string(),
8543            host: "127.0.0.1".to_string(),
8544            port,
8545            path: "/ping".to_string(),
8546            max_request_body: 2 * 1024 * 1024,
8547            max_response_body: 10 * 1024 * 1024,
8548            max_inflight_requests: 0,
8549            method: None,
8550            tls_config: None,
8551        };
8552        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8553
8554        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8555        let token = tokio_util::sync::CancellationToken::new();
8556        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8557
8558        let start_handle = tokio::spawn(async move {
8559            consumer.start(ctx).await.unwrap();
8560        });
8561
8562        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8563
8564        let client = plain_http_test_client();
8565        let resp = client
8566            .post(format!("http://127.0.0.1:{port}/ping"))
8567            .body("hello world")
8568            .send()
8569            .await
8570            .unwrap();
8571        assert_eq!(resp.status().as_u16(), 503);
8572
8573        token.cancel();
8574        let _ = start_handle.await;
8575    }
8576
8577    // -----------------------------------------------------------------------
8578    // maxInflightRequests upper bound (bd rc-ns3yc — mirror of the grpc
8579    // consumer_concurrency_limit fix, commit 101327e5)
8580    // -----------------------------------------------------------------------
8581
8582    /// bd rc-ns3yc: `L-1` and `L` are accepted unchanged, `L+1` is rejected
8583    /// with a typed config error naming the parameter, the configured value,
8584    /// and the limit, and `0` stays a representable reject-everything value
8585    /// (no `.max(1)` normalization — rc-3y6j). A semaphore of exactly L
8586    /// permits must construct without panicking: the accepted bound is
8587    /// exactly the primitive's bound.
8588    #[test]
8589    fn test_max_inflight_requests_limit_boundary() {
8590        let l = tokio::sync::Semaphore::MAX_PERMITS;
8591        assert_eq!(max_inflight_requests_limit(l - 1).unwrap(), l - 1);
8592        assert_eq!(max_inflight_requests_limit(l).unwrap(), l);
8593        assert_eq!(max_inflight_requests_limit(0).unwrap(), 0);
8594        match max_inflight_requests_limit(l + 1) {
8595            Err(CamelError::Config(msg)) => {
8596                assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8597                assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8598                assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8599            }
8600            other => panic!("expected CamelError::Config, got: {other:?}"),
8601        }
8602        let _ = tokio::sync::Semaphore::new(l);
8603    }
8604
8605    /// bd rc-ns3yc: the accepted upper bound is exactly
8606    /// `tokio::sync::Semaphore::MAX_PERMITS`; URI parse must accept L
8607    /// unchanged. Boundary-regression guard (green before and after the
8608    /// fix — red-first is not definable for an accepted value).
8609    #[test]
8610    fn test_parse_http_uri_max_inflight_at_limit_accepted() {
8611        let l = tokio::sync::Semaphore::MAX_PERMITS;
8612        let uri = format!("http://localhost:8080/api?maxInflightRequests={l}");
8613        let cfg = HttpServerConfig::from_uri(&uri).unwrap();
8614        assert_eq!(cfg.max_inflight_requests, l);
8615    }
8616
8617    /// bd rc-ns3yc: values above `tokio::sync::Semaphore::MAX_PERMITS` are
8618    /// rejected at parse with a typed config error naming the parameter,
8619    /// the configured value, and the limit.
8620    #[test]
8621    fn test_parse_http_uri_max_inflight_above_limit_rejected() {
8622        let l = tokio::sync::Semaphore::MAX_PERMITS;
8623        let uri = format!("http://localhost:8080/api?maxInflightRequests={}", l + 1);
8624        match HttpServerConfig::from_uri(&uri) {
8625            Err(CamelError::Config(msg)) => {
8626                assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8627                assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8628                assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8629            }
8630            other => panic!("expected CamelError::Config, got: {other:?}"),
8631        }
8632    }
8633
8634    /// bd rc-ns3yc: an oversized maxInflightRequests must be rejected at
8635    /// create_consumer even when the endpoint's HttpServerConfig was
8636    /// constructed directly (not via URI parse). Mirrors the
8637    /// https_consumer_without_tls_cert_errors direct-endpoint fixture.
8638    #[test]
8639    fn test_create_consumer_rejects_oversized_max_inflight() {
8640        let l = tokio::sync::Semaphore::MAX_PERMITS;
8641        let endpoint = HttpEndpoint {
8642            uri: "http://0.0.0.0:8080/api".to_string(),
8643            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
8644            server_config: HttpServerConfig {
8645                scheme: "http".to_string(),
8646                host: "0.0.0.0".to_string(),
8647                port: 8080,
8648                path: "/api".to_string(),
8649                max_request_body: 2 * 1024 * 1024,
8650                max_response_body: 10 * 1024 * 1024,
8651                max_inflight_requests: l + 1,
8652                method: None,
8653                tls_config: None,
8654            },
8655            client: reqwest::Client::new(),
8656            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
8657                PINNED_CLIENT_TTL,
8658                PINNED_CLIENT_MAX_ENTRIES,
8659            )),
8660            http_config: HttpConfig::default(),
8661        };
8662        match endpoint.create_consumer(rt()) {
8663            Err(CamelError::Config(msg)) => {
8664                assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8665                assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8666                assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8667            }
8668            Err(other) => panic!("expected CamelError::Config, got: {other:?}"),
8669            Ok(_) => panic!("expected error, got Ok"),
8670        }
8671    }
8672
8673    /// bd rc-ns3yc: `start` must reject an oversized maxInflightRequests
8674    /// BEFORE shared-server registry interaction, listener binding,
8675    /// envelope-channel construction, or semaphore construction — a typed
8676    /// config error, no panic. Mirrors the
8677    /// test_http_consumer_start_with_zero_max_inflight_rejects_503 fixture.
8678    #[tokio::test]
8679    async fn test_http_consumer_start_rejects_oversized_before_spawn() {
8680        use camel_component_api::ConsumerContext;
8681
8682        let l = tokio::sync::Semaphore::MAX_PERMITS;
8683        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8684        let port = listener.local_addr().unwrap().port();
8685        drop(listener);
8686
8687        let consumer_cfg = HttpServerConfig {
8688            scheme: "http".to_string(),
8689            host: "127.0.0.1".to_string(),
8690            port,
8691            path: "/ping".to_string(),
8692            max_request_body: 2 * 1024 * 1024,
8693            max_response_body: 10 * 1024 * 1024,
8694            max_inflight_requests: l + 1,
8695            method: None,
8696            tls_config: None,
8697        };
8698        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8699
8700        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8701        let token = tokio_util::sync::CancellationToken::new();
8702        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8703
8704        match consumer.start(ctx).await {
8705            Err(CamelError::Config(msg)) => {
8706                assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8707                assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8708                assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8709            }
8710            other => panic!("expected CamelError::Config, got: {other:?}"),
8711        }
8712        token.cancel();
8713    }
8714
8715    /// bd rc-ns3yc: defense-in-depth — `spawn_entry` must reject an
8716    /// oversized max_inflight_requests before any listener side effect and
8717    /// before `tokio::sync::Semaphore::new` is reached (which would panic).
8718    #[tokio::test]
8719    async fn test_spawn_entry_rejects_oversized_before_semaphore() {
8720        let l = tokio::sync::Semaphore::MAX_PERMITS;
8721        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8722        let addr = listener.local_addr().unwrap();
8723        let key: ServerKey = (addr.ip().to_string(), addr.port());
8724
8725        let result = spawn_entry(
8726            key,
8727            ListenerSource::Staged(listener),
8728            1,
8729            1,
8730            l + 1,
8731            test_rt(),
8732            "route-test".into(),
8733            None,
8734        )
8735        .await;
8736
8737        match result {
8738            Err(CamelError::Config(msg)) => {
8739                assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8740                assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8741                assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8742            }
8743            Err(other) => panic!("expected CamelError::Config, got: {other:?}"),
8744            Ok(_) => panic!("expected CamelError::Config, got Ok"),
8745        }
8746    }
8747
8748    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
8749    /// waits for the listener bind before publishing RouteStarted.
8750    #[test]
8751    fn test_http_consumer_startup_mode_is_explicit() {
8752        use camel_component_api::ConsumerStartupMode;
8753        let consumer_cfg = HttpServerConfig {
8754            scheme: "http".to_string(),
8755            host: "127.0.0.1".to_string(),
8756            port: 0,
8757            path: "/x".to_string(),
8758            max_request_body: 2 * 1024 * 1024,
8759            max_response_body: 10 * 1024 * 1024,
8760            max_inflight_requests: 1024,
8761            method: None,
8762            tls_config: None,
8763        };
8764        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
8765        assert_eq!(
8766            consumer.startup_mode(),
8767            ConsumerStartupMode::Explicit,
8768            "HttpConsumer must opt into Explicit startup"
8769        );
8770    }
8771
8772    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
8773    /// + route registration. The StartupSignal resolves Ok only when that
8774    /// happens. Verified here by injecting our own signal pair into the
8775    /// ConsumerContext and asserting the receiver resolves within a bounded
8776    /// window even before any HTTP request is made.
8777    #[allow(clippy::await_holding_lock)]
8778    #[tokio::test]
8779    async fn test_http_consumer_emits_mark_ready_after_bind() {
8780        use camel_component_api::{ConsumerContext, StartupSignal};
8781
8782        let _guard = lock_registry_test_mutex();
8783
8784        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8785        let port = listener.local_addr().unwrap().port();
8786        drop(listener);
8787
8788        let consumer_cfg = HttpServerConfig {
8789            scheme: "http".to_string(),
8790            host: "127.0.0.1".to_string(),
8791            port,
8792            path: "/ready-probe".to_string(),
8793            max_request_body: 2 * 1024 * 1024,
8794            max_response_body: 10 * 1024 * 1024,
8795            max_inflight_requests: 1024,
8796            method: None,
8797            tls_config: None,
8798        };
8799        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8800
8801        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8802        let token = tokio_util::sync::CancellationToken::new();
8803        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
8804
8805        // Inject our own startup signal so we can observe mark_ready.
8806        let (signal, startup_rx) = StartupSignal::pair();
8807        let ctx = ctx.with_startup(signal);
8808
8809        // Spawn start() — it MUST call mark_ready once the listener is bound
8810        // and the path is registered.
8811        tokio::spawn(async move {
8812            let _ = consumer.start(ctx).await;
8813        });
8814
8815        // The receiver MUST resolve Ok within a bounded window — proving
8816        // mark_ready was called by start(). A short timeout catches the
8817        // regression where mark_ready is never called (the old behaviour
8818        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
8819        let result =
8820            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
8821                .await
8822                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
8823        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
8824
8825        // Cancellation tears down the spawned start() loop.
8826        token.cancel();
8827    }
8828
8829    // -----------------------------------------------------------------------
8830    // Shared-server death supervision (rc-szmob / ADR-0007)
8831    // -----------------------------------------------------------------------
8832
8833    /// RuntimeObservability stub that records every `increment_errors`
8834    /// `(route_id, label)` pair so tests can assert error counters.
8835    #[derive(Default, Clone)]
8836    struct ErrorRecordingRuntime {
8837        errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
8838    }
8839
8840    impl camel_api::MetricsCollector for ErrorRecordingRuntime {
8841        fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
8842        fn increment_errors(&self, route_id: &str, error_type: &str) {
8843            self.errors
8844                .lock()
8845                .expect("error recorder lock")
8846                .push((route_id.to_string(), error_type.to_string()));
8847        }
8848        fn increment_exchanges(&self, _route_id: &str) {}
8849        fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
8850        fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
8851    }
8852
8853    impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
8854        fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
8855    }
8856
8857    impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
8858        fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
8859            std::sync::Arc::new(self.clone())
8860        }
8861        fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
8862            std::sync::Arc::new(self.clone())
8863        }
8864    }
8865
8866    /// rc-szmob (ADR-0007 parity): when the shared Axum server task for a
8867    /// host:port dies, EVERY HttpConsumer hosted on that port must fail its
8868    /// `start()` with an Err — that Err is the signal camel-core's consumer
8869    /// watcher turns into a per-route CrashNotification → FailRoute →
8870    /// supervision backoff restart. Before the fix the consumers hung in
8871    /// `Running` forever (zombie routes): neither `ctx.cancelled()` nor
8872    /// `env_rx.recv()` fires when the server task dies, because the envelope
8873    /// senders live in the (still-alive) registry, not in the dead task.
8874    ///
8875    /// Deterministic by construction: readiness is awaited via the injected
8876    /// StartupSignal (no sleeps), the server is killed via its AbortHandle
8877    /// (real JoinError → monitor's unexpected-exit branch), and consumer
8878    /// resolution is bounded by a timeout — on unmodified behavior the
8879    /// timeout trips, which is exactly the zombie this test pins down.
8880    #[allow(clippy::await_holding_lock)]
8881    #[tokio::test]
8882    async fn shared_server_death_fails_every_hosted_consumer() {
8883        use camel_component_api::{ConsumerContext, StartupSignal};
8884
8885        let _guard = lock_registry_test_mutex();
8886        ServerRegistry::reset();
8887
8888        // Reserve a port, release it, let get_or_spawn bind it.
8889        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8890        let port = listener.local_addr().unwrap().port();
8891        drop(listener);
8892
8893        let rt = ErrorRecordingRuntime::default();
8894
8895        let make_consumer = |path: &str| {
8896            HttpConsumer::new(
8897                HttpServerConfig {
8898                    scheme: "http".to_string(),
8899                    host: "127.0.0.1".to_string(),
8900                    port,
8901                    path: path.to_string(),
8902                    max_request_body: 2 * 1024 * 1024,
8903                    max_response_body: 10 * 1024 * 1024,
8904                    max_inflight_requests: 16,
8905                    method: None,
8906                    tls_config: None,
8907                },
8908                std::sync::Arc::new(rt.clone()),
8909            )
8910        };
8911
8912        let spawn_consumer = |path: &str, route_id: &str| {
8913            let mut consumer = make_consumer(path);
8914            let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8915            let token = tokio_util::sync::CancellationToken::new();
8916            let ctx = ConsumerContext::new(tx, token, route_id.to_string());
8917            let (signal, startup_rx) = StartupSignal::pair();
8918            let ctx = ctx.with_startup(signal);
8919            let task = tokio::spawn(async move { consumer.start(ctx).await });
8920            (task, startup_rx)
8921        };
8922
8923        // Two routes hosted on the SAME shared server (same host:port).
8924        let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
8925        let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
8926
8927        // Both consumers registered and the server is up (bounded, no sleeps).
8928        for (name, ready) in [("a", ready_a), ("b", ready_b)] {
8929            let result =
8930                tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
8931                    .await
8932                    .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
8933            assert!(
8934                result.is_ok(),
8935                "consumer {name} readiness must resolve Ok (bind + registration complete)"
8936            );
8937        }
8938
8939        // Kill the shared server task: abort → JoinError → the monitor's
8940        // unexpected-exit branch. This is the real crash path (no mock).
8941        {
8942            let registry = ServerRegistry::global();
8943            let guard = registry.inner.lock().expect("ServerRegistry lock");
8944            let cell = guard
8945                .entries
8946                .get(&("127.0.0.1".to_string(), port))
8947                .expect("shared server entry must exist");
8948            let handle = cell.get().expect("server handle must be initialized");
8949            handle.server_abort.abort();
8950        }
8951
8952        // THE assertion: both hosted consumers must fail (bounded). On the
8953        // zombie bug they never resolve and this timeout trips.
8954        let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
8955            .await
8956            .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
8957        let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
8958            .await
8959            .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
8960
8961        let err_a = outcome_a
8962            .expect("consumer-a task must join")
8963            .expect_err("consumer-a start() must return Err when the shared server dies");
8964        let err_b = outcome_b
8965            .expect("consumer-b task must join")
8966            .expect_err("consumer-b start() must return Err when the shared server dies");
8967
8968        // The error must identify the dead shared transport (it flows into the
8969        // CrashNotification message camel-core records against the route).
8970        for (name, err) in [("a", &err_a), ("b", &err_b)] {
8971            assert!(
8972                err.to_string().contains("127.0.0.1")
8973                    && err.to_string().contains(&port.to_string()),
8974                "consumer-{name} error must name the dead shared server, got: {err}"
8975            );
8976        }
8977
8978        // Error counter regression guard: the monitor still records
8979        // `e:http:server-task-exited` for the route that spawned the server.
8980        let recorded = rt.errors.lock().expect("error recorder lock").clone();
8981        assert!(
8982            recorded
8983                .iter()
8984                .any(|(route, label)| label == "e:http:server-task-exited"
8985                    && route == "zombie-route-a"),
8986            "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
8987        );
8988    }
8989
8990    #[tokio::test]
8991    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
8992        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8993
8994        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8995        let port = listener.local_addr().unwrap().port();
8996        drop(listener);
8997
8998        let consumer_cfg = HttpServerConfig {
8999            scheme: "http".to_string(),
9000            host: "127.0.0.1".to_string(),
9001            port,
9002            path: "/saturation".to_string(),
9003            max_request_body: 2 * 1024 * 1024,
9004            max_response_body: 10 * 1024 * 1024,
9005            max_inflight_requests: 1,
9006            method: None,
9007            tls_config: None,
9008        };
9009        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9010
9011        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9012        let token = tokio_util::sync::CancellationToken::new();
9013        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9014        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9015        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9016
9017        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
9018        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
9019
9020        tokio::spawn(async move {
9021            let mut first_seen_tx = Some(first_seen_tx);
9022            let mut unblock_first_rx = Some(unblock_first_rx);
9023
9024            loop {
9025                match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
9026                    Ok(Some(envelope)) => {
9027                        if let Some(tx) = first_seen_tx.take() {
9028                            let _ = tx.send(());
9029                            if let Some(rx_unblock) = unblock_first_rx.take() {
9030                                // Deadline-bound like the recv above: a
9031                                // parked first exchange fails loudly
9032                                // instead of hanging the pipeline task.
9033                                let _ = tokio::time::timeout(Duration::from_secs(2), rx_unblock)
9034                                    .await
9035                                    .expect("first exchange unblock within 2s");
9036                            }
9037                        }
9038
9039                        if let Some(reply_tx) = envelope.reply_tx {
9040                            let _ = reply_tx.send(Ok(envelope.exchange));
9041                        }
9042                    }
9043                    // Channel closed: drainer ends.
9044                    Ok(None) => break, // closed: drain complete
9045                    // Stalled: drainer ends.
9046                    Err(_) => break, // stalled: drainer ends
9047                }
9048            }
9049        });
9050
9051        let client = plain_http_test_client();
9052        let first_req = {
9053            let client = client.clone();
9054            async move {
9055                client
9056                    .get(format!("http://127.0.0.1:{port}/saturation"))
9057                    .send()
9058                    .await
9059                    .unwrap()
9060            }
9061        };
9062
9063        let first_handle = tokio::spawn(first_req);
9064        first_seen_rx.await.unwrap();
9065
9066        let second_resp = client
9067            .get(format!("http://127.0.0.1:{port}/saturation"))
9068            .send()
9069            .await
9070            .unwrap();
9071
9072        assert_eq!(second_resp.status().as_u16(), 503);
9073
9074        let _ = unblock_first_tx.send(());
9075        let first_resp = first_handle.await.unwrap();
9076        assert_eq!(first_resp.status().as_u16(), 200);
9077
9078        token.cancel();
9079    }
9080
9081    /// Audit 2026-08-31, F2-1: a chunked (no Content-Length) request body must
9082    /// still be capped — the byte limit travels with the stream, so any
9083    /// downstream materialization fails closed past `max_request_body`.
9084    #[tokio::test]
9085    async fn test_http_consumer_chunked_body_is_capped() {
9086        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9087
9088        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9089        let port = listener.local_addr().unwrap().port();
9090        drop(listener);
9091
9092        let consumer_cfg = HttpServerConfig {
9093            scheme: "http".to_string(),
9094            host: "127.0.0.1".to_string(),
9095            port,
9096            path: "/chunked-cap".to_string(),
9097            max_request_body: 1024, // tiny cap for the test
9098            max_response_body: 10 * 1024 * 1024,
9099            max_inflight_requests: 16,
9100            method: None,
9101            tls_config: None,
9102        };
9103        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9104
9105        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9106        let token = tokio_util::sync::CancellationToken::new();
9107        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9108        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9109        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9110
9111        // Chunked body: reqwest streams it without Content-Length.
9112        let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
9113            .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
9114            .collect();
9115        let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
9116
9117        let client = plain_http_test_client();
9118        let send_fut = client
9119            .post(format!("http://127.0.0.1:{port}/chunked-cap"))
9120            .body(stream_body)
9121            .send();
9122
9123        let (http_result, _) = tokio::join!(send_fut, async {
9124            if let Some(mut envelope) = rx.recv().await {
9125                // The route materializes the body — the cap must fire.
9126                let materialized = envelope
9127                    .exchange
9128                    .input
9129                    .body
9130                    .clone()
9131                    .into_bytes(64 * 1024)
9132                    .await;
9133                assert!(
9134                    materialized.is_err(),
9135                    "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
9136                );
9137                let err = materialized.unwrap_err().to_string();
9138                assert!(
9139                    err.contains("limit") || err.contains("exceeds"),
9140                    "error should mention the limit: {err}"
9141                );
9142                if let Some(reply_tx) = envelope.reply_tx {
9143                    envelope.exchange.input.body =
9144                        camel_component_api::Body::Text("handled".to_string());
9145                    let _ = reply_tx.send(Ok(envelope.exchange));
9146                }
9147            }
9148        });
9149
9150        let resp = http_result.unwrap();
9151        assert_eq!(resp.status().as_u16(), 200);
9152
9153        token.cancel();
9154    }
9155
9156    #[tokio::test]
9157    #[allow(clippy::await_holding_lock)]
9158    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
9159        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9160
9161        let _guard = lock_registry_test_mutex();
9162
9163        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9164        let port = listener.local_addr().unwrap().port();
9165        drop(listener);
9166
9167        let consumer_cfg = HttpServerConfig {
9168            scheme: "http".to_string(),
9169            host: "127.0.0.1".to_string(),
9170            port,
9171            path: "/limit-bytes".to_string(),
9172            max_request_body: 2 * 1024 * 1024,
9173            max_response_body: 16,
9174            max_inflight_requests: 1024,
9175            method: None,
9176            tls_config: None,
9177        };
9178        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9179
9180        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9181        let token = tokio_util::sync::CancellationToken::new();
9182        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9183        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9184        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9185
9186        let client = plain_http_test_client();
9187        let send_fut = client
9188            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
9189            .send();
9190
9191        let (http_result, _) = tokio::join!(send_fut, async {
9192            if let Some(mut envelope) = rx.recv().await {
9193                envelope.exchange.input.body =
9194                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
9195                if let Some(reply_tx) = envelope.reply_tx {
9196                    let _ = reply_tx.send(Ok(envelope.exchange));
9197                }
9198            }
9199        });
9200
9201        let resp = http_result.unwrap();
9202        assert_eq!(resp.status().as_u16(), 500);
9203        let body = resp.text().await.unwrap();
9204        assert_eq!(body, "Response body exceeds configured limit");
9205        token.cancel();
9206    }
9207
9208    #[tokio::test]
9209    #[allow(clippy::await_holding_lock)]
9210    async fn test_http_consumer_enforces_max_response_body_for_json() {
9211        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9212
9213        let _guard = lock_registry_test_mutex();
9214
9215        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9216        let port = listener.local_addr().unwrap().port();
9217        drop(listener);
9218
9219        let consumer_cfg = HttpServerConfig {
9220            scheme: "http".to_string(),
9221            host: "127.0.0.1".to_string(),
9222            port,
9223            path: "/limit-json".to_string(),
9224            max_request_body: 2 * 1024 * 1024,
9225            max_response_body: 16,
9226            max_inflight_requests: 1024,
9227            method: None,
9228            tls_config: None,
9229        };
9230        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9231
9232        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9233        let token = tokio_util::sync::CancellationToken::new();
9234        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9235        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9236        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9237
9238        let client = plain_http_test_client();
9239        let send_fut = client
9240            .get(format!("http://127.0.0.1:{port}/limit-json"))
9241            .send();
9242
9243        let (http_result, _) = tokio::join!(send_fut, async {
9244            if let Some(mut envelope) = rx.recv().await {
9245                envelope.exchange.input.body = camel_component_api::Body::Json(
9246                    serde_json::json!({"message":"this response is bigger than sixteen"}),
9247                );
9248                if let Some(reply_tx) = envelope.reply_tx {
9249                    let _ = reply_tx.send(Ok(envelope.exchange));
9250                }
9251            }
9252        });
9253
9254        let resp = http_result.unwrap();
9255        assert_eq!(resp.status().as_u16(), 500);
9256        let body = resp.text().await.unwrap();
9257        assert_eq!(body, "Response body exceeds configured limit");
9258        token.cancel();
9259    }
9260
9261    #[tokio::test]
9262    #[allow(clippy::await_holding_lock)]
9263    async fn test_http_consumer_enforces_max_response_body_for_xml() {
9264        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9265
9266        let _guard = lock_registry_test_mutex();
9267
9268        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9269        let port = listener.local_addr().unwrap().port();
9270        drop(listener);
9271
9272        let consumer_cfg = HttpServerConfig {
9273            scheme: "http".to_string(),
9274            host: "127.0.0.1".to_string(),
9275            port,
9276            path: "/limit-xml".to_string(),
9277            max_request_body: 2 * 1024 * 1024,
9278            max_response_body: 16,
9279            max_inflight_requests: 1024,
9280            method: None,
9281            tls_config: None,
9282        };
9283        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9284
9285        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9286        let token = tokio_util::sync::CancellationToken::new();
9287        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9288        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9289        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9290
9291        let client = plain_http_test_client();
9292        let send_fut = client
9293            .get(format!("http://127.0.0.1:{port}/limit-xml"))
9294            .send();
9295
9296        let (http_result, _) = tokio::join!(send_fut, async {
9297            if let Some(mut envelope) = rx.recv().await {
9298                envelope.exchange.input.body = camel_component_api::Body::Xml(
9299                    "<root><value>way-too-large</value></root>".into(),
9300                );
9301                if let Some(reply_tx) = envelope.reply_tx {
9302                    let _ = reply_tx.send(Ok(envelope.exchange));
9303                }
9304            }
9305        });
9306
9307        let resp = http_result.unwrap();
9308        assert_eq!(resp.status().as_u16(), 500);
9309        let body = resp.text().await.unwrap();
9310        assert_eq!(body, "Response body exceeds configured limit");
9311        token.cancel();
9312    }
9313
9314    #[tokio::test]
9315    #[allow(clippy::await_holding_lock)]
9316    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
9317        use camel_component_api::{
9318            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
9319        };
9320        use futures::stream;
9321
9322        let _guard = lock_registry_test_mutex();
9323
9324        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
9325        let port = listener.local_addr().unwrap().port();
9326        drop(listener);
9327
9328        let consumer_cfg = HttpServerConfig {
9329            scheme: "http".to_string(),
9330            host: "0.0.0.0".to_string(),
9331            port,
9332            path: "/limit-stream".to_string(),
9333            max_request_body: 2 * 1024 * 1024,
9334            max_response_body: 16,
9335            max_inflight_requests: 1024,
9336            method: None,
9337            tls_config: None,
9338        };
9339        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9340
9341        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9342        let token = tokio_util::sync::CancellationToken::new();
9343        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9344        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9345        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9346
9347        let client = plain_http_test_client();
9348        let send_fut = client
9349            .get(format!("http://127.0.0.1:{port}/limit-stream"))
9350            .send();
9351
9352        let (http_result, _) = tokio::join!(send_fut, async {
9353            if let Some(mut envelope) = rx.recv().await {
9354                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
9355                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
9356                let stream = Box::pin(stream::iter(chunks));
9357                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
9358                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
9359                    metadata: StreamMetadata {
9360                        size_hint: Some(32),
9361                        content_type: Some("application/octet-stream".into()),
9362                        origin: None,
9363                    },
9364                });
9365                if let Some(reply_tx) = envelope.reply_tx {
9366                    let _ = reply_tx.send(Ok(envelope.exchange));
9367                }
9368            }
9369        });
9370
9371        let resp = http_result.unwrap();
9372        assert_eq!(resp.status().as_u16(), 200);
9373        let body = resp.bytes().await.unwrap();
9374        assert_eq!(body.len(), 32);
9375        token.cancel();
9376    }
9377
9378    // -----------------------------------------------------------------------
9379    // Integration tests
9380    // -----------------------------------------------------------------------
9381
9382    #[tokio::test]
9383    #[allow(clippy::await_holding_lock)]
9384    async fn test_integration_single_consumer_round_trip() {
9385        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9386
9387        // Spawns an HTTP consumer on the global ServerRegistry
9388        // (HttpConsumer::start → get_or_spawn). Serialize against the other
9389        // registry tests so parallel runs do not race on shared global state.
9390        let _guard = lock_registry_test_mutex();
9391
9392        // Get an OS-assigned free port (ephemeral)
9393        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9394        let port = listener.local_addr().unwrap().port();
9395        drop(listener); // Release — ServerRegistry will rebind
9396
9397        let component = HttpComponent::new();
9398        let endpoint_ctx = NoOpComponentContext;
9399        let endpoint = component
9400            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
9401            .unwrap();
9402        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9403
9404        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9405        let token = tokio_util::sync::CancellationToken::new();
9406        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9407
9408        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9409        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9410
9411        let client = plain_http_test_client();
9412        let send_fut = client
9413            .post(format!("http://127.0.0.1:{port}/echo"))
9414            .header("Content-Type", "text/plain")
9415            .body("ping")
9416            .send();
9417
9418        let (http_result, _) = tokio::join!(send_fut, async {
9419            if let Some(mut envelope) = rx.recv().await {
9420                assert_eq!(
9421                    envelope.exchange.input.header("CamelHttpMethod"),
9422                    Some(&serde_json::Value::String("POST".into()))
9423                );
9424                assert_eq!(
9425                    envelope.exchange.input.header("CamelHttpPath"),
9426                    Some(&serde_json::Value::String("/echo".into()))
9427                );
9428                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
9429                if let Some(reply_tx) = envelope.reply_tx {
9430                    let _ = reply_tx.send(Ok(envelope.exchange));
9431                }
9432            }
9433        });
9434
9435        let resp = http_result.unwrap();
9436        assert_eq!(resp.status().as_u16(), 200);
9437        let body = resp.text().await.unwrap();
9438        assert_eq!(body, "pong");
9439
9440        token.cancel();
9441    }
9442
9443    #[tokio::test]
9444    #[allow(clippy::await_holding_lock)]
9445    async fn test_integration_two_consumers_shared_port() {
9446        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9447
9448        let _guard = lock_registry_test_mutex();
9449
9450        // Get an OS-assigned free port (ephemeral)
9451        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9452        let port = listener.local_addr().unwrap().port();
9453        drop(listener);
9454
9455        let component = HttpComponent::new();
9456        let endpoint_ctx = NoOpComponentContext;
9457
9458        // Consumer A: /hello
9459        let endpoint_a = component
9460            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
9461            .unwrap();
9462        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
9463
9464        // Consumer B: /world
9465        let endpoint_b = component
9466            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
9467            .unwrap();
9468        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
9469
9470        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9471        let token_a = tokio_util::sync::CancellationToken::new();
9472        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
9473
9474        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9475        let token_b = tokio_util::sync::CancellationToken::new();
9476        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
9477
9478        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
9479        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
9480        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9481
9482        let client = plain_http_test_client();
9483
9484        // Request to /hello
9485        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
9486        let (resp_hello, _) = tokio::join!(fut_hello, async {
9487            if let Some(mut envelope) = rx_a.recv().await {
9488                envelope.exchange.input.body =
9489                    camel_component_api::Body::Text("hello-response".to_string());
9490                if let Some(reply_tx) = envelope.reply_tx {
9491                    let _ = reply_tx.send(Ok(envelope.exchange));
9492                }
9493            }
9494        });
9495
9496        // Request to /world
9497        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
9498        let (resp_world, _) = tokio::join!(fut_world, async {
9499            if let Some(mut envelope) = rx_b.recv().await {
9500                envelope.exchange.input.body =
9501                    camel_component_api::Body::Text("world-response".to_string());
9502                if let Some(reply_tx) = envelope.reply_tx {
9503                    let _ = reply_tx.send(Ok(envelope.exchange));
9504                }
9505            }
9506        });
9507
9508        let body_a = resp_hello.unwrap().text().await.unwrap();
9509        let body_b = resp_world.unwrap().text().await.unwrap();
9510
9511        assert_eq!(body_a, "hello-response");
9512        assert_eq!(body_b, "world-response");
9513
9514        token_a.cancel();
9515        token_b.cancel();
9516    }
9517
9518    #[tokio::test]
9519    #[allow(clippy::await_holding_lock)]
9520    async fn test_integration_unregistered_path_returns_404() {
9521        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9522
9523        let _guard = lock_registry_test_mutex();
9524
9525        // Get an OS-assigned free port (ephemeral)
9526        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9527        let port = listener.local_addr().unwrap().port();
9528        drop(listener);
9529
9530        let component = HttpComponent::new();
9531        let endpoint_ctx = NoOpComponentContext;
9532        let endpoint = component
9533            .create_endpoint(
9534                &format!("http://127.0.0.1:{port}/registered"),
9535                &endpoint_ctx,
9536            )
9537            .unwrap();
9538        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9539
9540        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9541        let token = tokio_util::sync::CancellationToken::new();
9542        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9543
9544        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9545
9546        // Wait until the server is actually accepting connections (CI runners can be slow).
9547        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9548        loop {
9549            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
9550                .await
9551                .is_ok()
9552            {
9553                break;
9554            }
9555            if std::time::Instant::now() >= deadline {
9556                panic!("HTTP server did not start within 5s on port {port}");
9557            }
9558            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
9559        }
9560
9561        let client = plain_http_test_client();
9562        let resp = client
9563            .get(format!("http://127.0.0.1:{port}/not-there"))
9564            .send()
9565            .await
9566            .unwrap();
9567        assert_eq!(resp.status().as_u16(), 404);
9568
9569        token.cancel();
9570    }
9571
9572    #[test]
9573    fn test_http_consumer_declares_concurrent() {
9574        use camel_component_api::ConcurrencyModel;
9575
9576        let config = HttpServerConfig {
9577            scheme: "http".to_string(),
9578            host: "127.0.0.1".to_string(),
9579            port: 19999,
9580            path: "/test".to_string(),
9581            max_request_body: 2 * 1024 * 1024,
9582            max_response_body: 10 * 1024 * 1024,
9583            max_inflight_requests: 1024,
9584            method: None,
9585            tls_config: None,
9586        };
9587        let consumer = HttpConsumer::new(config, test_rt());
9588        assert_eq!(
9589            consumer.concurrency_model(),
9590            ConcurrencyModel::Concurrent { max: None }
9591        );
9592    }
9593
9594    #[test]
9595    fn server_config_parses_tls_cert_and_key() {
9596        let cfg = HttpServerConfig::from_uri(
9597            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
9598        )
9599        .unwrap();
9600        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
9601        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
9602    }
9603
9604    #[test]
9605    fn server_config_no_tls_when_params_absent() {
9606        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
9607        assert!(cfg.tls_config.is_none());
9608    }
9609
9610    // -----------------------------------------------------------------------
9611    // HttpReplyBody streaming tests
9612    // -----------------------------------------------------------------------
9613
9614    #[tokio::test]
9615    async fn test_http_reply_body_stream_variant_exists() {
9616        use bytes::Bytes;
9617        use camel_component_api::CamelError;
9618        use futures::stream;
9619
9620        let chunks: Vec<Result<Bytes, CamelError>> =
9621            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
9622        let stream = Box::pin(stream::iter(chunks));
9623        let reply_body = HttpReplyBody::Stream(stream);
9624        // Si compila y el match funciona, el test pasa
9625        match reply_body {
9626            HttpReplyBody::Stream(_) => {}
9627            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
9628        }
9629    }
9630
9631    // -----------------------------------------------------------------------
9632    // OpenTelemetry propagation tests (only compiled with "otel" feature)
9633    // -----------------------------------------------------------------------
9634
9635    #[cfg(feature = "otel")]
9636    mod otel_tests {
9637        use super::*;
9638        use camel_component_api::Message;
9639        use tower::ServiceExt;
9640
9641        #[tokio::test]
9642        async fn test_producer_injects_traceparent_header() {
9643            let (url, _handle) = start_test_server_with_header_capture().await;
9644            let ctx = test_producer_ctx();
9645
9646            let component = HttpComponent::new();
9647            let endpoint_ctx = NoOpComponentContext;
9648            let endpoint = component
9649                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
9650                .unwrap();
9651            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9652
9653            // Create exchange with an OTel context by extracting from a traceparent header
9654            let mut exchange = Exchange::new(Message::default());
9655            let mut headers = std::collections::HashMap::new();
9656            headers.insert(
9657                "traceparent".to_string(),
9658                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
9659            );
9660            camel_otel::extract_into_exchange(&mut exchange, &headers);
9661
9662            let result = producer.oneshot(exchange).await.unwrap();
9663
9664            // Verify request succeeded
9665            let status = result
9666                .input
9667                .header("CamelHttpResponseCode")
9668                .and_then(|v| v.as_u64())
9669                .unwrap();
9670            assert_eq!(status, 200);
9671
9672            // The test server echoes back the received traceparent header
9673            let traceparent = result.input.header("X-Received-Traceparent");
9674            assert!(
9675                traceparent.is_some(),
9676                "traceparent header should have been sent"
9677            );
9678
9679            let traceparent_str = traceparent.unwrap().as_str().unwrap();
9680            // Verify format: version-traceid-spanid-flags
9681            let parts: Vec<&str> = traceparent_str.split('-').collect();
9682            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9683            assert_eq!(parts[0], "00", "version should be 00");
9684            assert_eq!(
9685                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9686                "trace-id should match"
9687            );
9688            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
9689            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
9690        }
9691
9692        #[tokio::test]
9693        async fn test_consumer_extracts_traceparent_header() {
9694            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9695
9696            // Get an OS-assigned free port
9697            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9698            let port = listener.local_addr().unwrap().port();
9699            drop(listener);
9700
9701            let component = HttpComponent::new();
9702            let endpoint_ctx = NoOpComponentContext;
9703            let endpoint = component
9704                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
9705                .unwrap();
9706            let mut consumer = endpoint.create_consumer(rt()).unwrap();
9707
9708            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9709            let token = tokio_util::sync::CancellationToken::new();
9710            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9711
9712            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9713            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9714
9715            // Send request with traceparent header
9716            let client = plain_http_test_client();
9717            let send_fut = client
9718                .post(format!("http://127.0.0.1:{port}/trace"))
9719                .header(
9720                    "traceparent",
9721                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9722                )
9723                .body("test")
9724                .send();
9725
9726            let (http_result, _) = tokio::join!(send_fut, async {
9727                if let Some(envelope) = rx.recv().await {
9728                    // Verify the exchange has a valid OTel context by re-injecting it
9729                    // and checking the traceparent matches
9730                    let mut injected_headers = std::collections::HashMap::new();
9731                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9732
9733                    assert!(
9734                        injected_headers.contains_key("traceparent"),
9735                        "Exchange should have traceparent after extraction"
9736                    );
9737
9738                    let traceparent = injected_headers.get("traceparent").unwrap();
9739                    let parts: Vec<&str> = traceparent.split('-').collect();
9740                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9741                    assert_eq!(
9742                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9743                        "Trace ID should match the original traceparent header"
9744                    );
9745
9746                    if let Some(reply_tx) = envelope.reply_tx {
9747                        let _ = reply_tx.send(Ok(envelope.exchange));
9748                    }
9749                }
9750            });
9751
9752            let resp = http_result.unwrap();
9753            assert_eq!(resp.status().as_u16(), 200);
9754
9755            token.cancel();
9756        }
9757
9758        #[tokio::test]
9759        async fn test_consumer_extracts_mixed_case_traceparent_header() {
9760            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9761
9762            // Get an OS-assigned free port
9763            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9764            let port = listener.local_addr().unwrap().port();
9765            drop(listener);
9766
9767            let component = HttpComponent::new();
9768            let endpoint_ctx = NoOpComponentContext;
9769            let endpoint = component
9770                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
9771                .unwrap();
9772            let mut consumer = endpoint.create_consumer(rt()).unwrap();
9773
9774            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9775            let token = tokio_util::sync::CancellationToken::new();
9776            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9777
9778            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9779            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9780
9781            // Send request with MIXED-CASE TraceParent header (not lowercase)
9782            let client = plain_http_test_client();
9783            let send_fut = client
9784                .post(format!("http://127.0.0.1:{port}/trace"))
9785                .header(
9786                    "TraceParent",
9787                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9788                )
9789                .body("test")
9790                .send();
9791
9792            let (http_result, _) = tokio::join!(send_fut, async {
9793                if let Some(envelope) = rx.recv().await {
9794                    // Verify the exchange has a valid OTel context by re-injecting it
9795                    // and checking the traceparent matches
9796                    let mut injected_headers = HashMap::new();
9797                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9798
9799                    assert!(
9800                        injected_headers.contains_key("traceparent"),
9801                        "Exchange should have traceparent after extraction from mixed-case header"
9802                    );
9803
9804                    let traceparent = injected_headers.get("traceparent").unwrap();
9805                    let parts: Vec<&str> = traceparent.split('-').collect();
9806                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9807                    assert_eq!(
9808                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9809                        "Trace ID should match the original mixed-case TraceParent header"
9810                    );
9811
9812                    if let Some(reply_tx) = envelope.reply_tx {
9813                        let _ = reply_tx.send(Ok(envelope.exchange));
9814                    }
9815                }
9816            });
9817
9818            let resp = http_result.unwrap();
9819            assert_eq!(resp.status().as_u16(), 200);
9820
9821            token.cancel();
9822        }
9823
9824        #[tokio::test]
9825        async fn test_producer_no_trace_context_no_crash() {
9826            let (url, _handle) = start_test_server().await;
9827            let ctx = test_producer_ctx();
9828
9829            let component = HttpComponent::new();
9830            let endpoint_ctx = NoOpComponentContext;
9831            let endpoint = component
9832                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
9833                .unwrap();
9834            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9835
9836            // Create exchange with default (empty) otel_context - no trace context
9837            let exchange = Exchange::new(Message::default());
9838
9839            // Should succeed without panic
9840            let result = producer.oneshot(exchange).await.unwrap();
9841
9842            // Verify request succeeded
9843            let status = result
9844                .input
9845                .header("CamelHttpResponseCode")
9846                .and_then(|v| v.as_u64())
9847                .unwrap();
9848            assert_eq!(status, 200);
9849        }
9850
9851        /// Test server that captures and echoes back the traceparent header
9852        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
9853            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9854            let addr = listener.local_addr().unwrap();
9855            let url = format!("http://127.0.0.1:{}", addr.port());
9856
9857            let handle = tokio::spawn(async move {
9858                loop {
9859                    if let Ok((mut stream, _)) = listener.accept().await {
9860                        tokio::spawn(async move {
9861                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
9862                            let mut buf = vec![0u8; 8192];
9863                            let n = stream.read(&mut buf).await.unwrap_or(0);
9864                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
9865
9866                            // Extract traceparent header from request
9867                            let traceparent = request
9868                                .lines()
9869                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
9870                                .map(|line| {
9871                                    line.split(':')
9872                                        .nth(1)
9873                                        .map(|s| s.trim().to_string())
9874                                        .unwrap_or_default()
9875                                })
9876                                .unwrap_or_default();
9877
9878                            let body =
9879                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
9880                            let response = format!(
9881                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
9882                                body.len(),
9883                                traceparent,
9884                                body
9885                            );
9886                            let _ = stream.write_all(response.as_bytes()).await;
9887                        });
9888                    }
9889                }
9890            });
9891
9892            (url, handle)
9893        }
9894    }
9895
9896    // -----------------------------------------------------------------------
9897    // Response streaming tests (Eje A - Task 2)
9898    // -----------------------------------------------------------------------
9899
9900    // -----------------------------------------------------------------------
9901    // Request streaming tests (Eje B - Task 3)
9902    // -----------------------------------------------------------------------
9903
9904    #[tokio::test]
9905    async fn test_request_body_arrives_as_stream() {
9906        use camel_component_api::Body;
9907        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9908
9909        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9910        let port = listener.local_addr().unwrap().port();
9911        drop(listener);
9912
9913        let component = HttpComponent::new();
9914        let endpoint_ctx = NoOpComponentContext;
9915        let endpoint = component
9916            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
9917            .unwrap();
9918        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9919
9920        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9921        let token = tokio_util::sync::CancellationToken::new();
9922        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9923
9924        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9925        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9926
9927        let client = plain_http_test_client();
9928        let send_fut = client
9929            .post(format!("http://127.0.0.1:{port}/upload"))
9930            .body("hello streaming world")
9931            .send();
9932
9933        let (http_result, _) = tokio::join!(send_fut, async {
9934            if let Some(mut envelope) = rx.recv().await {
9935                // Body must be Body::Stream, not Body::Text or Body::Bytes
9936                assert!(
9937                    matches!(envelope.exchange.input.body, Body::Stream(_)),
9938                    "expected Body::Stream, got discriminant {:?}",
9939                    std::mem::discriminant(&envelope.exchange.input.body)
9940                );
9941                // Materialize to verify content
9942                let bytes = envelope
9943                    .exchange
9944                    .input
9945                    .body
9946                    .into_bytes(1024 * 1024)
9947                    .await
9948                    .unwrap();
9949                assert_eq!(&bytes[..], b"hello streaming world");
9950
9951                envelope.exchange.input.body = camel_component_api::Body::Empty;
9952                if let Some(reply_tx) = envelope.reply_tx {
9953                    let _ = reply_tx.send(Ok(envelope.exchange));
9954                }
9955            }
9956        });
9957
9958        let resp = http_result.unwrap();
9959        assert_eq!(resp.status().as_u16(), 200);
9960
9961        token.cancel();
9962    }
9963
9964    // -----------------------------------------------------------------------
9965    // Response streaming tests (Eje A - Task 2)
9966    // -----------------------------------------------------------------------
9967
9968    #[tokio::test]
9969    async fn test_streaming_response_chunked() {
9970        use bytes::Bytes;
9971        use camel_component_api::Body;
9972        use camel_component_api::CamelError;
9973        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9974        use camel_component_api::{StreamBody, StreamMetadata};
9975        use futures::stream;
9976        use std::sync::Arc;
9977        use tokio::sync::Mutex;
9978
9979        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9980        let port = listener.local_addr().unwrap().port();
9981        drop(listener);
9982
9983        let component = HttpComponent::new();
9984        let endpoint_ctx = NoOpComponentContext;
9985        let endpoint = component
9986            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
9987            .unwrap();
9988        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9989
9990        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9991        let token = tokio_util::sync::CancellationToken::new();
9992        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9993
9994        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9995        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9996
9997        let client = plain_http_test_client();
9998        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
9999
10000        let (http_result, _) = tokio::join!(send_fut, async {
10001            if let Some(mut envelope) = rx.recv().await {
10002                // Respond with Body::Stream
10003                let chunks: Vec<Result<Bytes, CamelError>> =
10004                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
10005                let stream = Box::pin(stream::iter(chunks));
10006                envelope.exchange.input.body = Body::Stream(StreamBody {
10007                    stream: Arc::new(Mutex::new(Some(stream))),
10008                    metadata: StreamMetadata::default(),
10009                });
10010                if let Some(reply_tx) = envelope.reply_tx {
10011                    let _ = reply_tx.send(Ok(envelope.exchange));
10012                }
10013            }
10014        });
10015
10016        let resp = http_result.unwrap();
10017        assert_eq!(resp.status().as_u16(), 200);
10018        let body = resp.text().await.unwrap();
10019        assert_eq!(body, "chunk1chunk2");
10020
10021        token.cancel();
10022    }
10023
10024    // -----------------------------------------------------------------------
10025    // 413 Content-Length limit test (Task 4)
10026    // -----------------------------------------------------------------------
10027
10028    #[tokio::test]
10029    async fn test_413_when_content_length_exceeds_limit() {
10030        use camel_component_api::ConsumerContext;
10031
10032        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10033        let port = listener.local_addr().unwrap().port();
10034        drop(listener);
10035
10036        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
10037        let component = HttpComponent::new();
10038        let endpoint_ctx = NoOpComponentContext;
10039        let endpoint = component
10040            .create_endpoint(
10041                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
10042                &endpoint_ctx,
10043            )
10044            .unwrap();
10045        let mut consumer = endpoint.create_consumer(rt()).unwrap();
10046
10047        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10048        let token = tokio_util::sync::CancellationToken::new();
10049        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10050
10051        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10052        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10053
10054        let client = plain_http_test_client();
10055        let resp = client
10056            .post(format!("http://127.0.0.1:{port}/upload"))
10057            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
10058            .body("x".repeat(1000))
10059            .send()
10060            .await
10061            .unwrap();
10062
10063        assert_eq!(resp.status().as_u16(), 413);
10064
10065        token.cancel();
10066    }
10067
10068    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
10069    /// The spec says: "If there is no Content-Length, the limit does not apply at the
10070    /// consumer level — the route is responsible."
10071    #[tokio::test]
10072    async fn test_chunked_upload_without_content_length_bypasses_limit() {
10073        use bytes::Bytes;
10074        use camel_component_api::Body;
10075        use camel_component_api::ConsumerContext;
10076        use futures::stream;
10077
10078        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10079        let port = listener.local_addr().unwrap().port();
10080        drop(listener);
10081
10082        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
10083        let component = HttpComponent::new();
10084        let endpoint_ctx = NoOpComponentContext;
10085        let endpoint = component
10086            .create_endpoint(
10087                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
10088                &endpoint_ctx,
10089            )
10090            .unwrap();
10091        let mut consumer = endpoint.create_consumer(rt()).unwrap();
10092
10093        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10094        let token = tokio_util::sync::CancellationToken::new();
10095        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10096
10097        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10098        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10099
10100        let client = plain_http_test_client();
10101
10102        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
10103        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
10104        // but since there's no Content-Length the 413 check must NOT fire.
10105        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
10106            Ok(Bytes::from("y".repeat(50))),
10107            Ok(Bytes::from("y".repeat(50))),
10108        ];
10109        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
10110        let send_fut = client
10111            .post(format!("http://127.0.0.1:{port}/upload"))
10112            .body(stream_body)
10113            .send();
10114
10115        let consumer_fut = async {
10116            // Use timeout to avoid deadlock if the handler rejects before enqueueing
10117            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
10118                Ok(Some(mut envelope)) => {
10119                    assert!(
10120                        matches!(envelope.exchange.input.body, Body::Stream(_)),
10121                        "expected Body::Stream"
10122                    );
10123                    envelope.exchange.input.body = camel_component_api::Body::Empty;
10124                    if let Some(reply_tx) = envelope.reply_tx {
10125                        let _ = reply_tx.send(Ok(envelope.exchange));
10126                    }
10127                }
10128                Ok(None) => panic!("consumer channel closed unexpectedly"),
10129                Err(_) => {
10130                    // Timeout: the request was rejected before reaching the consumer.
10131                    // The HTTP response will carry the real status code (we check below).
10132                }
10133            }
10134        };
10135
10136        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
10137
10138        let resp = http_result.unwrap();
10139        // Audit 2026-08-31 (F2-1): the request is no longer rejected at the door
10140        // (no Content-Length to pre-check), but the byte cap now travels with the
10141        // stream: ANY materialization past maxRequestBody fails closed. This test
10142        // does not consume the body, so the request still completes with 200 —
10143        // enforcement happens at consumption time (see
10144        // test_http_consumer_chunked_body_is_capped).
10145        assert_ne!(
10146            resp.status().as_u16(),
10147            413,
10148            "chunked upload has no Content-Length to pre-check"
10149        );
10150        assert_eq!(resp.status().as_u16(), 200);
10151
10152        token.cancel();
10153    }
10154
10155    #[test]
10156    fn test_is_private_ip_ranges() {
10157        use camel_api::is_ssrf_blocked_ip;
10158        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
10159        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
10160        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
10161        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
10162        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
10163        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
10164
10165        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
10166        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
10167        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
10168        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
10169        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
10170        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
10171        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
10172        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
10173
10174        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
10175        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
10176        assert!(!is_ssrf_blocked_ip(
10177            &"2001:4860:4860::8888".parse().unwrap()
10178        )); // allow-unwrap
10179    }
10180
10181    #[test]
10182    fn test_title_case_header() {
10183        assert_eq!(title_case_header("content-type"), "Content-Type");
10184        assert_eq!(title_case_header("authorization"), "Authorization");
10185        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
10186        assert_eq!(title_case_header("host"), "Host");
10187        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
10188        assert_eq!(title_case_header("single"), "Single");
10189        assert_eq!(title_case_header(""), "");
10190    }
10191
10192    #[test]
10193    fn test_resolve_url_combines_path_and_query_sources() {
10194        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
10195        let mut exchange = Exchange::new(Message::default());
10196        exchange.input.set_header(
10197            "CamelHttpPath",
10198            serde_json::Value::String("next".to_string()),
10199        );
10200        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10201        assert!(url.starts_with("http://example.com/base/next?"));
10202        assert!(url.contains("foo=bar"));
10203
10204        exchange.input.set_header(
10205            "CamelHttpUri",
10206            serde_json::Value::String("http://other.test/root".to_string()),
10207        );
10208        exchange.input.set_header(
10209            "CamelHttpQuery",
10210            serde_json::Value::String("a=1&b=2".to_string()),
10211        );
10212
10213        let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10214        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
10215    }
10216
10217    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
10218        let mut exchange = Exchange::new(Message::default());
10219        exchange
10220            .input
10221            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
10222        exchange.input.set_header(
10223            "CamelHttpQuery",
10224            serde_json::Value::String(query.to_string()),
10225        );
10226        exchange
10227    }
10228
10229    #[test]
10230    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
10231        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10232        cfg.bridge_endpoint = true;
10233        cfg.query_params
10234            .push(("token".to_string(), "secret".to_string()));
10235        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
10236        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10237        // Verbatim assembly: the old round-trip normalized the empty base
10238        // path to `/` (`http://x/?token=secret`); authored bytes end-to-end
10239        // no longer insert it.
10240        assert_eq!(url, "http://x?token=secret");
10241        assert!(!url.contains("/foo"));
10242        assert!(!url.contains("dropme"));
10243    }
10244
10245    #[test]
10246    fn resolve_url_bridge_endpoint_false_merges_path() {
10247        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10248        cfg.bridge_endpoint = false;
10249        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
10250        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10251        assert!(url.contains("/foo"), "url should contain /foo: {url}");
10252        assert!(
10253            url.contains("dropme=1"),
10254            "url should contain dropme=1: {url}"
10255        );
10256    }
10257
10258    #[test]
10259    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
10260        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10261        cfg.bridge_endpoint = true;
10262        let mut exchange = Exchange::new(Message::default());
10263        exchange.input.set_header(
10264            "CamelHttpPath",
10265            serde_json::Value::String("/foo".to_string()),
10266        );
10267        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10268        assert_eq!(url, "http://x");
10269        assert!(!url.contains("/foo"));
10270    }
10271
10272    #[test]
10273    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
10274        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10275        cfg.bridge_endpoint = true;
10276        // query_params stays empty ([])
10277        let mut exchange = Exchange::new(Message::default());
10278        exchange.input.set_header(
10279            "CamelHttpUri",
10280            serde_json::Value::String("http://dest/explicit".to_string()),
10281        );
10282        exchange.input.set_header(
10283            "CamelHttpPath",
10284            serde_json::Value::String("/foo".to_string()),
10285        );
10286        exchange.input.set_header(
10287            "CamelHttpQuery",
10288            serde_json::Value::String("x=1".to_string()),
10289        );
10290        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10291        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
10292        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
10293        // wins verbatim.
10294        assert_eq!(url, "http://x");
10295    }
10296
10297    #[test]
10298    fn bridge_programmatic_params_use_percent20() {
10299        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10300        cfg.bridge_endpoint = true;
10301        cfg.query_params = vec![("b".to_string(), "x y".to_string())];
10302        let exchange = Exchange::new(Message::default());
10303
10304        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10305
10306        // `%20 never +` is global for programmatic values — the bridge arm
10307        // uses the same encoder as the non-bridge path. Bridging
10308        // semantics (what gets bridged, precedence) are unchanged.
10309        assert_eq!(url, "http://x?b=x%20y");
10310        assert!(!url.contains('+'));
10311    }
10312
10313    #[test]
10314    fn bridge_arm_carries_authored_raw_query() {
10315        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
10316        // bridgeEndpoint is consumed as an endpoint option; a=1 is the
10317        // authored leftover riding raw_query.
10318        let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
10319
10320        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10321
10322        // Authored leftovers ride under bridging (Apache Camel semantics):
10323        // query is a=1 in authored bytes; exchange path/query stay ignored.
10324        assert_eq!(url, "http://h/p?a=1");
10325        assert!(!url.contains("dropme"), "exchange query leaked: {url}");
10326        assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
10327    }
10328
10329    // -----------------------------------------------------------------------
10330    // Bridge arm verbatim assembly (Papal Direction A): the bridged base is
10331    // never round-tripped through `url::Url` normalization — authored bytes
10332    // end-to-end, identical assembly to every other resolve_url arm.
10333    // -----------------------------------------------------------------------
10334
10335    #[test]
10336    fn resolve_url_bridge_preserves_dot_segments() {
10337        let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
10338        cfg.bridge_endpoint = true;
10339        cfg.query_params.push(("k".to_string(), "1".to_string()));
10340        let exchange = Exchange::new(Message::default());
10341
10342        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10343
10344        // Dot segments are authored bytes; the old round-trip collapsed
10345        // them (`/a/../b` → `/b`). Verbatim keeps them.
10346        assert_eq!(url, "http://h/a/../b?k=1");
10347    }
10348
10349    #[test]
10350    fn resolve_url_bridge_preserves_default_port() {
10351        let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
10352        cfg.bridge_endpoint = true;
10353        cfg.query_params.push(("k".to_string(), "1".to_string()));
10354        let exchange = Exchange::new(Message::default());
10355
10356        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10357
10358        // The old round-trip stripped the default port `:80`. Verbatim
10359        // keeps it.
10360        assert_eq!(url, "http://h:80/p?k=1");
10361    }
10362
10363    #[test]
10364    fn resolve_url_bridge_preserves_scheme_and_host_case() {
10365        let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
10366        cfg.bridge_endpoint = true;
10367        cfg.query_params.push(("k".to_string(), "1".to_string()));
10368        // `from_uri`'s scheme validation is case-sensitive, so the scheme
10369        // case is applied on the stored base directly — the resolve path
10370        // must carry whatever bytes the operator authored.
10371        cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
10372        let exchange = Exchange::new(Message::default());
10373
10374        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10375
10376        // The old round-trip lowercased scheme and host. Verbatim keeps
10377        // both authored.
10378        assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
10379    }
10380
10381    #[test]
10382    fn resolve_url_bridge_no_query_emits_base_verbatim() {
10383        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10384        cfg.bridge_endpoint = true;
10385        let exchange = Exchange::new(Message::default());
10386
10387        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10388
10389        // No resolved query: exactly the authored base — no synthetic `/`,
10390        // no dangling `?`.
10391        assert_eq!(url, "http://h/p");
10392    }
10393
10394    #[test]
10395    fn resolve_url_bridge_and_non_bridge_byte_identical() {
10396        // (a) Bridged arm: the effective query comes from programmatic
10397        // query_params.
10398        let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
10399        bridged.bridge_endpoint = true;
10400        bridged
10401            .query_params
10402            .push(("k".to_string(), "1".to_string()));
10403        let bridge_url =
10404            HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
10405
10406        // (b) Non-bridge CamelHttpQuery composition path: same effective
10407        // query riding the exchange header.
10408        let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
10409        let mut exchange = Exchange::new(Message::default());
10410        exchange.input.set_header(
10411            "CamelHttpQuery",
10412            serde_json::Value::String("k=1".to_string()),
10413        );
10414        let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
10415
10416        assert_eq!(bridge_url, plain_url);
10417        assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
10418    }
10419
10420    #[test]
10421    fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
10422        let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
10423        cfg.bridge_endpoint = true;
10424        cfg.query_params.push(("k".to_string(), "1".to_string()));
10425        let exchange = Exchange::new(Message::default());
10426
10427        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10428
10429        assert_eq!(url, "http://[::1]:8080/p?k=1");
10430    }
10431
10432    #[test]
10433    fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
10434        let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
10435        let exchange = Exchange::new(Message::default());
10436
10437        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10438
10439        // Authored query on an empty base path: the old round-trip
10440        // inserted a synthetic `/` (`http://h/?x=1`); verbatim does not.
10441        assert_eq!(url, "http://h?x=1");
10442    }
10443
10444    // -----------------------------------------------------------------------
10445    // Raw-preserving outbound query serialization (http-query-wire-fidelity)
10446    // -----------------------------------------------------------------------
10447
10448    #[test]
10449    fn resolve_url_preserves_authored_query_order_and_bytes() {
10450        let config =
10451            HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
10452        let exchange = Exchange::new(Message::default());
10453
10454        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10455
10456        // Authored order, authored separators, no %2C/%3A re-encoding,
10457        // consumed option (connectTimeout) removed.
10458        assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
10459    }
10460
10461    #[test]
10462    fn resolve_url_consumes_encoded_option_key() {
10463        let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
10464        let exchange = Exchange::new(Message::default());
10465
10466        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10467
10468        // The raw filter matches the decoded key, not the encoded bytes.
10469        assert_eq!(url, "http://h/p?a=1");
10470    }
10471
10472    #[test]
10473    fn resolve_url_all_options_consumed_drops_query() {
10474        let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
10475        let exchange = Exchange::new(Message::default());
10476
10477        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10478
10479        // A non-empty query whose every pair was consumed drops the query
10480        // component entirely — no dangling `?`.
10481        assert_eq!(url, "http://h/p");
10482        assert!(!url.contains('?'));
10483    }
10484
10485    #[test]
10486    fn resolve_url_preserves_empty_query_marker() {
10487        let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
10488        let exchange = Exchange::new(Message::default());
10489
10490        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10491
10492        // A bare `?` marker is preserved distinctly, never conflated with
10493        // an all-consumed query.
10494        assert_eq!(url, "http://h/p?");
10495    }
10496
10497    #[test]
10498    fn resolve_url_raw_wrapper_not_re_encoded() {
10499        let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
10500        let exchange = Exchange::new(Message::default());
10501
10502        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10503
10504        // RAW(...) wrapper bytes survive exactly as authored (rc-g4isv).
10505        assert_eq!(url, "http://h/p?token=RAW(abc)");
10506        assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
10507    }
10508
10509    #[test]
10510    fn resolve_url_camel_http_query_composes_verbatim_span() {
10511        let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
10512        let mut exchange = Exchange::new(Message::default());
10513        exchange.input.set_header(
10514            "CamelHttpQuery",
10515            serde_json::Value::String("userFilter=a%2Cb".to_string()),
10516        );
10517
10518        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10519
10520        // Policy change (ADR-0071): the header no longer replaces the
10521        // endpoint query — it composes, the endpoint winning collisions.
10522        // The header span bytes still ride verbatim: `a%2Cb` is carried
10523        // as-authored, never re-encoded (no %252C).
10524        assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
10525        assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
10526    }
10527
10528    // -----------------------------------------------------------------------
10529    // Outbound query composition (http-contract-surface, ADR-0071)
10530    // -----------------------------------------------------------------------
10531
10532    #[test]
10533    fn header_composes_with_endpoint_query() {
10534        let config =
10535            HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
10536        let mut exchange = Exchange::new(Message::default());
10537        exchange.input.set_header(
10538            "CamelHttpQuery",
10539            serde_json::Value::String("lang=es&page=2".to_string()),
10540        );
10541
10542        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10543
10544        // Higher precedence (endpoint) wins collisions: `lang` stays `en`;
10545        // the header appends only its absent keys.
10546        assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
10547    }
10548
10549    #[test]
10550    fn header_alone_still_rides() {
10551        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10552        let mut exchange = Exchange::new(Message::default());
10553        exchange.input.set_header(
10554            "CamelHttpQuery",
10555            serde_json::Value::String("page=2".to_string()),
10556        );
10557
10558        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10559
10560        // No endpoint query: the header pairs are the whole query.
10561        assert_eq!(url, "http://upstream/api?page=2");
10562    }
10563
10564    #[test]
10565    fn empty_reflected_query_leaves_endpoint_query_intact() {
10566        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
10567        let mut exchange = Exchange::new(Message::default());
10568        // The consumer installs an empty CamelHttpQuery on requests that
10569        // arrived without a query string.
10570        exchange
10571            .input
10572            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10573
10574        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10575
10576        // No second `?` marker, no dropped endpoint pair.
10577        assert_eq!(url, "http://upstream/api?apiKey=secret");
10578        assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
10579    }
10580
10581    #[test]
10582    fn forbidden_byte_in_header_query_errors() {
10583        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10584        let mut exchange = Exchange::new(Message::default());
10585        exchange.input.set_header(
10586            "CamelHttpQuery",
10587            serde_json::Value::String("q=ab<cd".to_string()),
10588        );
10589
10590        let err = HttpProducer::resolve_url(&exchange, &config)
10591            .unwrap_err()
10592            .to_string();
10593
10594        // Fail loud naming the forbidden byte (`<` = 0x3C); a resolve
10595        // error means no URL is emitted, never a re-encoded one.
10596        assert!(err.contains("0x3C"), "error must name the byte: {err}");
10597    }
10598
10599    #[test]
10600    fn override_uri_with_query_plus_header_query() {
10601        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10602        let mut exchange = Exchange::new(Message::default());
10603        exchange.input.set_header(
10604            "CamelHttpUri",
10605            serde_json::Value::String("http://host/api?a=1".to_string()),
10606        );
10607        exchange.input.set_header(
10608            "CamelHttpQuery",
10609            serde_json::Value::String("a=2&b=3".to_string()),
10610        );
10611
10612        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10613
10614        // Pair-level merge with a single `?`: the override's `a=1` wins
10615        // the collision, the header appends `b=3` — no `?a=1?a=2` concat.
10616        assert_eq!(url, "http://host/api?a=1&b=3");
10617    }
10618
10619    #[test]
10620    fn path_applies_before_query_composition() {
10621        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10622        let mut exchange = Exchange::new(Message::default());
10623        exchange.input.set_header(
10624            "CamelHttpUri",
10625            serde_json::Value::String("http://host/api?a=1".to_string()),
10626        );
10627        exchange.input.set_header(
10628            "CamelHttpPath",
10629            serde_json::Value::String("/extra".to_string()),
10630        );
10631        exchange.input.set_header(
10632            "CamelHttpQuery",
10633            serde_json::Value::String("b=2".to_string()),
10634        );
10635
10636        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10637
10638        // CamelHttpPath applies to the override base without its query,
10639        // then the query composes.
10640        assert_eq!(url, "http://host/api/extra?a=1&b=2");
10641    }
10642
10643    #[test]
10644    fn plain_proxy_reflection_composes() {
10645        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
10646        // Headers as the consumer installs them from the wire.
10647        let exchange = exchange_with_path_and_query("/in/extra", "page=2");
10648
10649        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10650
10651        // Reflection rides by default and composes: the operator pair is
10652        // not replaced (rc-k3pir parity).
10653        assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
10654    }
10655
10656    #[test]
10657    fn bridge_endpoint_ignores_url_headers() {
10658        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
10659        let mut exchange = Exchange::new(Message::default());
10660        exchange.input.set_header(
10661            "CamelHttpUri",
10662            serde_json::Value::String("http://evil.test/x".to_string()),
10663        );
10664        exchange.input.set_header(
10665            "CamelHttpPath",
10666            serde_json::Value::String("/foo".to_string()),
10667        );
10668        exchange.input.set_header(
10669            "CamelHttpQuery",
10670            serde_json::Value::String("z=9".to_string()),
10671        );
10672
10673        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10674
10675        // All three URL headers ignored; the endpoint base plus its own
10676        // (consumed-option-filtered) query is sent, exactly as before.
10677        assert_eq!(url, "http://h/p?a=1");
10678        assert!(!url.contains("evil"), "override leaked: {url}");
10679        assert!(!url.contains("z=9"), "header query leaked: {url}");
10680        assert!(!url.contains("/foo"), "header path leaked: {url}");
10681    }
10682
10683    #[test]
10684    fn resolve_url_programmatic_params_use_percent20_deterministic() {
10685        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10686        config.query_params = vec![
10687            ("b".to_string(), "x y".to_string()),
10688            ("a".to_string(), "1".to_string()),
10689        ];
10690        let exchange = Exchange::new(Message::default());
10691
10692        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10693
10694        // Declaration order (not lexical), minimal RFC-3986 encoding,
10695        // `%20` — never `+` — for spaces.
10696        assert_eq!(url, "http://h/p?b=x%20y&a=1");
10697        assert!(!url.contains('+'));
10698    }
10699
10700    #[test]
10701    fn resolve_url_authored_and_programmatic_merge() {
10702        let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
10703        config.query_params = vec![
10704            ("b".to_string(), "2".to_string()),
10705            ("a".to_string(), "9".to_string()),
10706        ];
10707        let exchange = Exchange::new(Message::default());
10708
10709        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10710
10711        // Programmatic `b` appended (absent from raw); programmatic `a=9`
10712        // ignored (authored key wins); no duplication.
10713        assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
10714    }
10715
10716    #[test]
10717    fn from_uri_no_longer_fills_query_params_from_uri() {
10718        let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
10719
10720        // Authored pairs live in raw_query ONLY (provenance pin).
10721        assert!(
10722            config.query_params.is_empty(),
10723            "query_params is programmatic-only: {:?}",
10724            config.query_params
10725        );
10726        assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
10727    }
10728
10729    #[test]
10730    fn resolve_url_forbidden_raw_byte_errors() {
10731        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10732        config.raw_query = Some("a=x y".to_string());
10733        let exchange = Exchange::new(Message::default());
10734
10735        let err = HttpProducer::resolve_url(&exchange, &config)
10736            .expect_err("literal space in raw query must error");
10737
10738        // The error names the forbidden byte; no output string is produced.
10739        assert!(
10740            err.to_string().contains("0x20"),
10741            "error must name the forbidden byte: {err}"
10742        );
10743    }
10744
10745    /// rc-m4xk1: the override URI's own query is span-validated at resolve
10746    /// time — a forbidden byte in the override arm errors naming the byte,
10747    /// instead of riding verbatim to a reqwest send error.
10748    #[test]
10749    fn resolve_url_override_query_forbidden_byte_errors() {
10750        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10751        let mut exchange = Exchange::new(Message::default());
10752        exchange.input.set_header(
10753            "CamelHttpUri",
10754            serde_json::Value::String("http://h2/p?a=x y".to_string()),
10755        );
10756
10757        let err = HttpProducer::resolve_url(&exchange, &config)
10758            .expect_err("literal space in the override URI's query must error");
10759
10760        assert!(
10761            err.to_string().contains("0x20"),
10762            "error must name the forbidden byte from the override query: {err}"
10763        );
10764    }
10765
10766    /// rc-m4xk1 pin: decoded-key collision — a header pair whose key decodes
10767    /// to a key already present in the higher-precedence query (here
10768    /// `%61=2`, decoding to `a`) is dropped by the shared decoded-key
10769    /// matching; the higher-precedence authored span rides verbatim.
10770    #[test]
10771    fn merge_header_query_decoded_key_collision_drops_header_pair() {
10772        let merged = merge_header_query(Some("a=1"), "%61=2")
10773            .expect("decoded-key collision must not be a parse error");
10774        assert_eq!(
10775            merged.as_deref(),
10776            Some("a=1"),
10777            "the higher-precedence span wins and the colliding header pair is dropped"
10778        );
10779    }
10780
10781    /// rc-m4xk1 pin: duplicate keys within ONE header query are not
10782    /// deduplicated — both spans ride verbatim in authored order.
10783    #[test]
10784    fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
10785        let merged = merge_header_query(None, "k=1&k=2")
10786            .expect("duplicate header keys must not be a parse error");
10787        assert_eq!(
10788            merged.as_deref(),
10789            Some("k=1&k=2"),
10790            "intra-header duplicate keys ride verbatim"
10791        );
10792    }
10793
10794    /// rc-dhkeo: the Debug surface masks userinfo-style bytes in
10795    /// `base_url` and leaves a userinfo-free base untouched, byte-for-byte.
10796    /// rc-yvjp3 (ADR-0076 strictest-wins): `base_url` routes through the
10797    /// canonical `camel_api::redact::redact_url` — query and fragment bytes
10798    /// now drop behind their sentinels and later `//user:pass@` windows
10799    /// mask too, dimensions the former byte-preserving local variant kept.
10800    #[test]
10801    fn endpoint_config_debug_masks_base_url_userinfo() {
10802        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10803        config.base_url = "http://user:pass@h.example/p".to_string();
10804        let rendered = format!("{config:?}");
10805        assert!(
10806            rendered.contains("***@h.example"),
10807            "userinfo must render masked: {rendered}"
10808        );
10809        assert!(
10810            !rendered.contains("user:pass"),
10811            "no credentials in Debug output: {rendered}"
10812        );
10813
10814        let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10815        let rendered_plain = format!("{plain:?}");
10816        assert!(
10817            rendered_plain.contains("http://h.example/p"),
10818            "a base without userinfo renders unchanged: {rendered_plain}"
10819        );
10820    }
10821
10822    /// rc-yvjp3 convergence: an authored query and fragment on `base_url`
10823    /// render as sentinels, never as raw bytes (strictest-wins over the
10824    /// former byte-preserving variant), and the rendered value is
10825    /// byte-identical to the canonical helper.
10826    #[test]
10827    fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
10828        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10829
10830        config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
10831        let rendered = format!("{config:?}");
10832        assert!(
10833            rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
10834            "query and fragment must render as composed sentinels: {rendered}"
10835        );
10836        assert!(
10837            !rendered.contains("token=secret") && !rendered.contains("access_token"),
10838            "query/fragment credential bytes must not render: {rendered}"
10839        );
10840
10841        config.base_url = "http://h.example//u2:p2@evil/".to_string();
10842        let rendered = format!("{config:?}");
10843        assert!(
10844            rendered.contains("base_url: \"http://h.example//***@evil/\""),
10845            "later //window userinfo must mask (canonical window rule): {rendered}"
10846        );
10847        assert!(
10848            !rendered.contains("u2:p2"),
10849            "later-window credentials must not render: {rendered}"
10850        );
10851
10852        // Cross-surface identity: the Debug field is byte-identical to the
10853        // canonical helper output for the same input.
10854        config.base_url = "http://user:pass@h.example/p?token=x".to_string();
10855        let canonical = camel_api::redact::redact_url(&config.base_url);
10856        assert_eq!(canonical, "http://***@h.example/p?[redacted]");
10857        let rendered = format!("{config:?}");
10858        assert!(
10859            rendered.contains(&format!("base_url: \"{canonical}\"")),
10860            "Debug base_url must equal canonical redact_url output: {rendered}"
10861        );
10862    }
10863
10864    /// rc-nmupb: authored apostrophe (0x27) is RFC 3986 pchar-legal, but
10865    /// reqwest's WHATWG parser re-encodes it as `%27` in every http/https
10866    /// query — the raw byte can never ride the wire verbatim. Resolve
10867    /// rejects it naming the byte; the authored `%27` escape is the
10868    /// wire-faithful form and rides verbatim.
10869    #[test]
10870    fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
10871        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10872
10873        config.raw_query = Some("q=it's".to_string());
10874        let exchange = Exchange::new(Message::default());
10875        let err = HttpProducer::resolve_url(&exchange, &config)
10876            .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
10877        assert!(
10878            err.to_string().contains("0x27"),
10879            "error must name the apostrophe byte: {err}"
10880        );
10881
10882        config.raw_query = Some("q=it%27s".to_string());
10883        let url = HttpProducer::resolve_url(&exchange, &config)
10884            .expect("authored %27 escape is wire-legal");
10885        assert!(
10886            url.contains("q=it%27s"),
10887            "the authored escape must ride byte-for-byte: {url}"
10888        );
10889
10890        // The rest of reqwest's WHATWG special-query set shares the same
10891        // rationale and is rejected alongside (`"` and backtick are not
10892        // RFC 3986 query-legal bytes; `<`/`>` likewise).
10893        for &byte in b"\"`<>" {
10894            config.raw_query = Some(format!("k={}x", byte as char));
10895            let err = HttpProducer::resolve_url(&exchange, &config)
10896                .expect_err("WHATWG special-query byte must be rejected");
10897            assert!(
10898                err.to_string().contains(&format!("0x{byte:02X}")),
10899                "error must name byte 0x{byte:02X}: {err}"
10900            );
10901        }
10902    }
10903
10904    #[test]
10905    fn armed_fence_rejects_unknown_host_redacted() {
10906        let cfg = HttpEndpointConfig::from_uri(
10907            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10908        )
10909        .unwrap();
10910        let mut exchange = Exchange::new(Message::default());
10911        exchange.input.set_header(
10912            "CamelHttpUri",
10913            serde_json::Value::String(
10914                "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
10915            ),
10916        );
10917
10918        let err = HttpProducer::resolve_url(&exchange, &cfg)
10919            .expect_err("override host outside the fence must fail resolution");
10920
10921        let message = err.to_string();
10922        assert!(!message.contains("pass"), "userinfo leaked: {message}");
10923        assert!(!message.contains("s3cret"), "query leaked: {message}");
10924    }
10925
10926    #[test]
10927    fn armed_fence_rejects_unparseable_override_redacted() {
10928        let cfg = HttpEndpointConfig::from_uri(
10929            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10930        )
10931        .unwrap();
10932        let mut exchange = Exchange::new(Message::default());
10933        exchange.input.set_header(
10934            "CamelHttpUri",
10935            serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
10936        );
10937
10938        let err = HttpProducer::resolve_url(&exchange, &cfg)
10939            .expect_err("unparseable override outside the fence must fail resolution");
10940
10941        let message = err.to_string();
10942        assert!(
10943            message.contains("allowedUriHosts fence"),
10944            "fence must be named: {message}"
10945        );
10946        assert!(
10947            message.contains("[redacted]"),
10948            "suppression sentinel missing: {message}"
10949        );
10950        assert!(
10951            !message.contains("evil.example.com"),
10952            "host leaked: fail-closed arm must render only the sentinel: {message}"
10953        );
10954        assert!(
10955            !message.contains("fencesecret"),
10956            "password leaked: {message}"
10957        );
10958        assert!(!message.contains("u:"), "userinfo leaked: {message}");
10959    }
10960
10961    #[test]
10962    fn armed_fence_rejects_password_only_userinfo_redacted() {
10963        let cfg = HttpEndpointConfig::from_uri(
10964            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10965        )
10966        .unwrap();
10967        let mut exchange = Exchange::new(Message::default());
10968        exchange.input.set_header(
10969            "CamelHttpUri",
10970            serde_json::Value::String(
10971                "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
10972            ),
10973        );
10974
10975        let err = HttpProducer::resolve_url(&exchange, &cfg)
10976            .expect_err("password-only override outside the fence must fail resolution");
10977
10978        let message = err.to_string();
10979        assert!(
10980            !message.contains("passwordonly"),
10981            "password-only userinfo leaked: {message}"
10982        );
10983        assert!(!message.contains("querysecret"), "query leaked: {message}");
10984        assert!(
10985            message.contains("http://***@evil.example.com/x?[redacted]"),
10986            "masked shape missing: {message}"
10987        );
10988    }
10989
10990    #[test]
10991    fn armed_fence_allows_listed_host() {
10992        let cfg = HttpEndpointConfig::from_uri(
10993            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10994        )
10995        .unwrap();
10996        let mut exchange = Exchange::new(Message::default());
10997        exchange.input.set_header(
10998            "CamelHttpUri",
10999            serde_json::Value::String("http://cdn.example.com/x".to_string()),
11000        );
11001
11002        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11003        assert_eq!(url, "http://cdn.example.com/x");
11004    }
11005
11006    #[test]
11007    fn host_only_entry_permits_any_port() {
11008        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
11009        let mut exchange = Exchange::new(Message::default());
11010        exchange.input.set_header(
11011            "CamelHttpUri",
11012            serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
11013        );
11014
11015        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11016        assert_eq!(url, "http://cdn.example.com:9443/x");
11017    }
11018
11019    #[test]
11020    fn unarmed_endpoint_unchanged() {
11021        let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
11022        let mut exchange = Exchange::new(Message::default());
11023        exchange.input.set_header(
11024            "CamelHttpUri",
11025            serde_json::Value::String("http://any.example.com/path".to_string()),
11026        );
11027
11028        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11029        assert_eq!(url, "http://any.example.com/path");
11030    }
11031
11032    #[test]
11033    fn empty_allowlist_fails_endpoint_creation() {
11034        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
11035    }
11036
11037    #[test]
11038    fn malformed_entry_fails_endpoint_creation() {
11039        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
11040    }
11041
11042    #[test]
11043    fn fence_entry_with_path_fails_creation() {
11044        // A trailing path is a typo'd entry: silently narrowing it to the
11045        // hostname would widen or skew the fence. Reject loudly.
11046        assert!(
11047            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
11048        );
11049    }
11050
11051    #[test]
11052    fn fence_entry_with_userinfo_fails_creation() {
11053        assert!(
11054            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
11055        );
11056    }
11057
11058    #[test]
11059    fn ipv6_fence_entry_allows_bracketed_host() {
11060        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
11061        // The textual host forms differ; both parse to the same bracketed
11062        // canonical host (`[::1]`) that the entry stores, so both ride.
11063        for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
11064            let mut exchange = Exchange::new(Message::default());
11065            exchange
11066                .input
11067                .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
11068            let url = HttpProducer::resolve_url(&exchange, &cfg)
11069                .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
11070            assert_eq!(url, uri, "bracketed IPv6 override not honored");
11071        }
11072    }
11073
11074    #[test]
11075    fn dns_case_insensitive_fence_match() {
11076        // The entry is stored ASCII-lowercased, so the mixed-case option
11077        // matches the lowercase override host.
11078        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
11079        let mut exchange = Exchange::new(Message::default());
11080        exchange.input.set_header(
11081            "CamelHttpUri",
11082            serde_json::Value::String("http://cdn.example.com/x".to_string()),
11083        );
11084        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11085        assert_eq!(url, "http://cdn.example.com/x");
11086    }
11087
11088    #[test]
11089    fn fence_allowed_override_query_merges_with_header() {
11090        // Fence pass plus full composition: the override URI query is the
11091        // higher-precedence source, the header pair appends.
11092        let cfg =
11093            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
11094        let mut exchange = Exchange::new(Message::default());
11095        exchange.input.set_header(
11096            "CamelHttpUri",
11097            serde_json::Value::String("http://host.example/api?a=1".to_string()),
11098        );
11099        exchange.input.set_header(
11100            "CamelHttpQuery",
11101            serde_json::Value::String("b=2".to_string()),
11102        );
11103
11104        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11105        assert_eq!(url, "http://host.example/api?a=1&b=2");
11106    }
11107
11108    #[test]
11109    fn empty_header_with_armed_fence_leaves_no_query() {
11110        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
11111        let mut exchange = Exchange::new(Message::default());
11112        exchange.input.set_header(
11113            "CamelHttpUri",
11114            serde_json::Value::String("http://host.example/api".to_string()),
11115        );
11116        exchange
11117            .input
11118            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
11119
11120        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11121        assert_eq!(url, "http://host.example/api");
11122        assert!(!url.contains('?'), "query marker leaked: {url}");
11123    }
11124
11125    #[test]
11126    fn fence_option_is_consumed() {
11127        // A raw query on the base URI plus the fence option; no override
11128        // header. The option is consumed at parse time and must never
11129        // appear in the outbound query.
11130        let cfg =
11131            HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
11132        let exchange = Exchange::new(Message::default());
11133
11134        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11135        assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
11136        assert!(url.contains("x=1"), "authored query lost: {url}");
11137    }
11138
11139    #[tokio::test]
11140    async fn resolve_url_malformed_base_url_errors_no_panic() {
11141        use tower::ServiceExt;
11142
11143        let (url, _handle) = start_test_server().await;
11144        let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
11145        config.allow_internal = true; // test server binds 127.0.0.1
11146        let producer = HttpProducer {
11147            config: Arc::new(config),
11148            client: build_client(&HttpConfig::default(), None).expect("client must build"), // allow-unwrap(test)
11149            pinned_cache: Arc::new(PinnedClientCache::new(
11150                PINNED_CLIENT_TTL,
11151                PINNED_CLIENT_MAX_ENTRIES,
11152            )),
11153            http_config: Arc::new(HttpConfig::default()),
11154            runtime: rt(),
11155        };
11156
11157        // First call: malformed base URL propagates as an error through the
11158        // real producer path — no panic, no poisoned state (rc-ph7z2).
11159        let first = producer
11160            .clone()
11161            .oneshot(Exchange::new(Message::default()))
11162            .await;
11163        let err = first.expect_err("malformed base URL must error, not panic");
11164        assert!(
11165            err.to_string().to_lowercase().contains("url"),
11166            "error must name the malformed URL: {err}"
11167        );
11168
11169        // Second call through the SAME producer succeeds — the failure
11170        // left no poisoned state.
11171        let mut exchange = Exchange::new(Message::default());
11172        exchange.input.set_header(
11173            "CamelHttpUri",
11174            serde_json::Value::String(format!("{url}/api")),
11175        );
11176        let response = producer
11177            .oneshot(exchange)
11178            .await
11179            .expect("valid request through same producer must succeed");
11180        let status = response
11181            .input
11182            .header("CamelHttpResponseCode")
11183            .and_then(|v| v.as_u64())
11184            .unwrap();
11185        assert_eq!(status, 200);
11186    }
11187
11188    #[test]
11189    fn resolve_url_bridge_malformed_base_errors_no_panic() {
11190        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
11191        cfg.bridge_endpoint = true;
11192        cfg.query_params.push(("k".to_string(), "1".to_string()));
11193        // `from_uri` rejects the malformed authority, so the base is set on
11194        // the stored config directly (same build shape as the scheme-case
11195        // test). The bridge arm's validation-only parse (rc-ph7z2) must
11196        // surface it as an error — no panic.
11197        cfg.base_url = "http://[::1:bad".to_string();
11198        let exchange = Exchange::new(Message::default());
11199
11200        let err = HttpProducer::resolve_url(&exchange, &cfg)
11201            .expect_err("malformed bridge base URL must error");
11202        assert!(
11203            err.to_string().contains("invalid base URL"),
11204            "error must name the invalid base URL: {err}"
11205        );
11206    }
11207
11208    #[test]
11209    fn test_http_producer_helpers_status_and_size_boundaries() {
11210        assert!(HttpProducer::is_ok_status(200, (200, 299)));
11211        assert!(HttpProducer::is_ok_status(299, (200, 299)));
11212        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
11213        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
11214
11215        assert!(!exceeds_max_response_body(10, 10));
11216        assert!(exceeds_max_response_body(11, 10));
11217    }
11218
11219    // -----------------------------------------------------------------------
11220    // Content-Type inference tests
11221    // -----------------------------------------------------------------------
11222
11223    #[allow(clippy::await_holding_lock)]
11224    async fn setup_consumer_on_free_port(
11225        path: &str,
11226    ) -> (
11227        u16,
11228        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
11229        tokio_util::sync::CancellationToken,
11230    ) {
11231        use camel_component_api::ConsumerContext;
11232
11233        // ADR-0070 staged-listener law: bind, KEEP the socket, and stage it
11234        // in the ServerRegistry; the consumer's `get_or_spawn` consumes the
11235        // staged listener, so the port never returns to the ephemeral pool
11236        // between probe and serve (no bind-read-drop race).
11237        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11238        let port = listener.local_addr().unwrap().port();
11239
11240        // Hold the registry test mutex across the whole stage→spawn→ready
11241        // window so a concurrent `ServerRegistry::reset()` cannot evict the
11242        // staged listener between staging and readiness. The guard covers
11243        // stage_listener, the consumer spawn, the readiness poll and the
11244        // tail-yield loop; it releases when this helper returns.
11245        // Poison-recovering acquire: a failed sibling test must not
11246        // cascade — the mutex guards test serialization only, no
11247        // structural invariant, so recovery via into_inner is safe.
11248        let _registry_guard = lock_registry_test_mutex();
11249
11250        ServerRegistry::global()
11251            .stage_listener(listener)
11252            .await
11253            .expect("stage consumer test listener");
11254
11255        let consumer_cfg = HttpServerConfig {
11256            scheme: "http".to_string(),
11257            host: "127.0.0.1".to_string(),
11258            port,
11259            path: path.to_string(),
11260            max_request_body: 2 * 1024 * 1024,
11261            max_response_body: 10 * 1024 * 1024,
11262            max_inflight_requests: 1024,
11263            method: None,
11264            tls_config: None,
11265        };
11266        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
11267
11268        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
11269        let token = tokio_util::sync::CancellationToken::new();
11270        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
11271
11272        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11273
11274        // Readiness without a fixed wall-clock sleep: poll the registry
11275        // entry live (1ms doubling backoff, 10s deadline), then yield so
11276        // the spawned `start()` completes route registration (that tail
11277        // path has no pending timers — only the registry lock — so
11278        // scheduler yields order it deterministically behind this loop).
11279        wait_for_registry_ready("127.0.0.1", port).await;
11280        for _ in 0..8 {
11281            tokio::task::yield_now().await;
11282        }
11283
11284        (port, rx, token)
11285    }
11286
11287    /// Poll `ServerRegistry::bound_addr(host, port)` until the entry
11288    /// appears: 1ms backoff doubling per iteration, capped at 64ms, with
11289    /// a 10s deadline. Panics with a hint naming the likely causes when
11290    /// the deadline fires.
11291    async fn wait_for_registry_ready(host: &str, port: u16) {
11292        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
11293        let mut backoff = std::time::Duration::from_millis(1);
11294        while ServerRegistry::global().bound_addr(host, port).is_none() {
11295            assert!(
11296                tokio::time::Instant::now() < deadline,
11297                "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
11298            );
11299            tokio::time::sleep(backoff).await;
11300            backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
11301        }
11302    }
11303
11304    #[tokio::test(start_paused = true)]
11305    #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
11306    async fn readiness_deadline_fires_loud_with_hint() {
11307        // start_paused: the backoff sleeps and the 10s deadline run on
11308        // the mocked clock (tokio test-util dev-feature), so the loud
11309        // path costs no wall time.
11310        // Poll a key no writer can produce. Registry keys come from
11311        // either the listener's resolved IP string (staged path) or the
11312        // caller-provided host verbatim (legacy get_or_spawn path), so a
11313        // synthetic host literal that no test passes is unreachable on
11314        // BOTH paths. Binding and HOLDING the listener (never dropped,
11315        // never staged) additionally keeps its port out of the ephemeral
11316        // pool, so no concurrent test can register that port either.
11317        // (Earlier drafts polled 127.0.0.2 — rejected: macOS exposes only
11318        // 127.0.0.1 and the bind fails there, rc-dwmd; and "localhost" —
11319        // rejected: the legacy host-verbatim path could produce it.)
11320        let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11321        let port = held.local_addr().unwrap().port();
11322        wait_for_registry_ready("httpflake-unreachable-host", port).await;
11323    }
11324
11325    // -----------------------------------------------------------------------
11326    // Readiness vs concurrent registry reset (httpflake, regression RED)
11327    // -----------------------------------------------------------------------
11328
11329    #[tokio::test]
11330    async fn readiness_survives_concurrent_registry_reset() {
11331        let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11332        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
11333
11334        // Hammer thread: loop legal resets, counting a contention only
11335        // when its try-lock on the registry test mutex reports WouldBlock
11336        // (someone else held it). Poison is a sibling's panic, not
11337        // contention: recover through the poison-recovering helper
11338        // without counting it. The guard is dropped at each iteration
11339        // end.
11340        let contended_hammer = std::sync::Arc::clone(&contended);
11341        let stop_hammer = std::sync::Arc::clone(&stop);
11342        let handle = std::thread::spawn(move || {
11343            while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
11344                let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
11345                    Err(std::sync::TryLockError::WouldBlock) => {
11346                        contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
11347                        lock_registry_test_mutex()
11348                    }
11349                    Err(std::sync::TryLockError::Poisoned(_)) => lock_registry_test_mutex(),
11350                    Ok(guard) => guard,
11351                };
11352                ServerRegistry::reset();
11353            }
11354        });
11355
11356        // Drop guard: even if a setup panics, stop the hammer and join it so
11357        // the thread never outlives the test.
11358        struct StopHammerOnDrop {
11359            handle: Option<std::thread::JoinHandle<()>>,
11360            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
11361        }
11362        impl Drop for StopHammerOnDrop {
11363            fn drop(&mut self) {
11364                self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
11365                if let Some(handle) = self.handle.take() {
11366                    let _ = handle.join();
11367                }
11368            }
11369        }
11370        let _hammer_guard = StopHammerOnDrop {
11371            handle: Some(handle),
11372            stop,
11373        };
11374
11375        // Always at least 25 setups on fresh ephemeral ports; continue past
11376        // 25 only until one contended reset is observed; hard cap 50.
11377        let mut setups = 0;
11378        loop {
11379            setups += 1;
11380            let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
11381            drop(rx);
11382            token.cancel();
11383            if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
11384                || setups >= 50
11385            {
11386                break;
11387            }
11388        }
11389
11390        let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
11391        assert!(
11392            contended_hits >= 1,
11393            "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
11394        );
11395    }
11396
11397    #[tokio::test]
11398    async fn test_content_type_inferred_for_json_body() {
11399        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
11400
11401        let client = plain_http_test_client();
11402        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
11403
11404        let (http_result, _) = tokio::join!(send_fut, async {
11405            if let Some(mut envelope) = rx.recv().await {
11406                envelope.exchange.input.body =
11407                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
11408                if let Some(reply_tx) = envelope.reply_tx {
11409                    let _ = reply_tx.send(Ok(envelope.exchange));
11410                }
11411            }
11412        });
11413
11414        let resp = http_result.unwrap();
11415        assert_eq!(resp.status().as_u16(), 200);
11416        let ct = resp
11417            .headers()
11418            .get("content-type")
11419            .expect("Content-Type header should be present");
11420        assert_eq!(ct, "application/json");
11421        let body = resp.text().await.unwrap();
11422        assert_eq!(body, r#"{"message":"hello"}"#);
11423
11424        token.cancel();
11425    }
11426
11427    #[tokio::test]
11428    async fn test_content_type_inferred_for_text_body() {
11429        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
11430
11431        // build_client (not bare plain_http_test_client()): CA-store test
11432        // windows are process-visible and Client::new() panics when the
11433        // native store loads zero roots; the crate builder falls back to
11434        // webpki roots instead.
11435        let client = build_client(&HttpConfig::default(), None).expect("client must build"); // allow-unwrap(test)
11436        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
11437
11438        let (http_result, _) = tokio::join!(send_fut, async {
11439            if let Some(mut envelope) = rx.recv().await {
11440                envelope.exchange.input.body =
11441                    camel_component_api::Body::Text("plain text response".to_string());
11442                if let Some(reply_tx) = envelope.reply_tx {
11443                    let _ = reply_tx.send(Ok(envelope.exchange));
11444                }
11445            }
11446        });
11447
11448        let resp = http_result.unwrap();
11449        assert_eq!(resp.status().as_u16(), 200);
11450        let ct = resp
11451            .headers()
11452            .get("content-type")
11453            .expect("Content-Type header should be present");
11454        assert_eq!(ct, "text/plain; charset=utf-8");
11455        let body = resp.text().await.unwrap();
11456        assert_eq!(body, "plain text response");
11457
11458        token.cancel();
11459    }
11460
11461    #[tokio::test]
11462    async fn test_content_type_inferred_for_xml_body() {
11463        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
11464
11465        let client = plain_http_test_client();
11466        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
11467
11468        let (http_result, _) = tokio::join!(send_fut, async {
11469            if let Some(mut envelope) = rx.recv().await {
11470                envelope.exchange.input.body =
11471                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
11472                if let Some(reply_tx) = envelope.reply_tx {
11473                    let _ = reply_tx.send(Ok(envelope.exchange));
11474                }
11475            }
11476        });
11477
11478        let resp = http_result.unwrap();
11479        assert_eq!(resp.status().as_u16(), 200);
11480        let ct = resp
11481            .headers()
11482            .get("content-type")
11483            .expect("Content-Type header should be present");
11484        assert_eq!(ct, "application/xml");
11485        let body = resp.text().await.unwrap();
11486        assert_eq!(body, "<root><item>value</item></root>");
11487
11488        token.cancel();
11489    }
11490
11491    #[tokio::test]
11492    async fn test_no_content_type_for_empty_body() {
11493        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
11494
11495        let client = plain_http_test_client();
11496        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
11497
11498        let (http_result, _) = tokio::join!(send_fut, async {
11499            if let Some(mut envelope) = rx.recv().await {
11500                envelope.exchange.input.body = camel_component_api::Body::Empty;
11501                if let Some(reply_tx) = envelope.reply_tx {
11502                    let _ = reply_tx.send(Ok(envelope.exchange));
11503                }
11504            }
11505        });
11506
11507        let resp = http_result.unwrap();
11508        assert_eq!(resp.status().as_u16(), 200);
11509        assert!(
11510            resp.headers().get("content-type").is_none(),
11511            "Empty body should not set Content-Type"
11512        );
11513
11514        token.cancel();
11515    }
11516
11517    #[tokio::test]
11518    async fn test_no_content_type_for_raw_bytes_body() {
11519        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
11520
11521        let client = plain_http_test_client();
11522        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
11523
11524        let (http_result, _) = tokio::join!(send_fut, async {
11525            if let Some(mut envelope) = rx.recv().await {
11526                envelope.exchange.input.body =
11527                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
11528                if let Some(reply_tx) = envelope.reply_tx {
11529                    let _ = reply_tx.send(Ok(envelope.exchange));
11530                }
11531            }
11532        });
11533
11534        let resp = http_result.unwrap();
11535        assert_eq!(resp.status().as_u16(), 200);
11536        assert!(
11537            resp.headers().get("content-type").is_none(),
11538            "Raw Bytes body should not set Content-Type"
11539        );
11540
11541        token.cancel();
11542    }
11543
11544    #[tokio::test]
11545    async fn test_content_type_from_stream_metadata() {
11546        use camel_component_api::{StreamBody, StreamMetadata};
11547        use futures::stream;
11548
11549        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
11550
11551        let client = plain_http_test_client();
11552        let send_fut = client
11553            .get(format!("http://127.0.0.1:{port}/stream-ct"))
11554            .send();
11555
11556        let (http_result, _) = tokio::join!(send_fut, async {
11557            if let Some(mut envelope) = rx.recv().await {
11558                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
11559                    vec![Ok(bytes::Bytes::from("audio data"))];
11560                let stream = Box::pin(stream::iter(chunks));
11561                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
11562                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
11563                    metadata: StreamMetadata {
11564                        size_hint: None,
11565                        content_type: Some("audio/mpeg".to_string()),
11566                        origin: None,
11567                    },
11568                });
11569                if let Some(reply_tx) = envelope.reply_tx {
11570                    let _ = reply_tx.send(Ok(envelope.exchange));
11571                }
11572            }
11573        });
11574
11575        let resp = http_result.unwrap();
11576        assert_eq!(resp.status().as_u16(), 200);
11577        let ct = resp
11578            .headers()
11579            .get("content-type")
11580            .expect("Content-Type header should be present");
11581        assert_eq!(ct, "audio/mpeg");
11582        let body = resp.text().await.unwrap();
11583        assert_eq!(body, "audio data");
11584
11585        token.cancel();
11586    }
11587
11588    #[tokio::test]
11589    async fn test_user_content_type_overrides_inferred() {
11590        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
11591
11592        let client = plain_http_test_client();
11593        let send_fut = client
11594            .get(format!("http://127.0.0.1:{port}/override-ct"))
11595            .send();
11596
11597        let (http_result, _) = tokio::join!(send_fut, async {
11598            if let Some(mut envelope) = rx.recv().await {
11599                envelope.exchange.input.body =
11600                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
11601                envelope.exchange.input.set_header(
11602                    "Content-Type",
11603                    serde_json::Value::String("text/html".to_string()),
11604                );
11605                if let Some(reply_tx) = envelope.reply_tx {
11606                    let _ = reply_tx.send(Ok(envelope.exchange));
11607                }
11608            }
11609        });
11610
11611        let resp = http_result.unwrap();
11612        assert_eq!(resp.status().as_u16(), 200);
11613        let ct = resp
11614            .headers()
11615            .get("content-type")
11616            .expect("Content-Type header should be present");
11617        assert_eq!(
11618            ct, "text/html",
11619            "User-set Content-Type should take precedence over inferred type"
11620        );
11621
11622        token.cancel();
11623    }
11624
11625    #[tokio::test]
11626    async fn test_user_content_type_with_bytes_body() {
11627        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
11628
11629        let client = plain_http_test_client();
11630        let send_fut = client
11631            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
11632            .send();
11633
11634        let (http_result, _) = tokio::join!(send_fut, async {
11635            if let Some(mut envelope) = rx.recv().await {
11636                envelope.exchange.input.body =
11637                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
11638                envelope.exchange.input.set_header(
11639                    "Content-Type",
11640                    serde_json::Value::String("application/json".to_string()),
11641                );
11642                if let Some(reply_tx) = envelope.reply_tx {
11643                    let _ = reply_tx.send(Ok(envelope.exchange));
11644                }
11645            }
11646        });
11647
11648        let resp = http_result.unwrap();
11649        assert_eq!(resp.status().as_u16(), 200);
11650        let ct = resp
11651            .headers()
11652            .get("content-type")
11653            .expect("Content-Type header should be present for Bytes body with user header");
11654        assert_eq!(
11655            ct, "application/json",
11656            "User Content-Type should be sent for Bytes body"
11657        );
11658
11659        token.cancel();
11660    }
11661
11662    // -----------------------------------------------------------------------
11663    // Server monitor tests (GRL-005)
11664    // -----------------------------------------------------------------------
11665
11666    #[tokio::test]
11667    async fn monitor_task_silent_on_clean_exit() {
11668        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
11669        let server_exited = tokio_util::sync::CancellationToken::new();
11670        // Clean exit should complete without panicking or logging errors
11671        monitor_axum_task(
11672            handle,
11673            "127.0.0.1:0".to_string(),
11674            noop_rt(),
11675            "test-monitor".into(),
11676            server_exited.clone(),
11677        )
11678        .await;
11679        // rc-szmob: a clean exit must NOT fail hosted consumers — route
11680        // stops own their termination (no CrashNotification storm on
11681        // graceful process shutdown).
11682        assert!(
11683            !server_exited.is_cancelled(),
11684            "clean server exit must not cancel server_exited"
11685        );
11686    }
11687
11688    #[tokio::test]
11689    async fn monitor_task_handles_panicked_task() {
11690        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
11691            panic!("simulated server crash");
11692        });
11693        let server_exited = tokio_util::sync::CancellationToken::new();
11694        // Should complete without panicking even though the inner task panicked
11695        monitor_axum_task(
11696            handle,
11697            "127.0.0.1:9999".to_string(),
11698            noop_rt(),
11699            "test-monitor".into(),
11700            server_exited.clone(),
11701        )
11702        .await;
11703        // rc-szmob: unexpected exit must cancel the token so every hosted
11704        // consumer fails and supervision engages (ADR-0007).
11705        assert!(
11706            server_exited.is_cancelled(),
11707            "crashed server must cancel server_exited"
11708        );
11709    }
11710
11711    // -----------------------------------------------------------------------
11712    // Credential redaction tests
11713    // -----------------------------------------------------------------------
11714
11715    #[test]
11716    fn http_auth_basic_debug_redacts_password() {
11717        let auth = HttpAuth::Basic {
11718            username: "admin".to_string(),
11719            password: "hunter2".to_string(),
11720        };
11721        let debug = format!("{:?}", auth);
11722        assert!(
11723            !debug.contains("hunter2"),
11724            "password must be redacted: {debug}"
11725        );
11726        assert!(debug.contains("admin"), "username should appear: {debug}");
11727    }
11728
11729    #[test]
11730    fn http_auth_bearer_debug_redacts_token() {
11731        let auth = HttpAuth::Bearer {
11732            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
11733        };
11734        let debug = format!("{:?}", auth);
11735        assert!(
11736            !debug.contains("eyJhbGci"),
11737            "token must be redacted: {debug}"
11738        );
11739    }
11740
11741    #[test]
11742    fn http_auth_none_debug_shows_variant() {
11743        let debug = format!("{:?}", HttpAuth::None);
11744        assert!(
11745            debug.contains("None"),
11746            "None variant should appear: {debug}"
11747        );
11748    }
11749
11750    #[test]
11751    fn http_endpoint_config_debug_redacts_auth_credentials() {
11752        let config = HttpEndpointConfig::from_uri(
11753            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
11754        )
11755        .unwrap();
11756        let debug = format!("{:?}", config);
11757        assert!(
11758            !debug.contains("secret123"),
11759            "password must be redacted in HttpEndpointConfig debug: {debug}"
11760        );
11761    }
11762
11763    #[test]
11764    fn debug_lists_all_public_fields() {
11765        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
11766        let debug = format!("{:?}", config);
11767        for field in [
11768            "base_url",
11769            "http_method",
11770            "throw_exception_on_failure",
11771            "ok_status_code_range",
11772            "response_timeout",
11773            "query_params",
11774            "raw_query",
11775            "allow_internal",
11776            "allow_cleartext",
11777            "blocked_hosts",
11778            "max_body_size",
11779            "read_timeout_ms",
11780            "max_response_bytes",
11781            "auth",
11782            "token_provider",
11783            "user_agent",
11784            "bridge_endpoint",
11785            "connection_close",
11786            "skip_request_headers",
11787            "skip_response_headers",
11788            "follow_redirects",
11789            "max_redirects",
11790        ] {
11791            assert!(
11792                debug.contains(field),
11793                "Debug output missing field '{field}': {debug}"
11794            );
11795        }
11796    }
11797
11798    // -----------------------------------------------------------------------
11799    // Static file serving tests (Task 5)
11800    // -----------------------------------------------------------------------
11801
11802    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
11803    use tower_http::services::ServeDir;
11804
11805    fn make_test_registry() -> HttpRouteRegistry {
11806        HttpRouteRegistry::new()
11807    }
11808
11809    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
11810        AppState {
11811            registry,
11812            max_request_body: 2 * 1024 * 1024,
11813            max_response_body: 10 * 1024 * 1024,
11814            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
11815        }
11816    }
11817
11818    #[allow(clippy::await_holding_lock)]
11819    #[tokio::test]
11820    async fn test_static_file_serving_serves_file_contents() {
11821        let _guard = lock_registry_test_mutex();
11822        ServerRegistry::reset();
11823
11824        // Create temp dir with test files
11825        let temp_dir =
11826            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
11827        std::fs::create_dir_all(&temp_dir).unwrap();
11828        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
11829        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
11830
11831        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11832
11833        let registry = make_test_registry();
11834        let serve_dir = ServeDir::new(&canonical_dir)
11835            .precompressed_gzip()
11836            .precompressed_br()
11837            .append_index_html_on_directories(true);
11838
11839        let mount = StaticMount {
11840            mount_path: "/".to_string(),
11841            mode: MountMode::Static,
11842            dir: canonical_dir.clone(),
11843            cache_control: "public, max-age=3600".to_string(),
11844            error_pages: std::collections::HashMap::new(),
11845            serve_dir,
11846        };
11847        registry.register_static_mount(mount).await.unwrap();
11848
11849        let state = make_test_state(registry);
11850
11851        // Test serving hello.txt
11852        let req = Request::builder()
11853            .uri("/hello.txt")
11854            .body(AxumBody::empty())
11855            .unwrap();
11856        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
11857        assert_eq!(resp.status(), StatusCode::OK);
11858        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11859            .await
11860            .unwrap();
11861        assert_eq!(&body[..], b"Hello, static world!");
11862
11863        // Test serving style.css
11864        let req = Request::builder()
11865            .uri("/style.css")
11866            .body(AxumBody::empty())
11867            .unwrap();
11868        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11869        assert_eq!(resp.status(), StatusCode::OK);
11870        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11871            .await
11872            .unwrap();
11873        assert_eq!(&body[..], b"body { color: red; }");
11874
11875        // Test 404 for non-existent file
11876        let req = Request::builder()
11877            .uri("/missing.txt")
11878            .body(AxumBody::empty())
11879            .unwrap();
11880        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
11881        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11882
11883        // Cleanup
11884        std::fs::remove_dir_all(&temp_dir).ok();
11885    }
11886
11887    #[allow(clippy::await_holding_lock)]
11888    #[tokio::test]
11889    async fn test_spa_fallback_serves_index_for_unknown_paths() {
11890        let _guard = lock_registry_test_mutex();
11891        ServerRegistry::reset();
11892
11893        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
11894        std::fs::create_dir_all(&temp_dir).unwrap();
11895        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
11896        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
11897
11898        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11899
11900        let registry = make_test_registry();
11901        let serve_dir = ServeDir::new(&canonical_dir)
11902            .precompressed_gzip()
11903            .precompressed_br()
11904            .append_index_html_on_directories(true);
11905
11906        let mount = StaticMount {
11907            mount_path: "/".to_string(),
11908            mode: MountMode::Spa,
11909            dir: canonical_dir.clone(),
11910            cache_control: "public, max-age=0".to_string(),
11911            error_pages: std::collections::HashMap::new(),
11912            serve_dir,
11913        };
11914        // Register as SPA mount
11915        registry.register_static_mount(mount).await.unwrap();
11916
11917        let state = make_test_state(registry);
11918
11919        // SPA fallback: GET /dashboard with Accept: text/html → index.html
11920        let req = Request::builder()
11921            .method("GET")
11922            .uri("/dashboard")
11923            .header("Accept", "text/html")
11924            .body(AxumBody::empty())
11925            .unwrap();
11926        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
11927        assert_eq!(resp.status(), StatusCode::OK);
11928        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11929            .await
11930            .unwrap();
11931        assert_eq!(&body[..], b"<h1>SPA App</h1>");
11932
11933        // Static file still works: GET /app.js
11934        let req = Request::builder()
11935            .method("GET")
11936            .uri("/app.js")
11937            .body(AxumBody::empty())
11938            .unwrap();
11939        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
11940        assert_eq!(resp.status(), StatusCode::OK);
11941        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11942            .await
11943            .unwrap();
11944        assert_eq!(&body[..], b"console.log('app')");
11945
11946        // No SPA fallback for JSON accept → 404
11947        let req = Request::builder()
11948            .method("GET")
11949            .uri("/api/data")
11950            .header("Accept", "application/json")
11951            .body(AxumBody::empty())
11952            .unwrap();
11953        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
11954        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11955
11956        // No SPA fallback for file extensions → 404
11957        let req = Request::builder()
11958            .method("GET")
11959            .uri("/style.css")
11960            .header("Accept", "text/html")
11961            .body(AxumBody::empty())
11962            .unwrap();
11963        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11964        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11965
11966        // Cleanup
11967        std::fs::remove_dir_all(&temp_dir).ok();
11968    }
11969
11970    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
11971    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
11972    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
11973    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
11974    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
11975    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
11976    #[allow(clippy::await_holding_lock)]
11977    async fn run_conditional_get_returns_304(mode: MountMode) {
11978        let _guard = lock_registry_test_mutex();
11979        ServerRegistry::reset();
11980
11981        let temp_dir = std::env::temp_dir().join(format!(
11982            "http_cond_get_{}_{}",
11983            if mode == MountMode::Spa {
11984                "spa"
11985            } else {
11986                "static"
11987            },
11988            std::process::id()
11989        ));
11990        std::fs::create_dir_all(&temp_dir).unwrap();
11991        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11992
11993        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11994
11995        let registry = make_test_registry();
11996        let serve_dir = ServeDir::new(&canonical_dir)
11997            .precompressed_gzip()
11998            .precompressed_br()
11999            .append_index_html_on_directories(true);
12000
12001        let mount = StaticMount {
12002            mount_path: "/".to_string(),
12003            mode,
12004            dir: canonical_dir.clone(),
12005            cache_control: "public, max-age=3600".to_string(),
12006            error_pages: std::collections::HashMap::new(),
12007            serve_dir,
12008        };
12009        registry.register_static_mount(mount).await.unwrap();
12010
12011        let state = make_test_state(registry);
12012
12013        // 1st request: normal GET → 200, capture validators.
12014        let req = Request::builder()
12015            .method("GET")
12016            .uri("/index.html")
12017            .body(AxumBody::empty())
12018            .unwrap();
12019        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12020        assert_eq!(
12021            resp.status(),
12022            StatusCode::OK,
12023            "first GET should return 200, got {}",
12024            resp.status()
12025        );
12026        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
12027        assert!(
12028            resp.headers().contains_key(http::header::CACHE_CONTROL),
12029            "200 response missing Cache-Control"
12030        );
12031        let etag = resp
12032            .headers()
12033            .get(http::header::ETAG)
12034            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
12035            .clone();
12036        let last_modified = resp
12037            .headers()
12038            .get(http::header::LAST_MODIFIED)
12039            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
12040            .clone();
12041        // Consume the body so the response is fully drained.
12042        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
12043            .await
12044            .unwrap();
12045
12046        // 2nd request: If-None-Match with the captured ETag → 304.
12047        // Unconditional: ETag presence is required (asserted above) so this
12048        // sub-test cannot silently skip on a ServeDir etag_method change.
12049        let req = Request::builder()
12050            .method("GET")
12051            .uri("/index.html")
12052            .header(http::header::IF_NONE_MATCH, etag.clone())
12053            .body(AxumBody::empty())
12054            .unwrap();
12055        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12056        assert_eq!(
12057            resp.status(),
12058            StatusCode::NOT_MODIFIED,
12059            "If-None-Match with matching ETag should return 304, got {}",
12060            resp.status()
12061        );
12062        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
12063        assert!(
12064            resp.headers().contains_key(http::header::CACHE_CONTROL),
12065            "304 (If-None-Match) missing Cache-Control"
12066        );
12067        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
12068        // response parts rebuild in serve_via_serve_dir preserves them.
12069        assert_eq!(
12070            resp.headers().get(http::header::ETAG),
12071            Some(&etag),
12072            "304 (If-None-Match) must echo the ETag validator"
12073        );
12074        assert_eq!(
12075            resp.headers().get(http::header::LAST_MODIFIED),
12076            Some(&last_modified),
12077            "304 (If-None-Match) must carry Last-Modified"
12078        );
12079
12080        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
12081        let req = Request::builder()
12082            .method("GET")
12083            .uri("/index.html")
12084            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
12085            .body(AxumBody::empty())
12086            .unwrap();
12087        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12088        assert_eq!(
12089            resp.status(),
12090            StatusCode::NOT_MODIFIED,
12091            "If-Modified-Since with matching timestamp should return 304, got {}",
12092            resp.status()
12093        );
12094        assert!(
12095            resp.headers().contains_key(http::header::CACHE_CONTROL),
12096            "304 (If-Modified-Since) missing Cache-Control"
12097        );
12098        assert_eq!(
12099            resp.headers().get(http::header::ETAG),
12100            Some(&etag),
12101            "304 (If-Modified-Since) must carry the ETag validator"
12102        );
12103        assert_eq!(
12104            resp.headers().get(http::header::LAST_MODIFIED),
12105            Some(&last_modified),
12106            "304 (If-Modified-Since) must echo Last-Modified"
12107        );
12108
12109        // Negative control: a PAST If-Modified-Since (before the file's mtime)
12110        // MUST return 200 — proving the 304 path is validator-aware, not a
12111        // blanket "always 304" regression. A future date would correctly yield
12112        // 304 since the file's mtime precedes it; that is RFC-correct 304
12113        // behaviour, not a negative control.
12114        let req = Request::builder()
12115            .method("GET")
12116            .uri("/index.html")
12117            .header(
12118                http::header::IF_MODIFIED_SINCE,
12119                "Wed, 21 Oct 2000 07:28:00 GMT",
12120            )
12121            .body(AxumBody::empty())
12122            .unwrap();
12123        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12124        assert_eq!(
12125            resp.status(),
12126            StatusCode::OK,
12127            "past If-Modified-Since should return 200 (file modified after it), got {}",
12128            resp.status()
12129        );
12130
12131        // Cleanup
12132        std::fs::remove_dir_all(&temp_dir).ok();
12133    }
12134
12135    #[tokio::test]
12136    async fn test_conditional_get_returns_304_static_mode() {
12137        run_conditional_get_returns_304(MountMode::Static).await;
12138    }
12139
12140    #[tokio::test]
12141    async fn test_conditional_get_returns_304_spa_mode() {
12142        run_conditional_get_returns_304(MountMode::Spa).await;
12143    }
12144
12145    #[allow(clippy::await_holding_lock)]
12146    #[tokio::test]
12147    async fn test_error_page_mapping_serves_custom_404() {
12148        let _guard = lock_registry_test_mutex();
12149        ServerRegistry::reset();
12150
12151        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
12152        let errors_dir = temp_dir.join("errors");
12153        std::fs::create_dir_all(&errors_dir).unwrap();
12154        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
12155        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
12156
12157        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
12158        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
12159
12160        let registry = make_test_registry();
12161        let serve_dir = ServeDir::new(&canonical_dir)
12162            .precompressed_gzip()
12163            .precompressed_br()
12164            .append_index_html_on_directories(true);
12165
12166        let mut error_pages = std::collections::HashMap::new();
12167        error_pages.insert(404, canonical_404);
12168
12169        let mount = StaticMount {
12170            mount_path: "/".to_string(),
12171            mode: MountMode::Static,
12172            dir: canonical_dir.clone(),
12173            cache_control: "public, max-age=0".to_string(),
12174            error_pages,
12175            serve_dir,
12176        };
12177        registry.register_static_mount(mount).await.unwrap();
12178
12179        let state = make_test_state(registry);
12180
12181        // Request non-existent file → custom 404 page
12182        let req = Request::builder()
12183            .method("GET")
12184            .uri("/missing.html")
12185            .body(AxumBody::empty())
12186            .unwrap();
12187        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
12188        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
12189        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12190            .await
12191            .unwrap();
12192        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
12193
12194        // Existing file still works
12195        let req = Request::builder()
12196            .method("GET")
12197            .uri("/index.html")
12198            .body(AxumBody::empty())
12199            .unwrap();
12200        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12201        assert_eq!(resp.status(), StatusCode::OK);
12202        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12203            .await
12204            .unwrap();
12205        assert_eq!(&body[..], b"<h1>Home</h1>");
12206
12207        // Cleanup
12208        std::fs::remove_dir_all(&temp_dir).ok();
12209    }
12210
12211    #[tokio::test]
12212    async fn http_consumer_returns_body_and_code_on_stop() {
12213        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
12214        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
12215        use tower::ServiceExt;
12216
12217        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
12218        let set_body_step = CompiledStep::Process {
12219            kind_hint: camel_api::SpanKindHint::Internal,
12220            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
12221                ex.input.body = Body::Text("nope".into());
12222                Box::pin(async move { Ok(ex) })
12223            }),
12224            body_contract: None,
12225            lifecycle: None,
12226            label: None,
12227            to_uri: None,
12228        };
12229        let set_status_step = CompiledStep::Process {
12230            kind_hint: camel_api::SpanKindHint::Internal,
12231            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
12232                ex.input.set_header(
12233                    "CamelHttpResponseCode",
12234                    serde_json::Value::Number(409.into()),
12235                );
12236                Box::pin(async move { Ok(ex) })
12237            }),
12238            body_contract: None,
12239            lifecycle: None,
12240            label: None,
12241            to_uri: None,
12242        };
12243        let pipeline = compose_pipeline_with_handler(
12244            vec![set_body_step, set_status_step, CompiledStep::Stop],
12245            None,
12246            PipelineRuntimeCtx::compile_time(),
12247        );
12248
12249        let ex = Exchange::new(Message::default());
12250        let result = pipeline.oneshot(ex).await;
12251        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
12252        let returned = result.unwrap();
12253        assert_eq!(returned.input.body.as_text(), Some("nope"));
12254        assert_eq!(
12255            returned
12256                .input
12257                .header("CamelHttpResponseCode")
12258                .and_then(|v| v.as_u64()),
12259            Some(409)
12260        );
12261    }
12262
12263    #[tokio::test]
12264    async fn http_consumer_returns_200_when_body_empty_on_stop() {
12265        // After ADR-0024: Stop with no body + no status header produces 200 (same as
12266        // a normal completion with no body). The 204 default is gone — users who
12267        // want 204 set CamelHttpResponseCode=204 explicitly.
12268        //
12269        // This test stays at the pipeline level (consistent with the test above).
12270        // E2E coverage of the full HTTP dispatch path is in
12271        // crates/camel-test/tests/integration_test.rs.
12272        use camel_api::{Exchange, Message};
12273        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
12274        use tower::ServiceExt;
12275
12276        let pipeline = compose_pipeline_with_handler(
12277            vec![CompiledStep::Stop],
12278            None,
12279            PipelineRuntimeCtx::compile_time(),
12280        );
12281        let ex = Exchange::new(Message::default());
12282        let result = pipeline.oneshot(ex).await;
12283        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
12284        // Body is default (empty); no CamelHttpResponseCode header was set.
12285        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
12286    }
12287
12288    // -----------------------------------------------------------------------
12289    // Task 5: Method-aware REST dispatch tests
12290    // -----------------------------------------------------------------------
12291
12292    /// Spins up an axum server on a free port with a fresh registry.
12293    /// Returns the port plus the registry so the caller can register
12294    /// REST endpoints directly.
12295    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
12296        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12297        let port = listener.local_addr().unwrap().port();
12298        let registry = HttpRouteRegistry::new();
12299        tokio::spawn(run_axum_server(
12300            listener,
12301            registry.clone(),
12302            2 * 1024 * 1024,
12303            10 * 1024 * 1024,
12304            Arc::new(tokio::sync::Semaphore::new(1024)),
12305            test_rt(),
12306            "test-route".into(),
12307        ));
12308        // Give the server a moment to start accepting.
12309        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
12310        (port, registry)
12311    }
12312
12313    /// Helper for REST integration tests: spawns a responder task that
12314    /// reads from `rx`, writes a fixed `(status, body)` back via the
12315    /// envelope's reply channel, and returns once the test request is
12316    /// satisfied.
12317    fn spawn_responder(
12318        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
12319        status: u16,
12320        body: String,
12321    ) -> tokio::task::JoinHandle<()> {
12322        tokio::spawn(async move {
12323            // Per-iteration deadline (lintwiden D4.2): the recv wait is
12324            // bounded; a stall panics the task (surfaced at the join)
12325            // instead of parking it. Channel close still ends silently.
12326            match tokio::time::timeout(Duration::from_secs(10), rx.recv()).await {
12327                Ok(Some(envelope)) => {
12328                    let _ = envelope.reply_tx.send(HttpReply {
12329                        status,
12330                        headers: vec![],
12331                        body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
12332                    });
12333                }
12334                Ok(None) => {}
12335                Err(_) => panic!("spawn_responder: no request arrived within 10s"),
12336            }
12337        })
12338    }
12339
12340    #[tokio::test]
12341    async fn method_aware_dispatch_same_path_different_verbs() {
12342        let (port, registry) = spawn_test_server().await;
12343
12344        // Register two REST endpoints on the same path with different
12345        // methods. This is the core scenario REST DSL needs to support:
12346        // GET /users (list) and POST /users (create) must not overwrite
12347        // each other.
12348        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12349        registry
12350            .register_rest_endpoint(
12351                "GET".into(),
12352                vec![PathSegment::Literal("users".into())],
12353                get_tx,
12354            )
12355            .await;
12356
12357        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12358        registry
12359            .register_rest_endpoint(
12360                "POST".into(),
12361                vec![PathSegment::Literal("users".into())],
12362                post_tx,
12363            )
12364            .await;
12365
12366        let get_handle = spawn_responder(get_rx, 200, "list".into());
12367        let post_handle = spawn_responder(post_rx, 201, "create".into());
12368
12369        let client = plain_http_test_client();
12370
12371        // GET /users → list route
12372        let resp = client
12373            .get(format!("http://127.0.0.1:{port}/users"))
12374            .send()
12375            .await
12376            .unwrap();
12377        assert_eq!(resp.status().as_u16(), 200);
12378        let body = resp.text().await.unwrap();
12379        assert_eq!(body, "list");
12380
12381        // POST /users → create route
12382        let resp = client
12383            .post(format!("http://127.0.0.1:{port}/users"))
12384            .send()
12385            .await
12386            .unwrap();
12387        assert_eq!(resp.status().as_u16(), 201);
12388        let body = resp.text().await.unwrap();
12389        assert_eq!(body, "create");
12390
12391        let (get_joined, post_joined) = tokio::join!(get_handle, post_handle);
12392        get_joined.expect("get responder task must not stall");
12393        post_joined.expect("post responder task must not stall");
12394    }
12395
12396    #[tokio::test]
12397    async fn method_aware_dispatch_templated_path_extracts_params() {
12398        let (port, registry) = spawn_test_server().await;
12399
12400        // Register GET /users/{id} as a templated endpoint. The
12401        // dispatcher should match `/users/42` against the template and
12402        // attach `id=42` to the envelope's path_params.
12403        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12404        registry
12405            .register_rest_endpoint(
12406                "GET".into(),
12407                vec![
12408                    PathSegment::Literal("users".into()),
12409                    PathSegment::Param("id".into()),
12410                ],
12411                tx,
12412            )
12413            .await;
12414
12415        // Spawn a responder that echoes the captured id back in the body
12416        // so the test can verify the param was set.
12417        let handle = tokio::spawn(async move {
12418            match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
12419                Ok(Some(envelope)) => {
12420                    let id = envelope.path_params.get("id").cloned().unwrap_or_default();
12421                    let _ = envelope.reply_tx.send(HttpReply {
12422                        status: 200,
12423                        headers: vec![],
12424                        body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
12425                    });
12426                }
12427                // Channel closed: task ends as today.
12428                Ok(None) => {}
12429                // Stalled: responder ends.
12430                Err(_) => {}
12431            }
12432        });
12433
12434        let client = plain_http_test_client();
12435        let resp = client
12436            .get(format!("http://127.0.0.1:{port}/users/42"))
12437            .send()
12438            .await
12439            .unwrap();
12440        assert_eq!(resp.status().as_u16(), 200);
12441        let body = resp.text().await.unwrap();
12442        assert_eq!(body, "id=42");
12443
12444        let _ = handle.await;
12445    }
12446
12447    #[tokio::test]
12448    async fn method_aware_dispatch_unmatched_method_falls_through() {
12449        // If no REST endpoint matches the method, dispatch must fall
12450        // through to the legacy api_routes lookup or static mounts. With
12451        // nothing else registered, the request gets 404 from static
12452        // dispatch.
12453        let (port, _registry) = spawn_test_server().await;
12454
12455        // Register only GET /users; a DELETE /users request has no match.
12456        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12457        _registry
12458            .register_rest_endpoint(
12459                "GET".into(),
12460                vec![PathSegment::Literal("users".into())],
12461                get_tx,
12462            )
12463            .await;
12464
12465        // Drain the GET channel in the background so the consumer side
12466        // doesn't block (we don't expect any envelopes here).
12467        let drain = tokio::spawn(async move {
12468            let mut get_rx = get_rx;
12469            loop {
12470                match tokio::time::timeout(Duration::from_secs(2), get_rx.recv()).await {
12471                    Ok(Some(_)) => continue,
12472                    Ok(None) => break, // closed: drain complete
12473                    Err(_) => break,   // stalled: drainer ends
12474                }
12475            }
12476        });
12477
12478        let client = plain_http_test_client();
12479        let resp = client
12480            .delete(format!("http://127.0.0.1:{port}/users"))
12481            .send()
12482            .await
12483            .unwrap();
12484        assert_eq!(resp.status().as_u16(), 404);
12485
12486        drop(drain);
12487    }
12488
12489    #[tokio::test]
12490    async fn regression_legacy_exact_api_route_still_works() {
12491        // A `http:` route registered without an `httpMethod=` URI param
12492        // lands in the legacy api_routes registry. The dispatcher must
12493        // still find it via exact path lookup. This guards against
12494        // regressions introduced by the new REST-aware dispatch.
12495        let (port, registry) = spawn_test_server().await;
12496
12497        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12498        registry.register_api_route("/legacy/path".into(), tx).await;
12499
12500        let handle = tokio::spawn(async move {
12501            match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
12502                Ok(Some(envelope)) => {
12503                    let _ = envelope.reply_tx.send(HttpReply {
12504                        status: 200,
12505                        headers: vec![],
12506                        body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
12507                    });
12508                }
12509                // Channel closed: task ends as today.
12510                Ok(None) => {}
12511                // Stalled: responder ends.
12512                Err(_) => {}
12513            }
12514        });
12515
12516        let client = plain_http_test_client();
12517        let resp = client
12518            .get(format!("http://127.0.0.1:{port}/legacy/path"))
12519            .send()
12520            .await
12521            .unwrap();
12522        assert_eq!(resp.status().as_u16(), 200);
12523        let body = resp.text().await.unwrap();
12524        assert_eq!(body, "legacy ok");
12525
12526        let _ = handle.await;
12527    }
12528
12529    #[allow(clippy::await_holding_lock)]
12530    #[tokio::test]
12531    async fn regression_static_mount_still_works() {
12532        // Verify that static file serving still works after the
12533        // dispatch refactor. We register a temp-dir mount and request
12534        // a file from it; the static dispatcher should serve it.
12535        let _guard = lock_registry_test_mutex();
12536        ServerRegistry::reset();
12537
12538        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
12539        std::fs::create_dir_all(&temp_dir).unwrap();
12540        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
12541        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
12542
12543        let registry = make_test_registry();
12544        let serve_dir = ServeDir::new(&canonical_dir)
12545            .precompressed_gzip()
12546            .precompressed_br()
12547            .append_index_html_on_directories(true);
12548        let mount = StaticMount {
12549            mount_path: "/".to_string(),
12550            mode: MountMode::Static,
12551            dir: canonical_dir.clone(),
12552            cache_control: "public, max-age=3600".to_string(),
12553            error_pages: std::collections::HashMap::new(),
12554            serve_dir,
12555        };
12556        registry.register_static_mount(mount).await.unwrap();
12557
12558        let state = make_test_state(registry);
12559        let req = Request::builder()
12560            .uri("/regress.txt")
12561            .body(AxumBody::empty())
12562            .unwrap();
12563        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
12564        assert_eq!(resp.status(), StatusCode::OK);
12565        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12566            .await
12567            .unwrap();
12568        assert_eq!(&body[..], b"static works");
12569
12570        std::fs::remove_dir_all(&temp_dir).ok();
12571    }
12572
12573    // -----------------------------------------------------------------------
12574    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
12575    // templated from-URI round-trip. These exercise the real axum dispatch
12576    // path (register → HTTP request → reply) so a regression in any of the
12577    // three critical fixes surfaces as a test failure rather than a silent
12578    // production 404/500.
12579    // -----------------------------------------------------------------------
12580
12581    #[tokio::test]
12582    async fn deregister_one_method_keeps_sibling_verbs() {
12583        // Review C1: stopping the GET /users consumer must NOT tear down the
12584        // live POST /users endpoint. Register both, deregister GET only,
12585        // then verify POST still dispatches.
12586        let (port, registry) = spawn_test_server().await;
12587
12588        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12589        registry
12590            .register_rest_endpoint(
12591                "GET".into(),
12592                vec![PathSegment::Literal("users".into())],
12593                get_tx,
12594            )
12595            .await;
12596
12597        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12598        registry
12599            .register_rest_endpoint(
12600                "POST".into(),
12601                vec![PathSegment::Literal("users".into())],
12602                post_tx,
12603            )
12604            .await;
12605
12606        // Drain GET in the background (no requests expected after deregister).
12607        let drain = tokio::spawn(async move {
12608            let mut get_rx = get_rx;
12609            loop {
12610                match tokio::time::timeout(Duration::from_secs(2), get_rx.recv()).await {
12611                    Ok(Some(_)) => continue,
12612                    Ok(None) => break, // closed: drain complete
12613                    Err(_) => break,   // stalled: drainer ends
12614                }
12615            }
12616        });
12617
12618        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
12619        registry.unregister_rest_endpoint("GET", "/users").await;
12620        drop(drain);
12621
12622        let post_handle = spawn_responder(post_rx, 201, "create".into());
12623
12624        let client = plain_http_test_client();
12625        // POST /users must still reach its consumer after GET was removed.
12626        let resp = client
12627            .post(format!("http://127.0.0.1:{port}/users"))
12628            .send()
12629            .await
12630            .unwrap();
12631        assert_eq!(resp.status().as_u16(), 201);
12632        assert_eq!(resp.text().await.unwrap(), "create");
12633
12634        post_handle
12635            .await
12636            .expect("post responder task must not stall");
12637    }
12638
12639    #[tokio::test]
12640    async fn dispatch_exact_legacy_beats_rest_template() {
12641        // Review C2: an exact legacy API route (`GET /api/users`, no
12642        // httpMethod) must win over a templated REST route
12643        // (`GET /api/{resource}`) for the request `/api/users`, per spec
12644        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
12645        let (port, registry) = spawn_test_server().await;
12646
12647        // Exact legacy route.
12648        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12649        registry
12650            .register_api_route("/api/users".into(), exact_tx)
12651            .await;
12652        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
12653
12654        // Templated REST route that would ALSO match /api/users.
12655        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12656        registry
12657            .register_rest_endpoint(
12658                "GET".into(),
12659                vec![
12660                    PathSegment::Literal("api".into()),
12661                    PathSegment::Param("resource".into()),
12662                ],
12663                tpl_tx,
12664            )
12665            .await;
12666        // The templated handler must NOT receive the /api/users request. If
12667        // it does, it replies "template-leak" so a future assertion could
12668        // catch it. We do NOT await this task: the exact-match branch wins
12669        // and the templated channel never receives, so awaiting would block
12670        // until the test runtime tears down.
12671        let _tpl_drain = tokio::spawn(async move {
12672            let mut tpl_rx = tpl_rx;
12673            match tokio::time::timeout(Duration::from_secs(2), tpl_rx.recv()).await {
12674                Ok(Some(env)) => {
12675                    let _ = env.reply_tx.send(HttpReply {
12676                        status: 200,
12677                        headers: vec![],
12678                        body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
12679                    });
12680                }
12681                // Channel closed: task ends as today.
12682                Ok(None) => {}
12683                // Stalled: responder ends.
12684                Err(_) => {}
12685            }
12686        });
12687
12688        let client = plain_http_test_client();
12689        let resp = client
12690            .get(format!("http://127.0.0.1:{port}/api/users"))
12691            .send()
12692            .await
12693            .unwrap();
12694        assert_eq!(resp.status().as_u16(), 200);
12695        // Exact-match handler answered — not the templated one.
12696        assert_eq!(resp.text().await.unwrap(), "exact");
12697
12698        exact_handle
12699            .await
12700            .expect("exact responder task must not stall");
12701    }
12702
12703    #[tokio::test]
12704    async fn ambiguous_rest_templates_return_500_not_silent_404() {
12705        // Review C3: two equal-specificity templates that both match one
12706        // request are an ambiguous registration. At runtime this must
12707        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
12708        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
12709        let (port, registry) = spawn_test_server().await;
12710
12711        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12712        registry
12713            .register_rest_endpoint(
12714                "GET".into(),
12715                vec![
12716                    PathSegment::Literal("users".into()),
12717                    PathSegment::Param("id".into()),
12718                ],
12719                a_tx,
12720            )
12721            .await;
12722
12723        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12724        registry
12725            .register_rest_endpoint(
12726                "GET".into(),
12727                vec![
12728                    PathSegment::Literal("users".into()),
12729                    PathSegment::Param("name".into()),
12730                ],
12731                b_tx,
12732            )
12733            .await;
12734
12735        let client = plain_http_test_client();
12736        let resp = client
12737            .get(format!("http://127.0.0.1:{port}/users/42"))
12738            .send()
12739            .await
12740            .unwrap();
12741        // Ambiguous → 500 (previously a silent 404).
12742        assert_eq!(resp.status().as_u16(), 500);
12743    }
12744
12745    #[test]
12746    fn from_uri_round_trips_templated_path_with_http_method() {
12747        // Review I4: a REST-lowered from-URI like
12748        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
12749        // through HttpServerConfig::from_uri, preserving the templated path
12750        // and the (uppercased) method. This is the binding the DSL lowering
12751        // emits and the consumer reads; it was previously unasserted.
12752        use crate::UriConfig;
12753        let cfg =
12754            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
12755        assert_eq!(cfg.host, "0.0.0.0");
12756        assert_eq!(cfg.port, 8080);
12757        assert_eq!(cfg.path, "/users/{id}");
12758        assert_eq!(cfg.method.as_deref(), Some("GET"));
12759
12760        // Lower-case httpMethod is uppercased (review I5).
12761        let cfg_lc =
12762            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
12763        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
12764        assert_eq!(cfg_lc.path, "/orders");
12765    }
12766
12767    // -----------------------------------------------------------------------
12768    // rc-1dk4: TypeConversionFailed → 400 Bad Request
12769    // -----------------------------------------------------------------------
12770
12771    #[test]
12772    fn type_conversion_failed_maps_to_400() {
12773        let reply = pipeline_error_to_reply(
12774            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
12775            "/api/users",
12776        );
12777        assert_eq!(reply.status, 400);
12778        // Exactly one Content-Type header, application/json
12779        let json_ct = reply
12780            .headers
12781            .iter()
12782            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12783            .count();
12784        assert_eq!(json_ct, 1);
12785        // Body must be structured error JSON with the expected fields
12786        let body = match &reply.body {
12787            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12788            _ => panic!("expected bytes body"),
12789        };
12790        let parsed: serde_json::Value =
12791            serde_json::from_str(&body).expect("body must be valid JSON");
12792        assert_eq!(parsed["error"], "bad_request");
12793        assert_eq!(parsed["message"], "invalid JSON at line 1");
12794    }
12795
12796    #[test]
12797    fn other_error_still_maps_to_500() {
12798        let reply =
12799            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
12800        assert_eq!(reply.status, 500);
12801    }
12802
12803    #[test]
12804    fn unauthenticated_maps_to_401() {
12805        let reply = pipeline_error_to_reply(
12806            CamelError::Unauthenticated("no token".to_string()),
12807            "/api/users",
12808        );
12809        assert_eq!(reply.status, 401);
12810    }
12811
12812    #[test]
12813    fn unauthorized_maps_to_403() {
12814        let reply = pipeline_error_to_reply(
12815            CamelError::Unauthorized("forbidden".to_string()),
12816            "/api/users",
12817        );
12818        assert_eq!(reply.status, 403);
12819    }
12820
12821    #[test]
12822    fn validation_error_maps_to_400() {
12823        let reply = pipeline_error_to_reply(
12824            CamelError::ValidationError("body does not match schema".to_string()),
12825            "/api/users",
12826        );
12827        assert_eq!(reply.status, 400);
12828        let json_ct = reply
12829            .headers
12830            .iter()
12831            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12832            .count();
12833        assert_eq!(json_ct, 1);
12834        let body = match &reply.body {
12835            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12836            _ => panic!("expected bytes body"),
12837        };
12838        let parsed: serde_json::Value =
12839            serde_json::from_str(&body).expect("body must be valid JSON");
12840        assert_eq!(parsed["error"], "validation_error");
12841        assert_eq!(parsed["message"], "body does not match schema");
12842    }
12843
12844    // -----------------------------------------------------------------------
12845    // rc-hlb1q: media negotiation errors → 415 / 406
12846    // -----------------------------------------------------------------------
12847
12848    #[test]
12849    fn finalizer_maps_unsupported_media_type() {
12850        let reply = pipeline_error_to_reply(
12851            CamelError::UnsupportedMediaType {
12852                consumed: "text/plain".to_string(),
12853                declared: "application/json".to_string(),
12854            },
12855            "/x",
12856        );
12857        assert_eq!(reply.status, 415);
12858        let json_ct = reply
12859            .headers
12860            .iter()
12861            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12862            .count();
12863        assert_eq!(json_ct, 1);
12864        let body = match &reply.body {
12865            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12866            _ => panic!("expected bytes body"),
12867        };
12868        let parsed: serde_json::Value =
12869            serde_json::from_str(&body).expect("body must be valid JSON");
12870        assert_eq!(parsed["error"], "unsupported_media_type");
12871        assert_eq!(
12872            parsed["message"],
12873            "consumed text/plain, declared application/json"
12874        );
12875    }
12876
12877    #[test]
12878    fn finalizer_maps_not_acceptable() {
12879        let reply = pipeline_error_to_reply(
12880            CamelError::NotAcceptable {
12881                accept: "application/xml".to_string(),
12882                produced: "application/json".to_string(),
12883            },
12884            "/x",
12885        );
12886        assert_eq!(reply.status, 406);
12887        let json_ct = reply
12888            .headers
12889            .iter()
12890            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12891            .count();
12892        assert_eq!(json_ct, 1);
12893        let body = match &reply.body {
12894            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12895            _ => panic!("expected bytes body"),
12896        };
12897        let parsed: serde_json::Value =
12898            serde_json::from_str(&body).expect("body must be valid JSON");
12899        assert_eq!(parsed["error"], "not_acceptable");
12900        assert_eq!(
12901            parsed["message"],
12902            "accept application/xml, produced application/json"
12903        );
12904    }
12905
12906    #[test]
12907    fn json_error_reply_preserves_empty_message() {
12908        let reply = json_error_reply(400, "bad_request", "".to_string());
12909        assert_eq!(reply.status, 400);
12910        let json_ct = reply
12911            .headers
12912            .iter()
12913            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12914            .count();
12915        assert_eq!(json_ct, 1);
12916        let body = match &reply.body {
12917            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12918            _ => panic!("expected bytes body"),
12919        };
12920        let parsed: serde_json::Value =
12921            serde_json::from_str(&body).expect("body must be valid JSON");
12922        assert_eq!(parsed["error"], "bad_request");
12923        assert_eq!(parsed["message"], "");
12924    }
12925
12926    #[test]
12927    fn https_consumer_without_tls_cert_errors() {
12928        let endpoint = HttpEndpoint {
12929            uri: "https://0.0.0.0:8443/api".to_string(),
12930            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12931            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12932            client: plain_http_test_client(),
12933            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12934                PINNED_CLIENT_TTL,
12935                PINNED_CLIENT_MAX_ENTRIES,
12936            )),
12937            http_config: HttpConfig::default(),
12938        };
12939        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12940        let result = endpoint.create_consumer(rt);
12941        assert!(result.is_err(), "expected error for https without tls cert");
12942        if let Err(e) = result {
12943            let msg = e.to_string();
12944            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
12945        }
12946    }
12947
12948    #[test]
12949    fn http_consumer_with_tls_config_errors() {
12950        let endpoint = HttpEndpoint {
12951            uri: "http://0.0.0.0:8080/api".to_string(),
12952            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
12953            server_config: HttpServerConfig::from_uri(
12954                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
12955            )
12956            .unwrap(),
12957            client: plain_http_test_client(),
12958            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12959                PINNED_CLIENT_TTL,
12960                PINNED_CLIENT_MAX_ENTRIES,
12961            )),
12962            http_config: HttpConfig::default(),
12963        };
12964        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12965        let result = endpoint.create_consumer(rt);
12966        assert!(result.is_err(), "expected error for http with tls config");
12967        if let Err(e) = result {
12968            let msg = e.to_string();
12969            assert!(msg.contains("https"), "error must mention https: {msg}");
12970        }
12971    }
12972
12973    #[test]
12974    fn https_consumer_with_partial_tls_cert_only_errors() {
12975        // tlsCert without tlsKey → tls_config is None at parse time
12976        // → create_consumer sees https:// + no TLS → must error
12977        let server_config =
12978            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12979        assert!(
12980            server_config.tls_config.is_none(),
12981            "partial tlsCert must not create ServerTlsConfig"
12982        );
12983        let endpoint = HttpEndpoint {
12984            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
12985            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
12986                .unwrap(),
12987            server_config,
12988            client: plain_http_test_client(),
12989            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12990                PINNED_CLIENT_TTL,
12991                PINNED_CLIENT_MAX_ENTRIES,
12992            )),
12993            http_config: HttpConfig::default(),
12994        };
12995        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12996        let result = endpoint.create_consumer(rt);
12997        assert!(
12998            result.is_err(),
12999            "must error: https:// requires both tlsCert and tlsKey"
13000        );
13001    }
13002
13003    #[test]
13004    fn load_tls_config_parses_valid_pem() {
13005        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
13006        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
13007        use camel_component_api::test_support::tls;
13008        let (_, cert_pem, key_pem) = tls::gen_server_cert();
13009        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
13010        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
13011
13012        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
13013        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
13014    }
13015
13016    #[tokio::test(flavor = "multi_thread")]
13017    #[allow(clippy::await_holding_lock)]
13018    async fn consumer_tls_handshake_roundtrip() {
13019        use camel_component_api::test_support::tls;
13020        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
13021
13022        // Install rustls crypto provider (aws-lc-rs)
13023        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
13024
13025        // Serialize against global ServerRegistry singleton
13026        let _guard = lock_registry_test_mutex();
13027
13028        // Generate CA + server cert
13029        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
13030        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
13031        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
13032        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
13033
13034        // Get ephemeral port
13035        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13036        let port = probe.local_addr().unwrap().port();
13037        drop(probe);
13038
13039        ServerRegistry::reset();
13040
13041        // Create real HttpComponent + endpoint with TLS URI
13042        let component = HttpComponent::new();
13043        let endpoint_ctx = NoOpComponentContext;
13044        let uri = format!(
13045            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
13046            cert_path.to_string_lossy(),
13047            key_path.to_string_lossy(),
13048        );
13049        let endpoint = component
13050            .create_endpoint(&uri, &endpoint_ctx)
13051            .expect("create TLS endpoint");
13052        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
13053
13054        // Start consumer — this calls get_or_spawn with tls_config
13055        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13056        let token = tokio_util::sync::CancellationToken::new();
13057        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
13058        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13059
13060        // Give server time to start
13061        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
13062
13063        // Client with CA cert — REAL verification (no danger_accept_invalid)
13064        let ca_bytes = std::fs::read(&ca_path).unwrap();
13065        let client = reqwest::Client::builder()
13066            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
13067            .build()
13068            .unwrap();
13069
13070        let send_fut = client
13071            .post(format!("https://localhost:{port}/test"))
13072            .body("ping")
13073            .send();
13074
13075        // Handler: receive envelope, reply 200 with "pong" body
13076        let (http_result, _) = tokio::join!(send_fut, async {
13077            if let Some(mut envelope) = rx.recv().await {
13078                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
13079                if let Some(reply_tx) = envelope.reply_tx {
13080                    let _ = reply_tx.send(Ok(envelope.exchange));
13081                }
13082            }
13083        });
13084
13085        let resp = http_result.expect("TLS handshake + request must succeed");
13086
13087        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
13088        let body = resp.text().await.unwrap();
13089        assert_eq!(body, "pong");
13090
13091        token.cancel();
13092    }
13093
13094    #[tokio::test(flavor = "multi_thread")]
13095    #[allow(clippy::await_holding_lock)]
13096    async fn consumer_tls_rejects_client_without_ca() {
13097        use camel_component_api::test_support::tls;
13098        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
13099
13100        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
13101
13102        // Serialize against global ServerRegistry singleton
13103        let _guard = lock_registry_test_mutex();
13104
13105        let (_, cert_pem, key_pem) = tls::gen_server_cert();
13106        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
13107        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
13108
13109        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13110        let port = probe.local_addr().unwrap().port();
13111        drop(probe);
13112
13113        ServerRegistry::reset();
13114
13115        // Spawn TLS server via real HttpComponent path
13116        let component = HttpComponent::new();
13117        let endpoint_ctx = NoOpComponentContext;
13118        let uri = format!(
13119            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
13120            cert_path.to_string_lossy(),
13121            key_path.to_string_lossy(),
13122        );
13123        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
13124        let mut consumer = endpoint.create_consumer(rt()).unwrap();
13125        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13126        let token = tokio_util::sync::CancellationToken::new();
13127        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
13128        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13129
13130        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
13131
13132        // Client WITHOUT CA cert — must fail TLS verification
13133        let client = plain_http_test_client();
13134
13135        let result = client
13136            .get(format!("https://localhost:{port}/test"))
13137            .send()
13138            .await;
13139
13140        assert!(
13141            result.is_err(),
13142            "must reject without CA — proves real verification"
13143        );
13144
13145        token.cancel();
13146    }
13147
13148    #[test]
13149    fn server_config_partial_tls_cert_without_key() {
13150        // Parse URI with only tlsCert (no tlsKey)
13151        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
13152        // Partial params → tls_config must be None
13153        assert!(cfg.tls_config.is_none());
13154    }
13155
13156    #[test]
13157    fn endpoint_uri_options_count_parity() {
13158        // Mirror struct must stay in sync with bespoke from_components parser.
13159        assert_eq!(
13160            HttpEndpointConfig::uri_options().len(),
13161            23,
13162            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
13163        );
13164    }
13165
13166    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
13167        pairs
13168            .iter()
13169            .map(|(k, v)| {
13170                (
13171                    (*k).to_string(),
13172                    serde_json::Value::String((*v).to_string()),
13173                )
13174            })
13175            .collect()
13176    }
13177
13178    #[test]
13179    fn response_emits_cache_control_via_pragma_warning() {
13180        let headers = make_headers(&[
13181            ("Cache-Control", "public, max-age=3600"),
13182            ("Via", "1.1 myproxy"),
13183            ("Pragma", "no-cache"),
13184            ("Warning", "199 misc"),
13185        ]);
13186        let selected = select_response_headers(&headers, None, None);
13187        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13188        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
13189            assert!(
13190                names.contains(&expected),
13191                "{expected} should pass through to the response"
13192            );
13193        }
13194    }
13195
13196    #[test]
13197    fn response_excludes_request_only_and_server_owned() {
13198        let headers = make_headers(&[
13199            ("User-Agent", "x"),
13200            ("Accept", "*/*"),
13201            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
13202        ]);
13203        let selected = select_response_headers(&headers, None, None);
13204        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13205        for excluded in ["User-Agent", "Accept", "Date"] {
13206            assert!(
13207                !names.contains(&excluded),
13208                "{excluded} should NOT appear in the response"
13209            );
13210        }
13211    }
13212
13213    #[test]
13214    fn response_re_derives_content_type() {
13215        let headers = make_headers(&[("Content-Type", "text/plain")]);
13216        let selected = select_response_headers(&headers, Some("application/json".into()), None);
13217        let ct_entries: Vec<&str> = selected
13218            .iter()
13219            .filter(|(k, _)| k == "Content-Type")
13220            .map(|(_, v)| v.as_str())
13221            .collect();
13222        assert_eq!(
13223            ct_entries,
13224            ["application/json"],
13225            "exactly one Content-Type entry, re-derived from user_content_type"
13226        );
13227    }
13228
13229    #[test]
13230    fn response_excludes_camel_headers() {
13231        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
13232        let selected = select_response_headers(&headers, None, None);
13233        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13234        assert!(
13235            !names.contains(&"CamelHttpPath"),
13236            "Camel-namespace headers must be excluded"
13237        );
13238        assert!(
13239            names.contains(&"Cache-Control"),
13240            "Cache-Control must pass through"
13241        );
13242    }
13243
13244    #[test]
13245    fn response_stringifies_scalar_header_values() {
13246        let mut headers = make_headers(&[("X-Label", "keep")]);
13247        headers.insert("X-Retries".to_string(), serde_json::json!(3));
13248        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
13249        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
13250        let selected = select_response_headers(&headers, None, None);
13251        let get = |name: &str| -> Option<&str> {
13252            selected
13253                .iter()
13254                .find(|(k, _)| k == name)
13255                .map(|(_, v)| v.as_str())
13256        };
13257        assert_eq!(
13258            get("X-Retries"),
13259            Some("3"),
13260            "integer header must be stringified"
13261        );
13262        assert_eq!(
13263            get("X-Ratio"),
13264            Some("3.5"),
13265            "float header must be stringified"
13266        );
13267        assert_eq!(
13268            get("X-Enabled"),
13269            Some("true"),
13270            "bool header must be stringified"
13271        );
13272        assert_eq!(
13273            get("X-Label"),
13274            Some("keep"),
13275            "string header must pass through"
13276        );
13277    }
13278
13279    #[test]
13280    fn response_drops_null_and_structured_header_values() {
13281        let mut headers = make_headers(&[("X-Keep", "yes")]);
13282        headers.insert("X-Null".to_string(), serde_json::Value::Null);
13283        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
13284        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
13285        let selected = select_response_headers(&headers, None, None);
13286        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13287        for dropped in ["X-Null", "X-Obj", "X-Arr"] {
13288            assert!(
13289                !names.contains(&dropped),
13290                "{dropped} must not be emitted: no single-value form"
13291            );
13292        }
13293        assert!(names.contains(&"X-Keep"), "scalar headers must survive");
13294    }
13295
13296    #[test]
13297    fn response_stringifies_scalars_despite_excluded_names() {
13298        // Excluded names stay excluded regardless of value type: the policy
13299        // filter runs before stringification, so numeric values cannot smuggle
13300        // content-length or server-owned headers into the reply.
13301        let mut headers = HashMap::new();
13302        headers.insert("Content-Length".to_string(), serde_json::json!(999));
13303        headers.insert("Date".to_string(), serde_json::json!(12345));
13304        let selected = select_response_headers(&headers, None, None);
13305        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13306        assert!(
13307            !names.contains(&"Content-Length"),
13308            "content-length is re-derived by the server"
13309        );
13310        assert!(!names.contains(&"Date"), "date is server-owned");
13311    }
13312
13313    #[test]
13314    fn outbound_stringifies_scalar_header_values() {
13315        let mut headers = make_headers(&[("X-Label", "keep")]);
13316        headers.insert("X-Retries".to_string(), serde_json::json!(3));
13317        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
13318        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
13319        let outbound = select_outbound_headers(&headers, &[], &[]);
13320        // HeaderName construction lowercases; lookups compare case-blind.
13321        let get = |name: &str| -> Option<String> {
13322            outbound
13323                .accepted
13324                .iter()
13325                .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13326                .map(|(_, v)| v.to_str().unwrap().to_string())
13327        };
13328        assert_eq!(
13329            get("X-Retries").as_deref(),
13330            Some("3"),
13331            "integer header must be stringified"
13332        );
13333        assert_eq!(
13334            get("X-Ratio").as_deref(),
13335            Some("3.5"),
13336            "float header must be stringified"
13337        );
13338        assert_eq!(
13339            get("X-Enabled").as_deref(),
13340            Some("true"),
13341            "bool header must be stringified"
13342        );
13343        assert_eq!(
13344            get("X-Label").as_deref(),
13345            Some("keep"),
13346            "string header must pass through"
13347        );
13348        assert!(outbound.drops.is_empty(), "scalar headers must not drop");
13349    }
13350
13351    #[test]
13352    fn outbound_drops_null_and_structured_header_values() {
13353        let mut headers = make_headers(&[("X-Keep", "yes")]);
13354        headers.insert("X-Null".to_string(), serde_json::Value::Null);
13355        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
13356        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
13357        let outbound = select_outbound_headers(&headers, &[], &[]);
13358        let has = |name: &str| {
13359            outbound
13360                .accepted
13361                .iter()
13362                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13363        };
13364        assert!(has("X-Keep"), "scalar headers must survive");
13365        for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
13366            let dropped = outbound
13367                .drops
13368                .iter()
13369                .find(|d| d.name == name)
13370                .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
13371            assert_eq!(
13372                dropped.reason, "no scalar string form",
13373                "{name} drop reason must name the value kind absence"
13374            );
13375            assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
13376        }
13377    }
13378
13379    #[test]
13380    fn outbound_stringifies_scalars_despite_excluded_names() {
13381        // Excluded names stay excluded regardless of value type: the policy
13382        // filter runs before stringification, so numeric values cannot smuggle
13383        // hop-by-hop or client-derived headers onto the wire.
13384        let mut headers = HashMap::new();
13385        headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
13386        headers.insert("Host".to_string(), serde_json::json!(12345));
13387        headers.insert("X-Ok".to_string(), serde_json::json!(7));
13388        let outbound = select_outbound_headers(&headers, &[], &[]);
13389        let has = |name: &str| {
13390            outbound
13391                .accepted
13392                .iter()
13393                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13394        };
13395        assert!(
13396            !has("Transfer-Encoding"),
13397            "hop-by-hop header must stay excluded"
13398        );
13399        assert!(!has("Host"), "host is destination-derived");
13400        assert!(has("X-Ok"), "non-excluded scalar must be stringified");
13401        assert!(
13402            outbound
13403                .drops
13404                .iter()
13405                .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
13406            "policy drop must be recorded before coercion"
13407        );
13408    }
13409
13410    #[test]
13411    fn outbound_drops_invalid_names_values_and_skip_config() {
13412        let mut headers = make_headers(&[("X-Good", "fine")]);
13413        headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
13414        headers.insert(
13415            "X-Control-Value".to_string(),
13416            serde_json::json!("line1\nline2"),
13417        );
13418        headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
13419        headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
13420        let skip = vec!["x-secret".to_string()];
13421        let outbound = select_outbound_headers(&headers, &skip, &[]);
13422        let has = |name: &str| {
13423            outbound
13424                .accepted
13425                .iter()
13426                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13427        };
13428        assert!(has("X-Good"), "valid header must survive");
13429        assert!(!has("X Bad Name"), "invalid header name must drop");
13430        assert!(!has("X-Control-Value"), "control-char value must drop");
13431        assert!(!has("X-Secret"), "skipped header must drop");
13432        assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
13433        let reason = |n: &str| {
13434            outbound
13435                .drops
13436                .iter()
13437                .find(|d| d.name == n)
13438                .map(|d| d.reason)
13439        };
13440        assert_eq!(reason("X Bad Name"), Some("invalid header name"));
13441        assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
13442        assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
13443        assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
13444    }
13445
13446    #[test]
13447    fn constructed_header_invalid_value_returns_drop_record() {
13448        let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
13449        let Err(record) = result else {
13450            panic!("invalid value must produce a drop record");
13451        };
13452        assert_eq!(record.reason, "invalid header value");
13453        assert_eq!(record.name, "user-agent");
13454        assert!(record.value_kind.is_none());
13455        let debug = format!("{record:?}");
13456        assert!(
13457            !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
13458            "drop record debug must not leak the value"
13459        );
13460    }
13461
13462    #[test]
13463    fn constructed_header_invalid_name_returns_drop_record() {
13464        let result = constructed_header("bad name", "ok");
13465        let Err(record) = result else {
13466            panic!("invalid name must produce a drop record");
13467        };
13468        assert_eq!(record.reason, "invalid header name");
13469        assert_eq!(record.name, "bad name");
13470        let debug = format!("{record:?}");
13471        assert!(
13472            !debug.contains("ok"),
13473            "drop record debug must not leak the value"
13474        );
13475    }
13476
13477    #[test]
13478    fn constructed_header_valid_pair_roundtrip() {
13479        let result = constructed_header("authorization", "Bearer abc123");
13480        let Ok((name, val)) = result else {
13481            panic!("valid pair must construct");
13482        };
13483        assert_eq!(name.as_str(), "authorization");
13484        let Ok(roundtrip) = val.to_str() else {
13485            panic!("valid value must roundtrip to str");
13486        };
13487        assert_eq!(roundtrip, "Bearer abc123");
13488    }
13489
13490    // -----------------------------------------------------------------------
13491    // Bridge proxy end-to-end integration tests (Task 4.1)
13492    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
13493    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
13494    // -----------------------------------------------------------------------
13495
13496    /// Destination server that captures the outbound request line and the
13497    /// `Host:` header the producer actually sent on the wire. Returns
13498    /// `(host_value, request_line)` so a bridge-proxy test can assert that
13499    /// the producer derived `Host` from the destination (not the exchange)
13500    /// and honoured bridging semantics for the path.
13501    async fn start_host_capturing_destination() -> (
13502        String,
13503        Arc<std::sync::Mutex<Option<(String, String)>>>,
13504        tokio::task::JoinHandle<()>,
13505    ) {
13506        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13507        let port = listener.local_addr().unwrap().port();
13508        let url = format!("http://127.0.0.1:{port}");
13509        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
13510            Arc::new(std::sync::Mutex::new(None));
13511        let captured_clone = Arc::clone(&captured);
13512        let handle = tokio::spawn(async move {
13513            use tokio::io::{AsyncReadExt, AsyncWriteExt};
13514            if let Ok((mut stream, _)) = listener.accept().await {
13515                let mut buf = vec![0u8; 16384];
13516                let n = stream.read(&mut buf).await.unwrap_or(0);
13517                let request = String::from_utf8_lossy(&buf[..n]).to_string();
13518                if request.contains("\r\n\r\n") {
13519                    let request_line = request.lines().next().unwrap_or("").to_string();
13520                    let host_value = request
13521                        .lines()
13522                        .find(|l| l.to_lowercase().starts_with("host:"))
13523                        .and_then(|l| l.split_once(':'))
13524                        .map(|(_, v)| v.trim().to_string())
13525                        .unwrap_or_default();
13526                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
13527                }
13528                let body = r#"{"echo":"ok"}"#;
13529                let resp = format!(
13530                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
13531                    body.len(),
13532                    body
13533                );
13534                let _ = stream.write_all(resp.as_bytes()).await;
13535            }
13536        });
13537        (url, captured, handle)
13538    }
13539
13540    /// A bridging producer must derive `Host` from the destination URL and
13541    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
13542    /// semantics. The wire-level proof is the raw `Host:` header and request
13543    /// line captured at the destination TCP socket.
13544    #[tokio::test]
13545    async fn bridge_proxy_outbound_host_matches_destination() {
13546        use tower::ServiceExt;
13547
13548        let (url, captured, _handle) = start_host_capturing_destination().await;
13549        // The Host header reqwest derives for http://127.0.0.1:{port} is the
13550        // authority, scheme-stripped: "127.0.0.1:{port}".
13551        let expected_host = url.strip_prefix("http://").unwrap();
13552
13553        let ctx = test_producer_ctx();
13554        let component = HttpComponent::new();
13555        let endpoint_ctx = NoOpComponentContext;
13556        let endpoint = component
13557            .create_endpoint(
13558                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
13559                &endpoint_ctx,
13560            )
13561            .unwrap();
13562        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
13563
13564        // Exchange carries a stale Host and a CamelHttpPath that bridging
13565        // must drop.
13566        let mut exchange = Exchange::new(Message::default());
13567        exchange.input.set_header("Host", "localhost");
13568        exchange.input.set_header("CamelHttpPath", "/foo");
13569
13570        let result = producer.oneshot(exchange).await;
13571        assert!(result.is_ok(), "producer call failed: {:?}", result);
13572
13573        tokio::time::sleep(Duration::from_millis(100)).await;
13574        let (host_value, request_line) = captured
13575            .lock()
13576            .unwrap()
13577            .take()
13578            .expect("destination capture mutex empty — producer did not reach the destination");
13579
13580        assert_ne!(
13581            host_value, "localhost",
13582            "bridge producer must not forward the exchange Host: localhost"
13583        );
13584        assert_eq!(
13585            host_value, expected_host,
13586            "Host must be derived from the destination authority (no scheme)"
13587        );
13588        assert!(
13589            !request_line.contains("/foo"),
13590            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
13591        );
13592    }
13593
13594    /// A response header set by the route (`Cache-Control`) must survive to
13595    /// the wire. The assertion is on the reqwest HTTP response — not an
13596    /// in-process HttpReply struct — so it proves the consumer's reply
13597    /// finaliser emitted the header over the socket.
13598    #[tokio::test]
13599    async fn bridge_proxy_route_set_response_header_survives() {
13600        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
13601
13602        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13603        let port = listener.local_addr().unwrap().port();
13604        drop(listener);
13605
13606        let component = HttpComponent::new();
13607        let endpoint_ctx = NoOpComponentContext;
13608        let endpoint = component
13609            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
13610            .unwrap();
13611        let mut consumer = endpoint.create_consumer(rt()).unwrap();
13612
13613        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13614        let token = tokio_util::sync::CancellationToken::new();
13615        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
13616
13617        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13618        tokio::time::sleep(Duration::from_millis(50)).await;
13619
13620        let client = plain_http_test_client();
13621        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
13622
13623        // Route sets Cache-Control on the outbound reply (exchange.input is
13624        // the message the reply finaliser reads — see select_response_headers
13625        // at the dispatch site).
13626        let (http_result, _) = tokio::join!(send_fut, async {
13627            if let Some(mut envelope) = rx.recv().await {
13628                envelope
13629                    .exchange
13630                    .input
13631                    .set_header("Cache-Control", "public, max-age=3600");
13632                if let Some(reply_tx) = envelope.reply_tx {
13633                    let _ = reply_tx.send(Ok(envelope.exchange));
13634                }
13635            }
13636        });
13637
13638        let resp = http_result.unwrap();
13639        assert_eq!(resp.status().as_u16(), 200);
13640
13641        let cache_control = resp.headers().get("cache-control");
13642        assert!(
13643            cache_control.is_some(),
13644            "Cache-Control header must survive to the wire response"
13645        );
13646        assert_eq!(
13647            cache_control.unwrap().to_str().unwrap(),
13648            "public, max-age=3600"
13649        );
13650
13651        token.cancel();
13652    }
13653
13654    // -----------------------------------------------------------------------
13655    // credential-sources task 2.3: credential values stay out of diagnostics
13656    // -----------------------------------------------------------------------
13657    //
13658    // camel-http has no request access log (design.md "Redaction sinks",
13659    // ADR-0051). The only diagnostic sink on the failed-auth path is
13660    // `pipeline_error_to_reply`, which renders the (generic) error message and
13661    // the *configured* route path — never the request URI, query string, or
13662    // extracted credential. These tests pin that redact-by-construction
13663    // contract: a sentinel credential presented in a declared source must not
13664    // appear in the reply body nor in any tracing record emitted while the
13665    // request is handled.
13666    //
13667    // Capture scope: `#[traced_test]` installs a per-crate env filter
13668    // (`camel_component_http=trace`), so records from OTHER targets
13669    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
13670    // redaction contract for those crates is guarded by their own tests.
13671    // Revisit this capture scope if camel-auth ever logs on the auth path.
13672    use camel_api::security_policy::CredentialSource;
13673    use camel_auth::credential_source::extract_token_from_exchange;
13674    use camel_auth::native_auth::NativeCredentialStore;
13675    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
13676
13677    // Sentinel credential values — test fixtures only, not real secrets.
13678    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
13679    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
13680    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
13681
13682    /// Build the exchange the consumer would build for a request envelope:
13683    /// standard Camel HTTP headers plus title-cased forwarded request headers.
13684    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
13685        let mut msg = Message::default();
13686        msg.set_header(
13687            "CamelHttpMethod",
13688            serde_json::Value::String(envelope.method.clone()),
13689        );
13690        msg.set_header(
13691            "CamelHttpPath",
13692            serde_json::Value::String(envelope.path.clone()),
13693        );
13694        msg.set_header(
13695            "CamelHttpQuery",
13696            serde_json::Value::String(envelope.query.clone()),
13697        );
13698        for (k, v) in &envelope.headers {
13699            if let Ok(val_str) = v.to_str() {
13700                msg.set_header(
13701                    title_case_header(k.as_str()),
13702                    serde_json::Value::String(val_str.to_string()),
13703                );
13704            }
13705        }
13706        Exchange::new(msg)
13707    }
13708
13709    /// Register a route whose responder authenticates each request against an
13710    /// empty native store, so every presented credential fails lookup with
13711    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
13712    /// authentication step (extract per `sources` → authenticate → deny) so the
13713    /// credential-extraction redaction contract is exercised on a real
13714    /// authentication failure.
13715    async fn spawn_failing_auth_route(
13716        registry: &HttpRouteRegistry,
13717        path: &str,
13718        sources: Vec<CredentialSource>,
13719    ) {
13720        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
13721            NativeCredentialStore::try_new(vec![]).unwrap(),
13722        ));
13723        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
13724        registry.register_api_route(path.to_string(), tx).await;
13725        let path_owned = path.to_string();
13726        tokio::spawn(async move {
13727            loop {
13728                // Per-iteration deadline (lintwiden D4.2): each recv
13729                // await is bounded; a stall panics the task instead of
13730                // parking it. Channel close still ends the loop.
13731                match tokio::time::timeout(Duration::from_secs(10), rx.recv()).await {
13732                    Ok(Some(envelope)) => {
13733                        let exchange = envelope_to_exchange(&envelope);
13734                        let reply_tx = envelope.reply_tx;
13735                        // Per-request deadline (lintwiden D4.2): a wedged
13736                        // authenticator must fail the request instead of
13737                        // pinning the route task.
13738                        let reply = match extract_token_from_exchange(&exchange, &sources)
13739                            .map(|extracted| extracted.token)
13740                        {
13741                            Some(token) => {
13742                                match tokio::time::timeout(
13743                                    Duration::from_secs(10),
13744                                    authenticator.authenticate_bearer(&token),
13745                                )
13746                                .await
13747                                {
13748                                    Ok(Ok(_)) => HttpReply {
13749                                        status: 200,
13750                                        headers: vec![],
13751                                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
13752                                    },
13753                                    Ok(Err(e)) => pipeline_error_to_reply(e, &path_owned),
13754                                    Err(_) => pipeline_error_to_reply(
13755                                        CamelError::ProcessorError(
13756                                            "authenticator stalled beyond 10s".into(),
13757                                        ),
13758                                        &path_owned,
13759                                    ),
13760                                }
13761                            }
13762                            None => pipeline_error_to_reply(
13763                                CamelError::Unauthenticated("no credential in any source".into()),
13764                                &path_owned,
13765                            ),
13766                        };
13767                        let _ = reply_tx.send(reply);
13768                    }
13769                    Ok(None) => break,
13770                    Err(_) => {
13771                        panic!("spawn_failing_auth_route: no request arrived within 10s")
13772                    }
13773                }
13774            }
13775        });
13776    }
13777
13778    /// Whether any tracing record captured so far (process-wide) contains
13779    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
13780    /// shared buffer, so logs from spawned request-handling tasks are included.
13781    fn captured_logs_contain(needle: &str) -> bool {
13782        let buf = tracing_test::internal::global_buf().lock().unwrap();
13783        String::from_utf8_lossy(&buf).contains(needle)
13784    }
13785
13786    #[tracing_test::traced_test]
13787    #[tokio::test]
13788    async fn error_context_redacts_query_sentinel() {
13789        let (port, registry) = spawn_test_server().await;
13790        spawn_failing_auth_route(
13791            &registry,
13792            "/secure-query",
13793            vec![CredentialSource::QueryParam {
13794                param: "token".to_string(),
13795            }],
13796        )
13797        .await;
13798
13799        let client = plain_http_test_client();
13800        let resp = client
13801            // allow-secret: `token` is the declared query-source param name, not a credential
13802            .get(format!(
13803                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
13804            ))
13805            .send()
13806            .await
13807            .unwrap();
13808
13809        assert_eq!(resp.status().as_u16(), 401);
13810        let body = resp.text().await.unwrap();
13811        assert_eq!(body, "Unauthorized");
13812        assert!(
13813            !body.contains(SENTINEL_QRY_42),
13814            "reply body must not contain the query credential"
13815        );
13816        assert!(
13817            !captured_logs_contain(SENTINEL_QRY_42),
13818            "no tracing record during request handling may render the query credential"
13819        );
13820        // Permanent positive control: the failed-auth warn! must be captured.
13821        // If the per-crate env filter ever stops matching, this fails loudly
13822        // instead of letting the sentinel assertions pass vacuously.
13823        assert!(
13824            captured_logs_contain("Authentication failed"),
13825            "positive control: the failed-auth warn! must be captured by the test subscriber"
13826        );
13827    }
13828
13829    #[tracing_test::traced_test]
13830    #[tokio::test]
13831    async fn error_context_redacts_cookie_sentinel() {
13832        let (port, registry) = spawn_test_server().await;
13833        spawn_failing_auth_route(
13834            &registry,
13835            "/secure-cookie",
13836            vec![CredentialSource::Cookie {
13837                name: "session".to_string(),
13838            }],
13839        )
13840        .await;
13841
13842        let client = plain_http_test_client();
13843        let resp = client
13844            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
13845            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
13846            .send()
13847            .await
13848            .unwrap();
13849
13850        assert_eq!(resp.status().as_u16(), 401);
13851        let body = resp.text().await.unwrap();
13852        assert_eq!(body, "Unauthorized");
13853        assert!(
13854            !body.contains(SENTINEL_CKY_7),
13855            "reply body must not contain the cookie credential"
13856        );
13857        assert!(
13858            !captured_logs_contain(SENTINEL_CKY_7),
13859            "no tracing record during request handling may render the cookie credential"
13860        );
13861    }
13862
13863    #[tracing_test::traced_test]
13864    #[tokio::test]
13865    async fn error_reply_no_credential_value() {
13866        let (port, registry) = spawn_test_server().await;
13867        spawn_failing_auth_route(
13868            &registry,
13869            "/secure-bad",
13870            vec![CredentialSource::Cookie {
13871                name: "session".to_string(),
13872            }],
13873        )
13874        .await;
13875
13876        let client = plain_http_test_client();
13877        let resp = client
13878            .get(format!("http://127.0.0.1:{port}/secure-bad"))
13879            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
13880            .send()
13881            .await
13882            .unwrap();
13883
13884        assert_eq!(resp.status().as_u16(), 401);
13885        let body = resp.text().await.unwrap();
13886        assert_eq!(body, "Unauthorized");
13887        assert!(
13888            !body.contains(SENTINEL_BAD_1),
13889            "reply body must not contain the credential value"
13890        );
13891        assert!(
13892            !captured_logs_contain(SENTINEL_BAD_1),
13893            "error logs must not render the credential value"
13894        );
13895    }
13896
13897    // -----------------------------------------------------------------------
13898    // Pinned-client-cache producer-path behavioral tests
13899    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
13900    // the endpoint cache, hostname requests build one client while the entry
13901    // stays retrievable, IP-literal requests bypass the cache)
13902    // -----------------------------------------------------------------------
13903
13904    /// Local responder that accepts any number of HTTP/1.1 connections on an
13905    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
13906    /// Unlike [`start_host_capturing_destination`], which serves exactly one
13907    /// connection, this loop keeps accepting so cache-reuse tests can drive
13908    /// several requests through one destination. Returns
13909    /// `(base_url, JoinHandle)`.
13910    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13911        use tokio::io::AsyncWriteExt;
13912
13913        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13914            .await
13915            .expect("bind ephemeral 127.0.0.1 listener");
13916        let port = listener.local_addr().expect("local addr").port();
13917        let base_url = format!("http://localhost:{port}");
13918        let handle = tokio::spawn(async move {
13919            while let Ok((mut conn, _)) = listener.accept().await {
13920                let _ = conn
13921                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13922                    .await;
13923                let _ = conn.shutdown().await;
13924            }
13925        });
13926        (base_url, handle)
13927    }
13928
13929    /// rc-0li3: local HTTPS responder — the TLS twin of
13930    /// [`spawn_multi_accept_200`]. Accepts any number of TLS connections on
13931    /// an ephemeral 127.0.0.1 port and answers each with a fixed 200. The
13932    /// certificate comes from `camel_component_api::test_support`
13933    /// (SANs: localhost, 127.0.0.1, ::1); clients run with
13934    /// `tls.insecure = true`.
13935    async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13936        use tokio::io::AsyncWriteExt;
13937
13938        let (_ca_pem, cert_pem, key_pem) =
13939            camel_component_api::test_support::tls::gen_server_cert();
13940        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
13941            .collect::<Result<_, _>>()
13942            .expect("parse server cert pem");
13943        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
13944            .expect("parse server key pem")
13945            .expect("server key present");
13946        // Explicit provider: the process default is ambiguous when multiple
13947        // crates pull rustls feature sets; the graph enables aws-lc-rs.
13948        let provider =
13949            std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
13950        let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
13951            .with_safe_default_protocol_versions()
13952            .expect("safe default protocol versions")
13953            .with_no_client_auth()
13954            .with_single_cert(certs, key)
13955            .expect("build rustls server config");
13956        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
13957
13958        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13959            .await
13960            .expect("bind ephemeral 127.0.0.1 listener");
13961        let port = listener.local_addr().expect("local addr").port();
13962        let base_url = format!("https://localhost:{port}");
13963        let handle = tokio::spawn(async move {
13964            while let Ok((conn, _)) = listener.accept().await {
13965                let acceptor = acceptor.clone();
13966                tokio::spawn(async move {
13967                    if let Ok(mut tls) = acceptor.accept(conn).await {
13968                        let _ = tls
13969                            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13970                            .await;
13971                        let _ = tls.shutdown().await;
13972                    }
13973                });
13974            }
13975        });
13976        (base_url, handle)
13977    }
13978
13979    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
13980    /// target a different authority (the 127.0.0.1 literal) on the same
13981    /// listener.
13982    fn responder_port(base_url: &str) -> u16 {
13983        url::Url::parse(base_url)
13984            .expect("responder base URL parses")
13985            .port()
13986            .expect("responder base URL carries an explicit port")
13987    }
13988
13989    /// Build an endpoint literal whose outbound config points at
13990    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
13991    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
13992    /// build counts stay observable across producers.
13993    fn endpoint_with_shared_cache(
13994        base_url: &str,
13995        pinned_cache: &Arc<PinnedClientCache>,
13996    ) -> HttpEndpoint {
13997        let uri = format!("{base_url}?allowInternal=true");
13998        HttpEndpoint {
13999            uri: uri.clone(),
14000            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
14001            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
14002            client: plain_http_test_client(),
14003            pinned_cache: Arc::clone(pinned_cache),
14004            http_config: HttpConfig::default(),
14005        }
14006    }
14007
14008    #[tokio::test]
14009    async fn producers_share_endpoint_cache() {
14010        use tower::ServiceExt;
14011
14012        let (base_url, _handle) = spawn_multi_accept_200().await;
14013        let pinned_cache = Arc::new(PinnedClientCache::new(
14014            PINNED_CLIENT_TTL,
14015            PINNED_CLIENT_MAX_ENTRIES,
14016        ));
14017
14018        let ctx = test_producer_ctx();
14019        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
14020        let producer_a = endpoint.create_producer(rt(), &ctx);
14021        let producer_b = endpoint.create_producer(rt(), &ctx);
14022
14023        // Each producer sends one exchange whose resolved URL is the
14024        // endpoint's localhost base URL (a domain name → pinned-client path).
14025        for producer in [producer_a, producer_b] {
14026            let producer = producer.expect("create producer");
14027            let exchange = Exchange::new(Message::default());
14028            let reply = producer.oneshot(exchange).await;
14029            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
14030        }
14031
14032        assert_eq!(
14033            pinned_cache.build_count(),
14034            1,
14035            "both producers must hit the same shared cache entry; a second \
14036             build means sharing is broken"
14037        );
14038    }
14039
14040    #[tokio::test]
14041    async fn producer_repeated_hostname_requests_build_one_client() {
14042        use tower::ServiceExt;
14043
14044        let (base_url, _handle) = spawn_multi_accept_200().await;
14045        let pinned_cache = Arc::new(PinnedClientCache::new(
14046            PINNED_CLIENT_TTL,
14047            PINNED_CLIENT_MAX_ENTRIES,
14048        ));
14049        let ctx = test_producer_ctx();
14050        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
14051        let producer = endpoint
14052            .create_producer(rt(), &ctx)
14053            .expect("create producer");
14054
14055        // Two sequential hostname requests — the cached pinned client stays
14056        // retrievable between them, so no second build may happen.
14057        for i in 0..2 {
14058            let exchange = Exchange::new(Message::default());
14059            let reply = producer.clone().oneshot(exchange).await;
14060            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
14061        }
14062
14063        assert_eq!(
14064            pinned_cache.build_count(),
14065            1,
14066            "repeated hostname requests must reuse the one pinned client; \
14067             0 builds means the producer bypassed the cache, more than 1 \
14068             means the entry was dropped"
14069        );
14070    }
14071
14072    #[tokio::test]
14073    async fn ip_literal_request_never_enters_cache() {
14074        use tower::ServiceExt;
14075
14076        let (base_url, _handle) = spawn_multi_accept_200().await;
14077        let pinned_cache = Arc::new(PinnedClientCache::new(
14078            PINNED_CLIENT_TTL,
14079            PINNED_CLIENT_MAX_ENTRIES,
14080        ));
14081
14082        let ctx = test_producer_ctx();
14083        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
14084        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
14085        let producer = endpoint
14086            .create_producer(rt(), &ctx)
14087            .expect("create producer");
14088
14089        let exchange = Exchange::new(Message::default());
14090        let reply = producer.oneshot(exchange).await;
14091        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
14092
14093        assert_eq!(
14094            pinned_cache.build_count(),
14095            0,
14096            "an IP-literal URL must use the shared unpinned client and \
14097             never enter the pinned cache"
14098        );
14099    }
14100
14101    #[tokio::test]
14102    async fn test_component_endpoints_share_pinned_cache() {
14103        use tower::ServiceExt;
14104
14105        let component = HttpComponent::new();
14106        let (base_url, _handle) = spawn_multi_accept_200().await;
14107        let baseline = component.pinned_cache.build_count();
14108
14109        let ctx = test_producer_ctx();
14110        let endpoint_ctx = NoOpComponentContext;
14111        for uri in [
14112            format!("{base_url}/a?allowInternal=true&k=a"),
14113            format!("{base_url}/b?allowInternal=true&k=b"),
14114        ] {
14115            let endpoint = component
14116                .create_endpoint(&uri, &endpoint_ctx)
14117                .expect("create endpoint");
14118            let producer = endpoint
14119                .create_producer(rt(), &ctx)
14120                .expect("create producer");
14121            let exchange = Exchange::new(Message::default());
14122            let reply = producer.oneshot(exchange).await;
14123            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
14124        }
14125
14126        assert_eq!(
14127            component.pinned_cache.build_count() - baseline,
14128            1,
14129            "endpoints created by one component must share its pinned cache; \
14130             0 builds means the endpoints bypassed it, more than 1 means \
14131             per-endpoint caches came back"
14132        );
14133    }
14134
14135    #[tokio::test]
14136    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
14137        use tower::ServiceExt;
14138
14139        let component = HttpComponent::new();
14140        let (base_url, _handle) = spawn_multi_accept_200().await;
14141        let baseline = component.pinned_cache.build_count();
14142
14143        let ctx = test_producer_ctx();
14144        let endpoint_ctx = NoOpComponentContext;
14145        for i in 0..3 {
14146            let endpoint = component
14147                .create_endpoint(
14148                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
14149                    &endpoint_ctx,
14150                )
14151                .expect("create endpoint");
14152            let producer = endpoint
14153                .create_producer(rt(), &ctx)
14154                .expect("create producer");
14155            let exchange = Exchange::new(Message::default());
14156            let reply = producer.oneshot(exchange).await;
14157            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
14158        }
14159
14160        assert_eq!(
14161            component.pinned_cache.build_count() - baseline,
14162            1,
14163            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
14164             must reuse the component's one pinned cache entry; 0 builds \
14165             means the endpoints bypassed it, more than 1 means \
14166             per-endpoint caches came back"
14167        );
14168    }
14169
14170    /// rc-0li3: BEHAVIORAL https sharing pin — two https endpoints created
14171    /// through one `HttpsComponent` drive real TLS requests through the
14172    /// component's single pinned cache. A regression that reintroduces
14173    /// per-endpoint `PinnedClientCache::new` inside
14174    /// `HttpsComponent::create_endpoint` leaves the component cache at
14175    /// delta 0 and fails this test (the structural ptr_eq test cannot see
14176    /// that).
14177    #[tokio::test]
14178    async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
14179        use tower::ServiceExt;
14180
14181        let http_config = HttpConfig {
14182            tls: Some(crate::config::TlsConfig {
14183                enabled: true,
14184                insecure: true,
14185                ..Default::default()
14186            }),
14187            ..Default::default()
14188        };
14189        let component = HttpsComponent::with_config(http_config);
14190        let (base_url, _handle) = spawn_tls_multi_accept_200().await;
14191        let baseline = component.pinned_cache.build_count();
14192
14193        let ctx = test_producer_ctx();
14194        let endpoint_ctx = NoOpComponentContext;
14195        for uri in [
14196            format!("{base_url}/a?allowInternal=true&k=a"),
14197            format!("{base_url}/b?allowInternal=true&k=b"),
14198        ] {
14199            let endpoint = component
14200                .create_endpoint(&uri, &endpoint_ctx)
14201                .expect("create https endpoint");
14202            let producer = endpoint
14203                .create_producer(rt(), &ctx)
14204                .expect("create producer");
14205            let exchange = Exchange::new(Message::default());
14206            let reply = producer.oneshot(exchange).await;
14207            assert!(reply.is_ok(), "https request failed: {reply:?}");
14208        }
14209
14210        assert_eq!(
14211            component.pinned_cache.build_count() - baseline,
14212            1,
14213            "endpoints of one HttpsComponent must share its pinned cache over \
14214             real https requests; 0 builds means the endpoints bypassed it \
14215             (per-endpoint cache regression), more than 1 means \
14216             per-endpoint caches came back"
14217        );
14218    }
14219
14220    #[test]
14221    fn test_https_component_owns_distinct_cache() {
14222        let http = HttpComponent::new();
14223        let https = HttpsComponent::new();
14224
14225        assert!(
14226            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
14227            "http and https components must each own their own pinned cache"
14228        );
14229
14230        let endpoint_ctx = NoOpComponentContext;
14231        let _ = http
14232            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
14233            .expect("http endpoint");
14234        let _ = https
14235            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
14236            .expect("https endpoint");
14237
14238        assert_eq!(
14239            http.pinned_cache.build_count(),
14240            0,
14241            "endpoint creation must not build a pinned client"
14242        );
14243        assert_eq!(
14244            https.pinned_cache.build_count(),
14245            0,
14246            "endpoint creation must not build a pinned client"
14247        );
14248    }
14249
14250    #[test]
14251    fn test_component_constructor_builds_one_unpinned_client() {
14252        let baseline = build_client_call_count();
14253
14254        let _http = HttpComponent::new();
14255        assert_eq!(
14256            build_client_call_count() - baseline,
14257            1,
14258            "HttpComponent::new() must build exactly one shared unpinned client"
14259        );
14260
14261        let _https = HttpsComponent::new();
14262        assert_eq!(
14263            build_client_call_count() - baseline,
14264            2,
14265            "HttpsComponent::new() must build exactly one more shared unpinned client"
14266        );
14267    }
14268
14269    #[test]
14270    fn test_component_endpoints_share_unpinned_client() {
14271        let component = HttpComponent::new();
14272        let baseline = build_client_call_count();
14273
14274        let endpoint_ctx = NoOpComponentContext;
14275        for uri in [
14276            "http://localhost:1/a?allowInternal=true",
14277            "http://localhost:1/b?allowInternal=true",
14278        ] {
14279            let _endpoint = component
14280                .create_endpoint(uri, &endpoint_ctx)
14281                .expect("create endpoint");
14282        }
14283
14284        assert_eq!(
14285            build_client_call_count() - baseline,
14286            0,
14287            "create_endpoint must clone the component's shared unpinned client, \
14288             never build a fresh one"
14289        );
14290    }
14291
14292    #[test]
14293    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
14294        let component = HttpComponent::new();
14295        let baseline = build_client_call_count();
14296
14297        let ctx = test_producer_ctx();
14298        let endpoint_ctx = NoOpComponentContext;
14299        for i in 0..3 {
14300            let endpoint = component
14301                .create_endpoint(
14302                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
14303                    &endpoint_ctx,
14304                )
14305                .expect("create endpoint");
14306            let _producer = endpoint
14307                .create_producer(rt(), &ctx)
14308                .expect("create producer");
14309        }
14310
14311        assert_eq!(
14312            build_client_call_count() - baseline,
14313            0,
14314            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
14315             must reuse the component's shared unpinned client and build \
14316             no additional clients"
14317        );
14318    }
14319
14320    #[test]
14321    fn test_webpki_fallback_config_builds_client() {
14322        // The fallback backend must construct a client on ANY platform —
14323        // it reads no platform state. This is the exact TLS configuration
14324        // Termux executes when the primary (platform-verifier) build
14325        // fails (rc-3j4mq).
14326        let client = reqwest::Client::builder()
14327            .tls_backend_preconfigured(webpki_root_client_config())
14328            .build()
14329            .expect("webpki-rooted client must build"); // allow-unwrap(test)
14330        // A built client is usable (internal state initialized); assert
14331        // via the debug render rather than a network round-trip.
14332        let rendered = format!("{client:?}");
14333        assert!(
14334            !rendered.is_empty(),
14335            "client must render a debug representation"
14336        );
14337    }
14338
14339    /// rcgen self-signed one-root CA, written to `ca.pem` inside a fresh
14340    /// tempdir (rcgen 0.14 shapes per camel-component-api test_support).
14341    fn fallback_test_ca() -> (tempfile::TempDir, String) {
14342        let ca_key = rcgen::KeyPair::generate().expect("ca keygen"); // allow-unwrap(test)
14343        let mut ca_params = rcgen::CertificateParams::default();
14344        ca_params
14345            .distinguished_name
14346            .push(rcgen::DnType::CommonName, "Fallback test CA");
14347        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
14348        let ca_cert = ca_params.self_signed(&ca_key).expect("ca self-sign"); // allow-unwrap(test)
14349        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14350        let ca_path = dir.path().join("ca.pem");
14351        std::fs::write(&ca_path, ca_cert.pem()).expect("write ca pem"); // allow-unwrap(test)
14352        (dir, ca_path.display().to_string())
14353    }
14354
14355    /// Valid strict TLS material fixtures (rc-3x5qj): a rcgen one-root
14356    /// CA written to `ca.pem` plus a CA-signed client identity written
14357    /// to `client-cert.pem`/`client-key.pem`, all inside a fresh
14358    /// tempdir. The CA itself is returned too, so handshake tests can
14359    /// sign a server leaf from the SAME CA the config carries — the
14360    /// rebuild-failure tests' precondition (VALID material throughout,
14361    /// so no material error can precede the terminal under test).
14362    /// Named fields for the strict TLS material fixtures (rc-3x5qj):
14363    /// position-only PathBufs in a tuple invited silent cert/key swaps.
14364    struct StrictMaterial {
14365        dir: tempfile::TempDir,
14366        ca: TestCa,
14367        ca_path: std::path::PathBuf,
14368        cert_path: std::path::PathBuf,
14369        key_path: std::path::PathBuf,
14370    }
14371
14372    fn strict_material_fixtures() -> StrictMaterial {
14373        let ca = gen_test_ca();
14374        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14375        let ca_path = dir.path().join("ca.pem");
14376        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
14377        let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
14378        let cert_path = dir.path().join("client-cert.pem");
14379        std::fs::write(&cert_path, client_cert_pem.as_bytes()).expect("write client cert"); // allow-unwrap(test)
14380        let key_path = dir.path().join("client-key.pem");
14381        std::fs::write(&key_path, client_key_pem.as_bytes()).expect("write client key"); // allow-unwrap(test)
14382        StrictMaterial {
14383            dir,
14384            ca,
14385            ca_path,
14386            cert_path,
14387            key_path,
14388        }
14389    }
14390
14391    /// Assert `err` is `EndpointCreationFailed` whose message contains
14392    /// every `fragment`.
14393    fn assert_strict_fallback_error(err: CamelError, fragments: &[&str]) {
14394        match err {
14395            CamelError::EndpointCreationFailed(msg) => {
14396                for fragment in fragments {
14397                    assert!(msg.contains(fragment), "message: {msg}");
14398                }
14399            }
14400            other => panic!("expected EndpointCreationFailed, got {other:?}"),
14401        }
14402    }
14403
14404    /// Order is hygiene-only for verification, not load-bearing:
14405    /// rustls 0.23.45 `src/webpki/verify.rs:245-263` passes
14406    /// `&roots.roots` to webpki in store order, and rustls-webpki
14407    /// 0.103.15 `src/verify_cert.rs:66-73` +
14408    /// `loop_while_non_fatal_error` (`:757-774`) returns on the FIRST
14409    /// anchor that validates and continues past non-fatal misses, so
14410    /// accept/reject cannot depend on order. Custom-first is pinned
14411    /// anyway for parity with the primary path
14412    /// (rustls-platform-verifier 0.7.0
14413    /// `src/verification/others.rs:81-93` adds extra roots before
14414    /// native certs).
14415    #[test]
14416    fn test_fallback_root_store_union_custom_and_mozilla() {
14417        let (_dir, ca_path) = fallback_test_ca();
14418        let store = fallback_root_store(Some(&ca_path), true).expect("union root store must build"); // allow-unwrap(test)
14419        assert_eq!(
14420            store.roots.len(),
14421            webpki_roots::TLS_SERVER_ROOTS.len() + 1,
14422            "custom CA must UNION with the Mozilla anchors, not replace them"
14423        );
14424        let ca_pem = std::fs::read_to_string(&ca_path).expect("read ca pem back"); // allow-unwrap(test)
14425        let der = rustls_pemfile::certs(&mut std::io::Cursor::new(ca_pem.as_bytes()))
14426            .next()
14427            .expect("exactly one CERTIFICATE section in test CA") // allow-unwrap(test)
14428            .expect("parse test CA pem"); // allow-unwrap(test)
14429        // Rebuild the expected anchor the same way the production store
14430        // accepts it, so the equality below is construction-identical.
14431        let mut scratch = rustls::RootCertStore::empty();
14432        let (added, _ignored) = scratch.add_parsable_certificates(vec![der]);
14433        assert_eq!(added, 1, "scratch root store must accept the test CA");
14434        let expected_anchor = scratch.roots[0].clone();
14435        assert_eq!(
14436            store.roots.first(),
14437            Some(&expected_anchor),
14438            "custom anchor must sit at index 0 — parity with the primary path's \
14439             extra-roots-before-platform-roots order"
14440        );
14441        assert_eq!(
14442            store.roots[1..],
14443            *webpki_roots::TLS_SERVER_ROOTS,
14444            "everything after the custom anchor must be exactly the bundled Mozilla anchors"
14445        );
14446        let first_custom = store
14447            .roots
14448            .iter()
14449            .position(|anchor| anchor == &expected_anchor)
14450            .expect("custom anchor present in store"); // allow-unwrap(test)
14451        let first_mozilla = store
14452            .roots
14453            .iter()
14454            .position(|anchor| webpki_roots::TLS_SERVER_ROOTS.contains(anchor))
14455            .expect("Mozilla anchors present in store"); // allow-unwrap(test)
14456        assert!(
14457            first_custom < first_mozilla,
14458            "first custom anchor (index {first_custom}) must precede the first \
14459             Mozilla anchor (index {first_mozilla})"
14460        );
14461    }
14462
14463    #[test]
14464    fn test_fallback_root_store_no_ca_is_mozilla_only() {
14465        let store = fallback_root_store(None, true).expect("mozilla-only store must build"); // allow-unwrap(test)
14466        assert_eq!(
14467            store.roots.len(),
14468            webpki_roots::TLS_SERVER_ROOTS.len(),
14469            "no configured CA must leave the bundled Mozilla anchors untouched"
14470        );
14471        assert_eq!(
14472            store.roots,
14473            *webpki_roots::TLS_SERVER_ROOTS,
14474            "with no configured CA the whole store IS the bundled Mozilla anchors, \
14475             byte-for-byte"
14476        );
14477    }
14478
14479    #[test]
14480    fn test_fallback_root_store_strict_unreadable_ca_fails_closed() {
14481        let err = match fallback_root_store(Some("/nonexistent/ca-bundle.pem"), true) {
14482            Err(e) => e,
14483            Ok(_) => panic!("strict mode must fail closed on an unreadable CA file"),
14484        };
14485        assert_strict_fallback_error(err, &["tls.strict/webpki-fallback", "unreadable"]);
14486    }
14487
14488    #[test]
14489    fn test_fallback_root_store_strict_zero_pem_sections_fails_closed() {
14490        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14491        let ca_path = dir.path().join("key-only.pem");
14492        std::fs::write(
14493            &ca_path,
14494            "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14495        )
14496        .expect("write key-only pem"); // allow-unwrap(test)
14497        let path = ca_path.display().to_string();
14498        let err = match fallback_root_store(Some(&path), true) {
14499            Err(e) => e,
14500            Ok(_) => panic!("strict mode must fail closed on a CA with no CERTIFICATE section"),
14501        };
14502        assert_strict_fallback_error(err, &["no parseable PEM CERTIFICATE"]);
14503    }
14504
14505    #[test]
14506    fn test_fallback_root_store_strict_zero_roots_accepted_fails_closed() {
14507        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14508        let ca_path = dir.path().join("garbage-cert.pem");
14509        std::fs::write(
14510            &ca_path,
14511            "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14512        )
14513        .expect("write garbage cert pem"); // allow-unwrap(test)
14514        let path = ca_path.display().to_string();
14515        let err = match fallback_root_store(Some(&path), true) {
14516            Err(e) => e,
14517            Ok(_) => panic!("strict mode must fail closed when the root store rejects the CA"),
14518        };
14519        assert_strict_fallback_error(err, &["rejected by the TLS root store"]);
14520    }
14521
14522    #[test]
14523    fn test_fallback_root_store_nonstrict_bad_ca_degrades_to_mozilla() {
14524        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14525        let ca_path = dir.path().join("garbage.pem");
14526        std::fs::write(&ca_path, "this is not a certificate\n").expect("write garbage pem"); // allow-unwrap(test)
14527        let path = ca_path.display().to_string();
14528        let store = fallback_root_store(Some(&path), false)
14529            .expect("non-strict garbage CA must degrade to Mozilla-only"); // allow-unwrap(test)
14530        assert_eq!(
14531            store.roots.len(),
14532            webpki_roots::TLS_SERVER_ROOTS.len(),
14533            "non-strict bad CA must degrade to the bundled Mozilla anchors"
14534        );
14535    }
14536
14537    #[test]
14538    fn test_fallback_client_config_disabled_tls_returns_none() {
14539        let tls = TlsConfig {
14540            enabled: false,
14541            ..Default::default()
14542        };
14543        assert!(
14544            fallback_client_config(&tls)
14545                .expect("disabled TLS is infallible") // allow-unwrap(test)
14546                .is_none()
14547        );
14548    }
14549
14550    #[test]
14551    fn test_fallback_client_config_no_material_verifying_returns_none() {
14552        let tls = TlsConfig {
14553            enabled: true,
14554            ..Default::default()
14555        };
14556        assert!(
14557            fallback_client_config(&tls)
14558                .expect("plain verifying TLS is infallible") // allow-unwrap(test)
14559                .is_none()
14560        );
14561    }
14562
14563    #[test]
14564    fn test_fallback_client_config_strict_identity_garbage_key_fails_closed() {
14565        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14566        let client_key = rcgen::KeyPair::generate().expect("client keygen"); // allow-unwrap(test)
14567        let client_params = rcgen::CertificateParams::default();
14568        let client_cert = client_params
14569            .self_signed(&client_key)
14570            .expect("client cert self-sign"); // allow-unwrap(test)
14571        let cert_path = dir.path().join("client.pem");
14572        std::fs::write(&cert_path, client_cert.pem()).expect("write client pem"); // allow-unwrap(test)
14573        let key_path = dir.path().join("key.pem");
14574        std::fs::write(&key_path, "garbage not a key at all\n").expect("write garbage key"); // allow-unwrap(test)
14575        let tls = TlsConfig {
14576            enabled: true,
14577            strict: true,
14578            client_cert_path: Some(cert_path.display().to_string()),
14579            client_key_path: Some(key_path.display().to_string()),
14580            ..Default::default()
14581        };
14582        let err = match fallback_client_config(&tls) {
14583            Err(e) => e,
14584            Ok(_) => panic!("strict mode must fail closed on a garbage client key"),
14585        };
14586        assert_strict_fallback_error(err, &["no parseable private key"]);
14587    }
14588
14589    #[test]
14590    fn test_fallback_client_config_strict_rustls_rejected_key_fails_closed() {
14591        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14592        let client_key = rcgen::KeyPair::generate().expect("client keygen"); // allow-unwrap(test)
14593        let client_params = rcgen::CertificateParams::default();
14594        let client_cert = client_params
14595            .self_signed(&client_key)
14596            .expect("client cert self-sign"); // allow-unwrap(test)
14597        let cert_path = dir.path().join("client.pem");
14598        std::fs::write(&cert_path, client_cert.pem()).expect("write client pem"); // allow-unwrap(test)
14599        let key_path = dir.path().join("key.pem");
14600        std::fs::write(
14601            &key_path,
14602            "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14603        )
14604        .expect("write pkcs8-framed garbage key"); // allow-unwrap(test)
14605        let tls = TlsConfig {
14606            enabled: true,
14607            strict: true,
14608            client_cert_path: Some(cert_path.display().to_string()),
14609            client_key_path: Some(key_path.display().to_string()),
14610            ..Default::default()
14611        };
14612        let err = match fallback_client_config(&tls) {
14613            Err(e) => e,
14614            Ok(_) => panic!("strict mode must fail closed when rustls rejects the identity"),
14615        };
14616        assert_strict_fallback_error(err, &["rejected by the TLS backend"]);
14617    }
14618
14619    #[test]
14620    fn test_fallback_client_config_half_mtls_pair_strict_fails_nonstrict_no_auth() {
14621        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14622        let cert_path = dir.path().join("client.pem");
14623        std::fs::write(&cert_path, "cert-only, key missing\n").expect("write placeholder pem"); // allow-unwrap(test)
14624        let tls_strict = TlsConfig {
14625            enabled: true,
14626            strict: true,
14627            client_cert_path: Some(cert_path.display().to_string()),
14628            ..Default::default()
14629        };
14630        let err = match fallback_client_config(&tls_strict) {
14631            Err(e) => e,
14632            Ok(_) => panic!("strict mode must fail closed on a half-configured mTLS pair"),
14633        };
14634        assert_strict_fallback_error(err, &["BOTH client_cert_path"]);
14635        let tls_permissive = TlsConfig {
14636            enabled: true,
14637            client_cert_path: Some(cert_path.display().to_string()),
14638            ..Default::default()
14639        };
14640        assert!(
14641            fallback_client_config(&tls_permissive)
14642                .expect("non-strict half pair degrades to no-auth") // allow-unwrap(test)
14643                .is_some()
14644        );
14645    }
14646
14647    #[test]
14648    fn test_no_verify_verifier_accepts_any_certificate() {
14649        use rustls::client::danger::ServerCertVerifier;
14650
14651        let verifier = NoVerifyServerCertVerifier;
14652        let garbage = rustls::pki_types::CertificateDer::from(vec![0xDE, 0xAD, 0xBE, 0xEF]);
14653        let server_name =
14654            rustls::pki_types::ServerName::try_from("localhost").expect("localhost dns name"); // allow-unwrap(test)
14655        assert!(
14656            verifier
14657                .verify_server_cert(
14658                    &garbage,
14659                    &[],
14660                    &server_name,
14661                    &[],
14662                    rustls::pki_types::UnixTime::now()
14663                )
14664                .is_ok(),
14665            "no-verify verifier must accept any certificate without validation"
14666        );
14667    }
14668
14669    #[test]
14670    fn test_build_client_falls_back_on_empty_platform_ca_store() {
14671        // Serialize against the primary-path test: the env window below
14672        // is process-visible and would force its build through the
14673        // fallback too.
14674        let _ca_guard = lock_ca_store_test_mutex();
14675        // Simulate a CA-less platform (Android/Termux, rc-3j4mq):
14676        // openssl-probe honors SSL_CERT_FILE/SSL_CERT_DIR only when the
14677        // paths EXIST, so an existing-but-empty file plus an
14678        // existing-but-empty directory make rustls-native-certs load zero
14679        // roots — the exact condition that made the platform verifier
14680        // (and pre-fix startup) fail on Termux. The env window is
14681        // process-visible: concurrent client builds in other tests also
14682        // take the fallback, which still builds a working client — no
14683        // assertion outside this test can distinguish the two paths
14684        // except via this thread's BUILD_CLIENT_FALLBACKS counter.
14685        let empty_ca_dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14686        let empty_ca_file = empty_ca_dir.path().join("empty-ca-bundle.pem");
14687        std::fs::write(&empty_ca_file, b"").expect("write empty CA file"); // allow-unwrap(test)
14688
14689        // Safety: test-only mutation of process env vars. This test does
14690        // not spawn threads that read these vars outside the guarded
14691        // window below; the guard restores previous values before return.
14692        let (prev_file, prev_dir) = unsafe {
14693            let prev = (
14694                std::env::var_os("SSL_CERT_FILE"),
14695                std::env::var_os("SSL_CERT_DIR"),
14696            );
14697            std::env::set_var("SSL_CERT_FILE", &empty_ca_file);
14698            std::env::set_var("SSL_CERT_DIR", empty_ca_dir.path());
14699            prev
14700        };
14701
14702        let result = std::panic::catch_unwind(|| {
14703            let fallbacks_before = build_client_fallback_count();
14704            let _client = build_client(&HttpConfig::default(), None).expect("client must build"); // allow-unwrap(test)
14705            build_client_fallback_count() - fallbacks_before
14706        });
14707
14708        // Restore FIRST so the guard holds even if assertions fail.
14709        // Safety: restoring the previously-captured values.
14710        unsafe {
14711            match prev_file {
14712                Some(v) => std::env::set_var("SSL_CERT_FILE", v),
14713                None => std::env::remove_var("SSL_CERT_FILE"),
14714            }
14715            match prev_dir {
14716                Some(v) => std::env::set_var("SSL_CERT_DIR", v),
14717                None => std::env::remove_var("SSL_CERT_DIR"),
14718            }
14719        }
14720
14721        let fallbacks_taken = result.expect("build_client must not panic on an empty CA store"); // allow-unwrap(test)
14722        assert_eq!(
14723            fallbacks_taken, 1,
14724            "an empty platform CA store must route exactly one build through \
14725             the webpki fallback (primary build failed, fallback succeeded)"
14726        );
14727    }
14728
14729    /// Throwaway self-signed root used ONLY to make the native-root
14730    /// store non-empty hermetically (tests need a parseable PEM
14731    /// CERTIFICATE section, nothing more — the private key was discarded
14732    /// at generation and the root signs/trusts nothing). Generated
14733    /// 2026-09-21, self-expiring 2126.
14734    const HERMETIC_TEST_ROOT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
14735        MIIDVzCCAj+gAwIBAgIUQ+hB0JPFtHpUKaNwcTB8ijGqEW0wDQYJKoZIhvcNAQEL\n\
14736        BQAwOjEdMBsGA1UEAwwUY2FtZWwtaHR0cCB0ZXN0IHJvb3QxGTAXBgNVBAoMEGNh\n\
14737        bWVsLWh0dHAgdGVzdHMwIBcNMjYwOTIxMTEyMzUwWhgPMjEyNjA4MjgxMTIzNTBa\n\
14738        MDoxHTAbBgNVBAMMFGNhbWVsLWh0dHAgdGVzdCByb290MRkwFwYDVQQKDBBjYW1l\n\
14739        bC1odHRwIHRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAolnF\n\
14740        SiT/bK8pMl9n50HTQj4oxPZGXK34Q/OSq8TMUEcmhUFzBjzwZKoFqvDIt/e0mSi/\n\
14741        h8WQlnrHAvYOnsRlpDsfs4yder+lpEYE84OooJjQvj/kREj7ncK6WQKDY1NTGikx\n\
14742        lF8kIaCrNHNIlUgoSWMR4E6UshLFwSu5lKtcWCeN6FNzGVQ9jxYJSFsmVk+wFyHI\n\
14743        edvpPbnFkD3M1/GKNVuxCCR50sO+cQeB7w9FCyFdHvoTWWuwPV4Qq2LBM2n4eedG\n\
14744        pCXkqmuARtFuOKjpYIRryGybic9u9ZW59FbTbgHSj6rcOXNtBUVV/KnWRDRWo7ZC\n\
14745        cR5SmO2YwtfpISjbmwIDAQABo1MwUTAdBgNVHQ4EFgQUkhwU0Q5JYNLrs7yOqbXE\n\
14746        szzn2/cwHwYDVR0jBBgwFoAUkhwU0Q5JYNLrs7yOqbXEszzn2/cwDwYDVR0TAQH/\n\
14747        BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAXLqN7poLyxlpxN9vY1jISs403P2I\n\
14748        edwgX3eWyJgXQfc/+dhnig8Pi7SkJKNM9PM27qain3+e2/HeetNYGBD8xnzQo5ga\n\
14749        1ScPvm7u2DWIWc5y0bpm5JuCPqXoDl/7kBcaelA2lZRrPJzQuu6uPnH1ubjUPFJj\n\
14750        9SxWhR+QgJW3cFc17C179WSVSJSPLozxX1ODABSyxNXnz14Rq+MOJi2tEsDggyMm\n\
14751        txh6GwgVbiSey9c9bvu3B28iPfNpULuxBzxSLHcRrSSoII+46foY0G63V/jx7huY\n\
14752        Fc2DPwOcO2Ni8fLssMaAnEdWPZaIN/DdjmUFw9XgHE9Eld6aKEXBcRFPzA==\n\
14753        -----END CERTIFICATE-----";
14754
14755    #[test]
14756    fn test_build_client_primary_path_when_platform_store_non_empty() {
14757        // The webpki set is a FALLBACK, never a replacement for platform
14758        // roots (managed fleets keep OS root-program control). Hermetic:
14759        // SSL_CERT_FILE pinned to a parseable fixture root makes the
14760        // native store non-empty on ANY host — including genuinely
14761        // CA-less ones — so the no-fallback assertion cannot depend on
14762        // the build machine. Serialized against the CA-less sibling test
14763        // by the CA-store mutex (its env window forces the fallback).
14764        let _ca_guard = lock_ca_store_test_mutex();
14765        let ca_dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14766        let ca_file = ca_dir.path().join("hermetic-root.pem");
14767        std::fs::write(&ca_file, HERMETIC_TEST_ROOT_PEM).expect("write fixture root"); // allow-unwrap(test)
14768
14769        // Safety: test-only mutation of process env vars, restored
14770        // before any assertion below.
14771        let (prev_file, prev_dir) = unsafe {
14772            let prev = (
14773                std::env::var_os("SSL_CERT_FILE"),
14774                std::env::var_os("SSL_CERT_DIR"),
14775            );
14776            std::env::set_var("SSL_CERT_FILE", &ca_file);
14777            std::env::set_var("SSL_CERT_DIR", ca_dir.path());
14778            prev
14779        };
14780
14781        let fallbacks_before = build_client_fallback_count();
14782        let _client = build_client(&HttpConfig::default(), None).expect("client must build"); // allow-unwrap(test)
14783        let fallbacks_taken = build_client_fallback_count() - fallbacks_before;
14784
14785        // Safety: restoring the previously-captured values.
14786        unsafe {
14787            match prev_file {
14788                Some(v) => std::env::set_var("SSL_CERT_FILE", v),
14789                None => std::env::remove_var("SSL_CERT_FILE"),
14790            }
14791            match prev_dir {
14792                Some(v) => std::env::set_var("SSL_CERT_DIR", v),
14793                None => std::env::remove_var("SSL_CERT_DIR"),
14794            }
14795        }
14796
14797        assert_eq!(
14798            fallbacks_taken, 0,
14799            "with at least one platform root loadable, build_client must \
14800             use the platform-verifier primary path, never the webpki \
14801             fallback"
14802        );
14803    }
14804
14805    #[test]
14806    fn test_build_client_forced_fallback_strict_store_rejected_ca_returns_typed_error() {
14807        // pemfile-ok / store-rejected CA: the PEM section base64-decodes
14808        // (rustls-pemfile does not validate DER), so only the root store
14809        // rejects it — the store-rejection class through the full
14810        // build_client fallback path.
14811        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14812        let ca_path = dir.path().join("garbage-cert.pem");
14813        std::fs::write(
14814            &ca_path,
14815            "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14816        )
14817        .expect("write garbage cert pem"); // allow-unwrap(test)
14818        let config = HttpConfig {
14819            tls: Some(TlsConfig {
14820                enabled: true,
14821                strict: true,
14822                ca_cert_path: Some(ca_path.display().to_string()),
14823                ..Default::default()
14824            }),
14825            ..Default::default()
14826        };
14827
14828        let (err, fallbacks_taken) = forced_fallback_env(|| {
14829            let fallbacks_before = build_client_fallback_count();
14830            let err = match build_client(&config, None) {
14831                Err(e) => e,
14832                Ok(_) => panic!("strict mode must fail closed when the root store rejects the CA"),
14833            };
14834            (err, build_client_fallback_count() - fallbacks_before)
14835        });
14836
14837        assert_strict_fallback_error(
14838            err,
14839            &[
14840                "tls.strict/webpki-fallback",
14841                "rejected by the TLS root store",
14842            ],
14843        );
14844        assert_eq!(
14845            fallbacks_taken, 1,
14846            "the typed error must come from exactly one webpki fallback entry"
14847        );
14848    }
14849
14850    #[test]
14851    fn test_build_client_forced_fallback_strict_material_rejection_classes() {
14852        // One fixture set, four strict rejection classes, each routed
14853        // through the full build_client fallback path.
14854        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14855
14856        // (a) CA material with zero PEM CERTIFICATE sections.
14857        let plain_text_ca = dir.path().join("plain-text.pem");
14858        std::fs::write(&plain_text_ca, "this is not a certificate\n").expect("write plain text ca"); // allow-unwrap(test)
14859
14860        // Valid rcgen client identity shared by the mTLS classes.
14861        let client_key = rcgen::KeyPair::generate().expect("client keygen"); // allow-unwrap(test)
14862        let client_cert = rcgen::CertificateParams::default()
14863            .self_signed(&client_key)
14864            .expect("client self-sign"); // allow-unwrap(test)
14865        let cert_path = dir.path().join("client-cert.pem");
14866        std::fs::write(&cert_path, client_cert.pem()).expect("write client cert"); // allow-unwrap(test)
14867        let valid_key_path = dir.path().join("client-key.pem");
14868        std::fs::write(&valid_key_path, client_key.serialize_pem()).expect("write client key"); // allow-unwrap(test)
14869        let garbage_key_path = dir.path().join("garbage-key.pem");
14870        std::fs::write(&garbage_key_path, "not a key\n").expect("write garbage key"); // allow-unwrap(test)
14871        // PEM section parses; rustls rejects the bytes at client-auth
14872        // configuration.
14873        let rejected_key_path = dir.path().join("rejected-key.pem");
14874        std::fs::write(
14875            &rejected_key_path,
14876            "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14877        )
14878        .expect("write rejected key"); // allow-unwrap(test)
14879
14880        struct RejectionCase {
14881            name: &'static str,
14882            tls: TlsConfig,
14883            fragment: &'static str,
14884        }
14885        let cases = [
14886            RejectionCase {
14887                name: "CA with zero PEM CERTIFICATE sections",
14888                tls: TlsConfig {
14889                    enabled: true,
14890                    strict: true,
14891                    ca_cert_path: Some(plain_text_ca.display().to_string()),
14892                    ..Default::default()
14893                },
14894                fragment: "no parseable PEM CERTIFICATE section",
14895            },
14896            RejectionCase {
14897                name: "client key with no parseable section",
14898                tls: TlsConfig {
14899                    enabled: true,
14900                    strict: true,
14901                    client_cert_path: Some(cert_path.display().to_string()),
14902                    client_key_path: Some(garbage_key_path.display().to_string()),
14903                    ..Default::default()
14904                },
14905                fragment: "no parseable private key section",
14906            },
14907            RejectionCase {
14908                name: "client key rejected by the TLS backend",
14909                tls: TlsConfig {
14910                    enabled: true,
14911                    strict: true,
14912                    client_cert_path: Some(cert_path.display().to_string()),
14913                    client_key_path: Some(rejected_key_path.display().to_string()),
14914                    ..Default::default()
14915                },
14916                fragment: "rejected by the TLS backend",
14917            },
14918            RejectionCase {
14919                name: "unreadable client cert",
14920                tls: TlsConfig {
14921                    enabled: true,
14922                    strict: true,
14923                    client_cert_path: Some("/nonexistent/client-cert.pem".to_string()),
14924                    client_key_path: Some(valid_key_path.display().to_string()),
14925                    ..Default::default()
14926                },
14927                fragment: "unreadable",
14928            },
14929        ];
14930
14931        for case in cases {
14932            let config = HttpConfig {
14933                tls: Some(case.tls),
14934                ..Default::default()
14935            };
14936            let err = forced_fallback_env(|| match build_client(&config, None) {
14937                Err(e) => e,
14938                Ok(_) => panic!("strict case '{}' must fail closed", case.name),
14939            });
14940            assert_strict_fallback_error(err, &["tls.strict/webpki-fallback", case.fragment]);
14941        }
14942    }
14943
14944    #[test]
14945    fn test_build_client_forced_fallback_nonstrict_bad_ca_still_builds() {
14946        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14947        let ca_path = dir.path().join("garbage.pem");
14948        std::fs::write(&ca_path, "this is not a certificate\n").expect("write garbage pem"); // allow-unwrap(test)
14949        let config = HttpConfig {
14950            tls: Some(TlsConfig {
14951                enabled: true,
14952                strict: false,
14953                ca_cert_path: Some(ca_path.display().to_string()),
14954                ..Default::default()
14955            }),
14956            ..Default::default()
14957        };
14958
14959        let (_client, fallbacks_taken) = forced_fallback_env(|| {
14960            let fallbacks_before = build_client_fallback_count();
14961            let client = build_client(&config, None)
14962                .expect("non-strict bad CA must degrade per-item and still build"); // allow-unwrap(test)
14963            (client, build_client_fallback_count() - fallbacks_before)
14964        });
14965
14966        assert_eq!(
14967            fallbacks_taken, 1,
14968            "permissive item downgrade still routes exactly one build \
14969             through the webpki fallback"
14970        );
14971    }
14972
14973    #[test]
14974    fn test_http_component_with_config_folds_fallback_error_without_panic() {
14975        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14976        let ca_path = dir.path().join("garbage-cert.pem");
14977        std::fs::write(
14978            &ca_path,
14979            "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14980        )
14981        .expect("write garbage cert pem"); // allow-unwrap(test)
14982        let config = HttpConfig {
14983            tls: Some(TlsConfig {
14984                enabled: true,
14985                strict: true,
14986                ca_cert_path: Some(ca_path.display().to_string()),
14987                ..Default::default()
14988            }),
14989            ..Default::default()
14990        };
14991
14992        let endpoint_err = forced_fallback_env(|| {
14993            // Construction must complete without panic; the build error
14994            // folds into strict_tls_error and surfaces at endpoint
14995            // creation.
14996            let component = HttpComponent::with_config(config);
14997            component
14998                .create_endpoint("http://h/p", &NoOpComponentContext)
14999                .err()
15000        });
15001
15002        let err = endpoint_err.expect("folded fallback error must fail endpoint creation"); // allow-unwrap(test)
15003        assert!(
15004            err.to_string().contains("rejected by the TLS root store"),
15005            "folded error must carry the fallback diagnosis: {err}"
15006        );
15007    }
15008
15009    #[test]
15010    fn test_http_component_new_no_panic_on_empty_ca_store() {
15011        let endpoint = forced_fallback_env(|| {
15012            let component = HttpComponent::new();
15013            component
15014                .create_endpoint("http://h/p", &NoOpComponentContext)
15015                .ok()
15016        });
15017        let _endpoint = endpoint.expect("material-free webpki fallback must serve endpoints"); // allow-unwrap(test)
15018    }
15019
15020    // ---- Forced-fallback loopback TLS handshake scenarios (castrict 1.3)
15021    //
15022    // Real TLS handshakes against a loopback rustls server prove that
15023    // the configured TLS material is CARRIED into the webpki fallback
15024    // backend: the strict material-free build demonstrably routes
15025    // through the forced webpki fallback (its Mozilla roots reject the
15026    // rcgen CA), while every material-carrying build completes a real
15027    // handshake against a server certified ONLY by the configured CA.
15028    //
15029    // Path forcing (e_glm adjudication, rc-hl9cn — Task 1.3 deviation
15030    // note in openspec/changes/castrict/tasks.md): rustls-platform-
15031    // verifier 0.7.0 merges configured extra roots into the platform
15032    // store FIRST and hard-errors only when the merged store is EMPTY,
15033    // so on Linux an empty `SSL_CERT_FILE`/`SSL_CERT_DIR` window plus a
15034    // valid configured CA keeps the PRIMARY build succeeding (extra-
15035    // roots rescue); the fallback-with-valid-material path is real only
15036    // on verifier-hard-error platforms (android/apple — the Termux
15037    // case). The material-carrying tests below therefore run
15038    // `build_client` under the `force_webpki_fallback` seam (primary
15039    // skipped, fallback entered directly; the trigger warn's error
15040    // field reads "forced webpki fallback entry (test)") and prove path
15041    // control with a `build_client_fallback_count()` delta of exactly
15042    // one. The material-free test keeps the REAL env window — no extra
15043    // roots exist to rescue the verifier, so its fallback entry is
15044    // genuine. Every send is timeout-bounded, so a broken harness
15045    // fails fast instead of hanging.
15046
15047    #[tracing_test::traced_test]
15048    #[tokio::test]
15049    async fn tls_handshake_strict_custom_ca_carried() {
15050        let ca = gen_test_ca();
15051        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15052        let ca_path = dir.path().join("ca.pem");
15053        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
15054        let (server_cert, server_key) =
15055            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15056        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15057
15058        let config = HttpConfig {
15059            tls: Some(TlsConfig {
15060                enabled: true,
15061                strict: true,
15062                ca_cert_path: Some(ca_path.display().to_string()),
15063                ..Default::default()
15064            }),
15065            ..Default::default()
15066        };
15067        let client = build_under_seam(&config);
15068
15069        let resp = tokio::time::timeout(
15070            Duration::from_secs(15),
15071            client.get(format!("https://{addr}/")).send(),
15072        )
15073        .await
15074        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15075        .expect("strict webpki fallback must carry the configured CA into the handshake"); // allow-unwrap(test)
15076        assert_eq!(resp.status().as_u16(), 200);
15077        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15078        assert_eq!(body, "ok");
15079
15080        // Path control beyond the counter: the strict material-carried
15081        // info log fires inside the fallback (scope-filtered
15082        // `logs_contain`, injected by #[traced_test], so sibling tests'
15083        // events cannot pollute this assertion).
15084        assert!(
15085            logs_contain("carries configured TLS material"),
15086            "strict webpki fallback with configured material must log the carry"
15087        );
15088
15089        server.abort();
15090    }
15091
15092    #[tracing_test::traced_test]
15093    #[tokio::test]
15094    async fn tls_handshake_strict_material_free_fails_same_server() {
15095        let ca = gen_test_ca();
15096        let (server_cert, server_key) =
15097            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15098        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15099
15100        let config = HttpConfig {
15101            tls: Some(TlsConfig {
15102                enabled: true,
15103                strict: true,
15104                ..Default::default()
15105            }),
15106            ..Default::default()
15107        };
15108        let client = forced_fallback_env(|| {
15109            build_client(&config, None).expect("client must build") // allow-unwrap(test)
15110        });
15111
15112        let send_result = tokio::time::timeout(
15113            Duration::from_secs(15),
15114            client.get(format!("https://{addr}/")).send(),
15115        )
15116        .await
15117        .expect("request must complete within the harness timeout"); // allow-unwrap(test)
15118        assert!(
15119            send_result.is_err(),
15120            "material-free strict fallback trusts only Mozilla roots, which do not \
15121             trust the rcgen CA — the handshake must fail"
15122        );
15123
15124        // No TLS material configured: no per-item degradation warns
15125        // (spec scenario — material-free strict fallback). Scope-
15126        // filtered `logs_contain` (injected by #[traced_test]) so
15127        // sibling tests' mTLS-degrade warns cannot pollute the
15128        // negations; the positive control proves capture works.
15129        assert!(
15130            logs_contain("HTTP client build failed on platform TLS roots"),
15131            "positive control: this test's own fallback warn must be captured"
15132        );
15133        assert!(
15134            !logs_contain("client certificate NOT used"),
15135            "no identity was configured — the identity degrade warn must not fire"
15136        );
15137        assert!(
15138            !logs_contain("falling back to bundled Mozilla roots"),
15139            "no CA was configured — the CA degrade warn must not fire"
15140        );
15141
15142        server.abort();
15143    }
15144
15145    #[tracing_test::traced_test]
15146    #[tokio::test]
15147    async fn tls_handshake_strict_mtls_identity_carried() {
15148        let ca = gen_test_ca();
15149        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15150        let ca_path = dir.path().join("ca.pem");
15151        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
15152        let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
15153        let client_cert_path = dir.path().join("client-cert.pem");
15154        std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); // allow-unwrap(test)
15155        let client_key_path = dir.path().join("client-key.pem");
15156        std::fs::write(&client_key_path, client_key_pem.as_bytes()).expect("write client key"); // allow-unwrap(test)
15157        let (server_cert, server_key) =
15158            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15159        // The server REQUIRES client certificates verified against the
15160        // same CA that signed the identity.
15161        let (addr, server) = spawn_tls_server(&server_cert, &server_key, Some(&ca.ca_pem)).await;
15162
15163        let config = HttpConfig {
15164            tls: Some(TlsConfig {
15165                enabled: true,
15166                strict: true,
15167                ca_cert_path: Some(ca_path.display().to_string()),
15168                client_cert_path: Some(client_cert_path.display().to_string()),
15169                client_key_path: Some(client_key_path.display().to_string()),
15170                ..Default::default()
15171            }),
15172            ..Default::default()
15173        };
15174        let client = build_under_seam(&config);
15175
15176        let resp = tokio::time::timeout(
15177            Duration::from_secs(15),
15178            client.get(format!("https://{addr}/")).send(),
15179        )
15180        .await
15181        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15182        .expect("strict webpki fallback must carry the mTLS identity into the handshake"); // allow-unwrap(test)
15183        assert_eq!(resp.status().as_u16(), 200);
15184        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15185        assert_eq!(body, "ok");
15186
15187        // Path control beyond the counter: the strict material-carried
15188        // info log fires inside the fallback (scope-filtered
15189        // `logs_contain`, injected by #[traced_test]).
15190        assert!(
15191            logs_contain("carries configured TLS material"),
15192            "strict webpki fallback with configured material must log the carry"
15193        );
15194
15195        server.abort();
15196    }
15197
15198    #[tokio::test]
15199    async fn tls_handshake_strict_mtls_less_client_rejected() {
15200        let ca = gen_test_ca();
15201        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15202        let ca_path = dir.path().join("ca.pem");
15203        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
15204        let (server_cert, server_key) =
15205            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15206        let (addr, server) = spawn_tls_server(&server_cert, &server_key, Some(&ca.ca_pem)).await;
15207
15208        // Same CA-carried strict config but WITHOUT the client
15209        // identity: the handshake must fail server-side.
15210        let config = HttpConfig {
15211            tls: Some(TlsConfig {
15212                enabled: true,
15213                strict: true,
15214                ca_cert_path: Some(ca_path.display().to_string()),
15215                ..Default::default()
15216            }),
15217            ..Default::default()
15218        };
15219        let client = build_under_seam(&config);
15220
15221        let send_result = tokio::time::timeout(
15222            Duration::from_secs(15),
15223            client.get(format!("https://{addr}/")).send(),
15224        )
15225        .await
15226        .expect("request must complete within the harness timeout"); // allow-unwrap(test)
15227        assert!(
15228            send_result.is_err(),
15229            "the server requires client certificates — an identity-less client \
15230             must be rejected at the handshake"
15231        );
15232
15233        server.abort();
15234    }
15235
15236    #[tracing_test::traced_test]
15237    #[tokio::test]
15238    async fn tls_handshake_nonstrict_valid_ca_carried() {
15239        let ca = gen_test_ca();
15240        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15241        let ca_path = dir.path().join("ca.pem");
15242        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
15243        let (server_cert, server_key) =
15244            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15245        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15246
15247        let config = HttpConfig {
15248            tls: Some(TlsConfig {
15249                enabled: true,
15250                strict: false,
15251                ca_cert_path: Some(ca_path.display().to_string()),
15252                ..Default::default()
15253            }),
15254            ..Default::default()
15255        };
15256        let client = build_under_seam(&config);
15257
15258        let resp = tokio::time::timeout(
15259            Duration::from_secs(15),
15260            client.get(format!("https://{addr}/")).send(),
15261        )
15262        .await
15263        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15264        .expect("non-strict webpki fallback must carry the valid configured CA into the handshake"); // allow-unwrap(test)
15265        assert_eq!(resp.status().as_u16(), 200);
15266        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15267        assert_eq!(body, "ok");
15268
15269        // Loadable material never degrades: no per-item warns even
15270        // outside strict mode (spec scenario — valid material carried
15271        // regardless of strict). Scope-filtered `logs_contain`
15272        // (injected by #[traced_test]) so sibling tests' mTLS-degrade
15273        // warns cannot pollute the negations; the counter delta above
15274        // is the positive path control. The platform warn DOES fire
15275        // under the seam — that is the Forced-trigger notice, not a
15276        // material warn; the negations below target the per-item
15277        // material warns only.
15278        assert!(
15279            !logs_contain("client certificate NOT used"),
15280            "no identity was configured — the identity degrade warn must not fire"
15281        );
15282        assert!(
15283            !logs_contain("falling back to bundled Mozilla roots"),
15284            "the CA loaded cleanly — the CA degrade warn must not fire"
15285        );
15286
15287        server.abort();
15288    }
15289
15290    /// Genuine-trigger anchor for the force seam (castrict 1.3-deviation,
15291    /// Option C, weakened variant): strict mTLS identity WITHOUT
15292    /// `ca_cert_path`, verification ON, under the REAL env window — no
15293    /// force seam, and no extra roots exist to rescue the verifier, so
15294    /// the primary genuinely fails. The prescribed 200-ok variant is
15295    /// architecturally unreachable: reqwest's `!certs_verification`
15296    /// branch constructs the NoVerifier verifier and never builds the
15297    /// platform verifier, so a verify-off config cannot genuinely fail
15298    /// the primary on ANY platform; and every verify-on genuine fallback
15299    /// trusts only Mozilla ∪ custom roots, which cannot handshake with a
15300    /// hermetic rcgen CA. Handshake-level identity proof therefore lives
15301    /// in the seam tests, which share all downstream code from
15302    /// `fallback_client_config` onward.
15303    #[tracing_test::traced_test]
15304    #[tokio::test]
15305    async fn tls_handshake_mtls_identity_only_genuine_fallback_anchor() {
15306        let ca = gen_test_ca();
15307        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15308        let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
15309        let client_cert_path = dir.path().join("client-cert.pem");
15310        std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); // allow-unwrap(test)
15311        let client_key_path = dir.path().join("client-key.pem");
15312        std::fs::write(&client_key_path, client_key_pem.as_bytes()).expect("write client key"); // allow-unwrap(test)
15313
15314        let config = HttpConfig {
15315            tls: Some(TlsConfig {
15316                enabled: true,
15317                strict: true,
15318                client_cert_path: Some(client_cert_path.display().to_string()),
15319                client_key_path: Some(client_key_path.display().to_string()),
15320                ..Default::default()
15321            }),
15322            ..Default::default()
15323        };
15324        let (client, fallbacks_taken) = {
15325            let fallbacks_before = build_client_fallback_count();
15326            let client = forced_fallback_env(|| {
15327                build_client(&config, None).expect("client must build") // allow-unwrap(test)
15328            });
15329            (client, build_client_fallback_count() - fallbacks_before)
15330        };
15331        assert_eq!(
15332            fallbacks_taken, 1,
15333            "the empty-store env window must genuinely fail the platform-verifier primary"
15334        );
15335
15336        // The strict identity is carried into the fallback config
15337        // (`with_client_auth_cert` accepted it) — the build is Ok, not
15338        // fail-closed. No handshake is attempted: the fallback client
15339        // trusts only Mozilla roots client-side, which cannot verify the
15340        // hermetic rcgen CA, so a send would fail by design (see the
15341        // comment above).
15342        drop(client);
15343
15344        // Trigger authenticity: the REAL platform warn (not the Forced
15345        // seam notice) fires inside the fallback. Scope-filtered
15346        // `logs_contain` (injected by #[traced_test]) so sibling tests'
15347        // events cannot pollute the assertion.
15348        assert!(
15349            logs_contain("HTTP client build failed on platform TLS roots"),
15350            "the genuine env-window trigger must emit the real platform warn"
15351        );
15352    }
15353
15354    // Task 1.4 (castrict): verification-parity coverage. The two
15355    // danger-mode parity tests prove a verify-off fallback config
15356    // handshakes with a self-signed server the Mozilla/platform roots
15357    // cannot trust; the remaining three prove strict material failures
15358    // still fail closed, non-strict degradation stays item-wise, and a
15359    // verifying fallback rejects the same self-signed server — the
15360    // control showing the parity tests pass BECAUSE of the danger
15361    // verifier, not because the harness skips verification. All five
15362    // build under the FORCE_WEBPKI_FALLBACK seam: danger mode makes
15363    // reqwest construct the NoVerifier branch and never build the
15364    // platform verifier, so the primary cannot genuinely fail for
15365    // verify-off configs on ANY platform (1.3-deviation note in
15366    // openspec/changes/castrict/tasks.md).
15367
15368    /// Self-signed (NOT CA-signed) server leaf: signer unrelated to any
15369    /// configured CA — exactly what the parity scenarios need.
15370    #[tokio::test]
15371    async fn tls_parity_insecure_self_signed_succeeds() {
15372        let (server_cert, server_key) = gen_self_signed_server_cert();
15373        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15374
15375        let config = HttpConfig {
15376            tls: Some(TlsConfig {
15377                enabled: true,
15378                strict: false,
15379                insecure: true,
15380                ..Default::default()
15381            }),
15382            ..Default::default()
15383        };
15384        let client = build_under_seam(&config);
15385
15386        let resp = tokio::time::timeout(
15387            Duration::from_secs(15),
15388            client.get(format!("https://{addr}/")).send(),
15389        )
15390        .await
15391        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15392        .expect("verify-off (insecure) fallback must handshake with the self-signed server"); // allow-unwrap(test)
15393        assert_eq!(resp.status().as_u16(), 200);
15394        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15395        assert_eq!(body, "ok");
15396
15397        server.abort();
15398    }
15399
15400    #[tokio::test]
15401    async fn tls_parity_verify_peer_false_self_signed_succeeds() {
15402        let (server_cert, server_key) = gen_self_signed_server_cert();
15403        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15404
15405        let config = HttpConfig {
15406            tls: Some(TlsConfig {
15407                enabled: true,
15408                strict: false,
15409                verify_peer: false,
15410                ..Default::default()
15411            }),
15412            ..Default::default()
15413        };
15414        let client = build_under_seam(&config);
15415
15416        let resp = tokio::time::timeout(
15417            Duration::from_secs(15),
15418            client.get(format!("https://{addr}/")).send(),
15419        )
15420        .await
15421        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15422        .expect("verify_peer=false fallback must handshake with the self-signed server"); // allow-unwrap(test)
15423        assert_eq!(resp.status().as_u16(), 200);
15424        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15425        assert_eq!(body, "ok");
15426
15427        server.abort();
15428    }
15429
15430    #[tokio::test]
15431    async fn tls_parity_insecure_strict_bad_material_still_fails_closed() {
15432        // No server needed: the build must fail before any connection.
15433        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15434        // Store-rejected fixture: a PEM CERTIFICATE section whose body
15435        // is not base64 — the root store accepts 0 of 1 certs.
15436        let ca_path = dir.path().join("rejected-ca.pem");
15437        std::fs::write(
15438            &ca_path,
15439            b"-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
15440        )
15441        .expect("write rejected ca fixture"); // allow-unwrap(test)
15442
15443        let config = HttpConfig {
15444            tls: Some(TlsConfig {
15445                enabled: true,
15446                strict: true,
15447                insecure: true,
15448                ca_cert_path: Some(ca_path.display().to_string()),
15449                ..Default::default()
15450            }),
15451            ..Default::default()
15452        };
15453        // Raw force-guard + match: build_under_seam expects Ok, and the
15454        // strict material failure must surface as Err before any send —
15455        // verification-disable never masks strict material failure.
15456        let err = match force_webpki_fallback(|| build_client(&config, None)) {
15457            Err(e) => e,
15458            Ok(_) => panic!(
15459                "strict mode must fail closed on store-rejected CA material \
15460                 even with insecure=true"
15461            ),
15462        };
15463        assert_strict_fallback_error(err, &["tls.strict/webpki-fallback"]);
15464    }
15465
15466    #[test]
15467    fn strict_rebuild_failure_returns_typed_error() {
15468        let StrictMaterial {
15469            dir: _dir,
15470            ca_path,
15471            cert_path,
15472            key_path,
15473            ..
15474        } = strict_material_fixtures();
15475
15476        // VALID material throughout: no material error can precede the
15477        // rebuild terminal, so any typed Err must come from the
15478        // strict-fail-closed second-error terminal itself.
15479        let config = HttpConfig {
15480            tls: Some(TlsConfig {
15481                enabled: true,
15482                strict: true,
15483                ca_cert_path: Some(ca_path.display().to_string()),
15484                client_cert_path: Some(cert_path.display().to_string()),
15485                client_key_path: Some(key_path.display().to_string()),
15486                ..Default::default()
15487            }),
15488            ..Default::default()
15489        };
15490        let fallbacks_before = build_client_fallback_count();
15491        let err = match force_webpki_fallback_rebuild_failure(|| build_client(&config, None)) {
15492            Err(e) => e,
15493            Ok(_) => panic!(
15494                "strict mode must fail closed when the webpki fallback client \
15495                 rebuild fails — never the material-free emergency client"
15496            ),
15497        };
15498        assert_eq!(
15499            build_client_fallback_count() - fallbacks_before,
15500            1,
15501            "the rebuild-failure call must route through the webpki fallback exactly once"
15502        );
15503        assert_strict_fallback_error(
15504            err,
15505            &[
15506                "tls.strict/webpki-fallback:",
15507                "webpki fallback client rebuild failed",
15508                "forced webpki fallback rebuild failure",
15509            ],
15510        );
15511    }
15512
15513    #[test]
15514    fn non_strict_rebuild_failure_returns_emergency_client() {
15515        let StrictMaterial {
15516            dir: _dir,
15517            ca_path,
15518            cert_path,
15519            key_path,
15520            ..
15521        } = strict_material_fixtures();
15522
15523        let config = HttpConfig {
15524            tls: Some(TlsConfig {
15525                enabled: true,
15526                strict: false,
15527                ca_cert_path: Some(ca_path.display().to_string()),
15528                client_cert_path: Some(cert_path.display().to_string()),
15529                client_key_path: Some(key_path.display().to_string()),
15530                ..Default::default()
15531            }),
15532            ..Default::default()
15533        };
15534        let fallbacks_before = build_client_fallback_count();
15535        let _client = force_webpki_fallback_rebuild_failure(|| build_client(&config, None))
15536            .expect("non-strict rebuild failure must degrade to the emergency client"); // allow-unwrap(test)
15537        assert_eq!(
15538            build_client_fallback_count() - fallbacks_before,
15539            1,
15540            "the rebuild-failure call must route through the webpki fallback exactly once"
15541        );
15542    }
15543
15544    #[test]
15545    fn strict_rebuild_failure_surfaces_at_endpoint_creation() {
15546        let StrictMaterial {
15547            dir: _dir,
15548            ca_path,
15549            cert_path,
15550            key_path,
15551            ..
15552        } = strict_material_fixtures();
15553
15554        // VALID strict config: no material error can precede the
15555        // rebuild terminal, so any folded Err must come from the
15556        // strict-fail-closed rebuild terminal itself.
15557        let config = HttpConfig {
15558            tls: Some(TlsConfig {
15559                enabled: true,
15560                strict: true,
15561                ca_cert_path: Some(ca_path.display().to_string()),
15562                client_cert_path: Some(cert_path.display().to_string()),
15563                client_key_path: Some(key_path.display().to_string()),
15564                ..Default::default()
15565            }),
15566            ..Default::default()
15567        };
15568
15569        let endpoint_err = force_webpki_fallback_rebuild_failure(|| {
15570            // Construction must complete without panic; the rebuild-
15571            // failure error folds into strict_tls_error and surfaces at
15572            // endpoint creation (mirrors the forced-fallback fold test).
15573            let component = HttpComponent::with_config(config);
15574            component
15575                .create_endpoint("http://h/p", &NoOpComponentContext)
15576                .err()
15577        });
15578
15579        let err = endpoint_err.expect("folded rebuild failure must fail endpoint creation"); // allow-unwrap(test)
15580        assert_strict_fallback_error(
15581            err,
15582            &[
15583                "tls.strict/webpki-fallback:",
15584                "webpki fallback client rebuild failed",
15585            ],
15586        );
15587    }
15588
15589    #[tracing_test::traced_test]
15590    #[tokio::test]
15591    async fn non_strict_rebuild_failure_drops_custom_trust() {
15592        let StrictMaterial {
15593            dir: _dir,
15594            ca,
15595            ca_path,
15596            ..
15597        } = strict_material_fixtures();
15598
15599        // Server certified ONLY by the same CA the config carries: a
15600        // material-carrying fallback build completes this handshake,
15601        // the emergency client cannot.
15602        let (server_cert, server_key) =
15603            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15604        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15605
15606        let config = HttpConfig {
15607            tls: Some(TlsConfig {
15608                enabled: true,
15609                strict: false,
15610                ca_cert_path: Some(ca_path.display().to_string()),
15611                ..Default::default()
15612            }),
15613            ..Default::default()
15614        };
15615        // Arrange phase — build BOTH clients first, each inside its own
15616        // seam guard scope (the guards must not nest); requests run
15617        // AFTER both scopes close, matching the isolation pattern of
15618        // the parity tests.
15619        //
15620        // CONTROL: forced fallback WITHOUT the rebuild-failure seam —
15621        // the normal fallback build honors ca_cert_path, so this client
15622        // carries the CA and verifies the server.
15623        let control = force_webpki_fallback(|| {
15624            build_client(&config, None).expect("control client must build") // allow-unwrap(test)
15625        });
15626        // EMERGENCY: both seams armed — the rebuild-failure
15627        // substitution drops the custom trust, leaving Mozilla roots
15628        // only.
15629        let client = force_webpki_fallback_rebuild_failure(|| {
15630            build_client(&config, None).expect("emergency client must build") // allow-unwrap(test)
15631        });
15632
15633        // Assert phase — same server, and the ONLY delta between the
15634        // two clients is the rebuild-failure substitution: control
15635        // succeeds (positive control), emergency fails (causal trust
15636        // loss, not any incidental network condition).
15637        let control_resp = tokio::time::timeout(
15638            Duration::from_secs(15),
15639            control.get(format!("https://{addr}/")).send(),
15640        )
15641        .await
15642        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15643        .expect("the control client carries the CA — the CA-signed handshake must succeed"); // allow-unwrap(test)
15644        assert_eq!(control_resp.status().as_u16(), 200);
15645
15646        let send_result = tokio::time::timeout(
15647            Duration::from_secs(15),
15648            client.get(format!("https://{addr}/")).send(),
15649        )
15650        .await
15651        .expect("request must complete within the harness timeout"); // allow-unwrap(test)
15652        assert!(
15653            send_result.is_err(),
15654            "the emergency client carries Mozilla roots only — the configured \
15655             CA trust is provably dropped, so the CA-signed handshake must fail"
15656        );
15657
15658        // The rebuild terminal's system-broken error line is unchanged
15659        // by this change. Scope-filtered `logs_contain` (injected by
15660        // #[traced_test]) so sibling tests' events cannot pollute.
15661        assert!(
15662            logs_contain("webpki fallback client build failed — TLS stack broken process-wide"),
15663            "the rebuild-failure terminal must log the system-broken diagnosis"
15664        );
15665
15666        server.abort();
15667    }
15668
15669    #[tracing_test::traced_test]
15670    #[tokio::test]
15671    async fn tls_parity_nonstrict_itemwise_valid_ca_bad_identity() {
15672        let ca = gen_test_ca();
15673        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15674        let ca_path = dir.path().join("ca.pem");
15675        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
15676        let (client_cert_pem, _client_key_pem) = gen_client_identity(&ca);
15677        let client_cert_path = dir.path().join("client-cert.pem");
15678        std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); // allow-unwrap(test)
15679        let (server_cert, server_key) =
15680            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15681        // No client-auth requirement: the identity item's fate is
15682        // client-side only.
15683        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15684
15685        let config = HttpConfig {
15686            tls: Some(TlsConfig {
15687                enabled: true,
15688                strict: false,
15689                ca_cert_path: Some(ca_path.display().to_string()),
15690                client_cert_path: Some(client_cert_path.display().to_string()),
15691                // UNREADABLE key path per the spec scenario: the mTLS
15692                // pair cannot load, so the identity item degrades.
15693                client_key_path: Some("/nonexistent/key.pem".to_string()),
15694                ..Default::default()
15695            }),
15696            ..Default::default()
15697        };
15698        let client = build_under_seam(&config);
15699
15700        let resp = tokio::time::timeout(
15701            Duration::from_secs(15),
15702            client.get(format!("https://{addr}/")).send(),
15703        )
15704        .await
15705        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15706        .expect("the valid CA item must be carried despite the unreadable key"); // allow-unwrap(test)
15707        assert_eq!(resp.status().as_u16(), 200);
15708        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15709        assert_eq!(body, "ok");
15710
15711        // Item-wise degradation: the identity item warned and was
15712        // dropped; the CA item loaded cleanly — no Mozilla-roots
15713        // downgrade. Scope-filtered `logs_contain` (injected by
15714        // #[traced_test]) so sibling tests' events cannot pollute.
15715        assert!(
15716            logs_contain("client certificate NOT used"),
15717            "the unreadable key must degrade the identity item with the mTLS warn"
15718        );
15719        assert!(
15720            !logs_contain("falling back to bundled Mozilla roots"),
15721            "the CA loaded cleanly — the CA degrade warn must not fire"
15722        );
15723
15724        server.abort();
15725    }
15726
15727    #[tokio::test]
15728    async fn tls_parity_verifying_client_rejects_self_signed_without_insecure() {
15729        let (server_cert, server_key) = gen_self_signed_server_cert();
15730        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15731
15732        // Strict, NO material, verification ON: the fallback trusts
15733        // only Mozilla roots, which cannot verify the self-signed
15734        // server — the control proving the two parity tests above pass
15735        // BECAUSE of the danger verifier, not because the harness
15736        // skips verification.
15737        let config = HttpConfig {
15738            tls: Some(TlsConfig {
15739                enabled: true,
15740                strict: true,
15741                ..Default::default()
15742            }),
15743            ..Default::default()
15744        };
15745        let client = build_under_seam(&config);
15746
15747        let send_result = tokio::time::timeout(
15748            Duration::from_secs(15),
15749            client.get(format!("https://{addr}/")).send(),
15750        )
15751        .await
15752        .expect("request must complete within the harness timeout"); // allow-unwrap(test)
15753        assert!(
15754            send_result.is_err(),
15755            "a verifying client must reject the self-signed server at the handshake"
15756        );
15757
15758        server.abort();
15759    }
15760}