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                if let Ok((mut stream, _)) = listener.accept().await {
6399                    let captured = Arc::clone(&captured);
6400                    tokio::spawn(async move {
6401                        let Some(req) = capture_request(&mut stream).await else {
6402                            return;
6403                        };
6404                        captured.lock().unwrap().push(req);
6405
6406                        // 200 OK with Content-Length: 0 and no body, then drop
6407                        // the stream so the client opens a fresh connection.
6408                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
6409                        let _ = stream.write_all(response.as_bytes()).await;
6410                    });
6411                }
6412            }
6413        });
6414
6415        (url, handle, captured_for_return)
6416    }
6417
6418    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
6419    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
6420    /// whose `/final` path answers `200 OK` with an empty body. Every hop
6421    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
6422    /// the connection after responding so each hop is a fresh connection.
6423    async fn start_redirect_capture_server() -> (
6424        String,
6425        tokio::task::JoinHandle<()>,
6426        Arc<Mutex<Vec<CapturedRequest>>>,
6427    ) {
6428        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6429        let addr = listener.local_addr().unwrap();
6430        let url = format!("http://127.0.0.1:{}", addr.port());
6431
6432        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
6433        let captured_for_return = Arc::clone(&captured);
6434
6435        let handle = tokio::spawn(async move {
6436            use tokio::io::AsyncWriteExt;
6437            loop {
6438                if let Ok((mut stream, _)) = listener.accept().await {
6439                    let captured = Arc::clone(&captured);
6440                    tokio::spawn(async move {
6441                        let Some(req) = capture_request(&mut stream).await else {
6442                            return;
6443                        };
6444                        let path = req.path.clone();
6445                        captured.lock().unwrap().push(req);
6446
6447                        let (status_line, location) = match path.as_str() {
6448                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
6449                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
6450                            "/final" => ("HTTP/1.1 200 OK", None),
6451                            _ => ("HTTP/1.1 404 Not Found", None),
6452                        };
6453
6454                        let response = match location {
6455                            // Connection: close stops the client pooling the
6456                            // connection this handler drops right after the
6457                            // response (pooled-race, rc-u3aw class).
6458                            Some(loc) => format!(
6459                                "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
6460                            ),
6461                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
6462                        };
6463                        let _ = stream.write_all(response.as_bytes()).await;
6464                    });
6465                }
6466            }
6467        });
6468
6469        (url, handle, captured_for_return)
6470    }
6471
6472    #[tokio::test]
6473    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
6474        use tower::ServiceExt;
6475
6476        let (url, _handle, captured) = start_capture_server().await;
6477        let ctx = test_producer_ctx();
6478
6479        let component = HttpComponent::with_config(HttpConfig::default());
6480        let endpoint_ctx = NoOpComponentContext;
6481        let endpoint = component
6482            .create_endpoint(
6483                &format!("{url}?httpMethod=GET&allowInternal=true"),
6484                &endpoint_ctx,
6485            )
6486            .unwrap();
6487        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6488
6489        let mut exchange = Exchange::new(Message::default());
6490        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6491
6492        let result = producer.oneshot(exchange).await.unwrap();
6493
6494        let status = result
6495            .input
6496            .header("CamelHttpResponseCode")
6497            .and_then(|v| v.as_u64())
6498            .unwrap();
6499        assert_eq!(status, 200);
6500
6501        let captured = captured.lock().unwrap();
6502        assert_eq!(captured.len(), 1, "expected exactly one captured request");
6503        let req = &captured[0];
6504        assert_eq!(req.method, "GET");
6505        // `httpMethod`/`allowInternal` are URI options, not request-target
6506        // query params, so the origin-form target is just "/".
6507        assert_eq!(req.path, "/");
6508        assert!(req.body.is_empty(), "GET must not carry a body");
6509        assert!(
6510            req.content_length.is_none(),
6511            "suppressed request must not carry Content-Length"
6512        );
6513        assert!(
6514            req.transfer_encoding.is_none(),
6515            "suppressed request must not carry Transfer-Encoding"
6516        );
6517
6518        // The exchange body is consumed by the producer (std::mem::take).
6519        assert!(
6520            result.input.body.is_empty(),
6521            "exchange body must be consumed"
6522        );
6523    }
6524
6525    #[tokio::test]
6526    async fn test_head_with_body_suppressed_via_header() {
6527        use tower::ServiceExt;
6528
6529        let (url, _handle, captured) = start_capture_server().await;
6530        let ctx = test_producer_ctx();
6531
6532        let component = HttpComponent::with_config(HttpConfig::default());
6533        let endpoint_ctx = NoOpComponentContext;
6534        let endpoint = component
6535            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6536            .unwrap();
6537        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6538
6539        let mut exchange = Exchange::new(Message::default());
6540        exchange.input.set_header(
6541            "CamelHttpMethod",
6542            serde_json::Value::String("HEAD".to_string()),
6543        );
6544        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6545
6546        let result = producer.oneshot(exchange).await.unwrap();
6547        let status = result
6548            .input
6549            .header("CamelHttpResponseCode")
6550            .and_then(|v| v.as_u64())
6551            .unwrap();
6552        assert_eq!(status, 200);
6553
6554        let captured = captured.lock().unwrap();
6555        assert_eq!(captured.len(), 1);
6556        let req = &captured[0];
6557        assert_eq!(req.method, "HEAD");
6558        assert!(req.body.is_empty(), "HEAD must not carry a body");
6559    }
6560
6561    #[tokio::test]
6562    async fn test_delete_options_trace_with_body_suppressed() {
6563        use tower::ServiceExt;
6564
6565        let (url, _handle, captured) = start_capture_server().await;
6566        let ctx = test_producer_ctx();
6567        let component = HttpComponent::with_config(HttpConfig::default());
6568        let endpoint_ctx = NoOpComponentContext;
6569
6570        for method in ["DELETE", "OPTIONS", "TRACE"] {
6571            let endpoint = component
6572                .create_endpoint(
6573                    &format!("{url}?httpMethod={method}&allowInternal=true"),
6574                    &endpoint_ctx,
6575                )
6576                .unwrap();
6577            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6578
6579            let mut exchange = Exchange::new(Message::default());
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, "method {method} should succeed");
6589        }
6590
6591        let captured = captured.lock().unwrap();
6592        assert_eq!(captured.len(), 3, "expected three captured requests");
6593        for method in ["DELETE", "OPTIONS", "TRACE"] {
6594            let req = captured
6595                .iter()
6596                .find(|r| r.method == method)
6597                .unwrap_or_else(|| panic!("missing captured request for {method}"));
6598            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
6599        }
6600    }
6601
6602    #[tokio::test]
6603    async fn test_post_put_patch_with_body_still_sent() {
6604        use tower::ServiceExt;
6605
6606        let (url, _handle, captured) = start_capture_server().await;
6607        let ctx = test_producer_ctx();
6608        let component = HttpComponent::with_config(HttpConfig::default());
6609        let endpoint_ctx = NoOpComponentContext;
6610
6611        for method in ["POST", "PUT", "PATCH"] {
6612            let endpoint = component
6613                .create_endpoint(
6614                    &format!("{url}?httpMethod={method}&allowInternal=true"),
6615                    &endpoint_ctx,
6616                )
6617                .unwrap();
6618            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6619
6620            let payload = format!("body-for-{method}");
6621            let mut exchange = Exchange::new(Message::default());
6622            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
6623
6624            let result = producer.oneshot(exchange).await.unwrap();
6625            let status = result
6626                .input
6627                .header("CamelHttpResponseCode")
6628                .and_then(|v| v.as_u64())
6629                .unwrap();
6630            assert_eq!(status, 200, "method {method} should succeed");
6631        }
6632
6633        let captured = captured.lock().unwrap();
6634        assert_eq!(captured.len(), 3, "expected three captured requests");
6635        for method in ["POST", "PUT", "PATCH"] {
6636            let req = captured
6637                .iter()
6638                .find(|r| r.method == method)
6639                .unwrap_or_else(|| panic!("missing captured request for {method}"));
6640            let expected = format!("body-for-{method}");
6641            assert!(!req.body.is_empty(), "{method} must still carry its body");
6642            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
6643        }
6644    }
6645
6646    /// A GET with a stream body must not attach the stream: the entity-enclosing
6647    /// gate drops the stream (mem::take) before the request is built, leaving
6648    /// the exchange body Empty instead of a partially-consumed Body::Stream.
6649    #[tokio::test]
6650    async fn test_stream_body_under_get_not_attached() {
6651        use tower::ServiceExt;
6652
6653        let (url, _handle, captured) = start_capture_server().await;
6654        let ctx = test_producer_ctx();
6655
6656        let component = HttpComponent::with_config(HttpConfig::default());
6657        let endpoint_ctx = NoOpComponentContext;
6658        let endpoint = component
6659            .create_endpoint(
6660                &format!("{url}?httpMethod=GET&allowInternal=true"),
6661                &endpoint_ctx,
6662            )
6663            .unwrap();
6664        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6665
6666        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6667            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
6668        let stream = Box::pin(futures::stream::iter(chunks));
6669        let mut exchange = Exchange::new(Message::default());
6670        exchange.input.body = Body::Stream(StreamBody {
6671            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6672            metadata: StreamMetadata::default(),
6673        });
6674
6675        let result = producer.oneshot(exchange).await.unwrap();
6676
6677        let status = result
6678            .input
6679            .header("CamelHttpResponseCode")
6680            .and_then(|v| v.as_u64())
6681            .unwrap();
6682        assert_eq!(status, 200);
6683
6684        let captured = captured.lock().unwrap();
6685        assert_eq!(captured.len(), 1, "expected exactly one captured request");
6686        assert!(
6687            captured[0].body.is_empty(),
6688            "GET must not carry a stream body"
6689        );
6690        assert!(
6691            captured[0].transfer_encoding.is_none(),
6692            "suppressed request must not carry Transfer-Encoding"
6693        );
6694        assert!(
6695            captured[0].content_length.is_none(),
6696            "suppressed request must not carry Content-Length"
6697        );
6698        assert!(
6699            result.input.body.is_empty(),
6700            "exchange body must be consumed to Empty, not left as a stream"
6701        );
6702    }
6703
6704    /// A suppressed body must never be replayed across 307/308 redirect hops:
6705    /// the gate empties `materialized_body` before the redirect loop runs, so
6706    /// neither the first hop nor the final hop carries the body.
6707    #[tokio::test]
6708    async fn test_redirect_hops_never_replay_suppressed_body() {
6709        use tower::ServiceExt;
6710
6711        let (url, _handle, captured) = start_redirect_capture_server().await;
6712        let ctx = test_producer_ctx();
6713
6714        let component =
6715            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6716        let endpoint_ctx = NoOpComponentContext;
6717
6718        for path in ["/hop307", "/hop308"] {
6719            let endpoint = component
6720                .create_endpoint(
6721                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
6722                    &endpoint_ctx,
6723                )
6724                .unwrap();
6725            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6726
6727            let mut exchange = Exchange::new(Message::default());
6728            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6729
6730            let result = producer.oneshot(exchange).await.unwrap();
6731            let status = result
6732                .input
6733                .header("CamelHttpResponseCode")
6734                .and_then(|v| v.as_u64())
6735                .unwrap();
6736            assert_eq!(
6737                status, 200,
6738                "redirect chain for {path} should end at /final"
6739            );
6740        }
6741
6742        // Two chains (307 and 308), each with two hops (redirect + final).
6743        let captured = captured.lock().unwrap();
6744        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6745        for req in captured.iter() {
6746            assert!(
6747                req.body.is_empty(),
6748                "hop {} {} must not carry a body",
6749                req.method,
6750                req.path
6751            );
6752        }
6753    }
6754
6755    /// The warn! emitted on a suppressed body renders three distinguishable
6756    /// substrings in the log line (tracing-subscriber default field format):
6757    ///   - the message:       "dropping request body ..."
6758    ///   - `method = %method_str`            → `method=GET`
6759    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
6760    /// The closure matches all three so exactly one warn per suppressed
6761    /// request is required (the "HTTP request" debug! also carries
6762    /// `method=GET` and the same `correlation_id=`, but not the message).
6763    #[tracing_test::traced_test]
6764    #[tokio::test]
6765    async fn test_suppressed_body_logs_exactly_one_warn() {
6766        use tower::ServiceExt;
6767
6768        let (url, _handle, _captured) = start_capture_server().await;
6769        let ctx = test_producer_ctx();
6770
6771        let component = HttpComponent::with_config(HttpConfig::default());
6772        let endpoint_ctx = NoOpComponentContext;
6773        let endpoint = component
6774            .create_endpoint(
6775                &format!("{url}?httpMethod=GET&allowInternal=true"),
6776                &endpoint_ctx,
6777            )
6778            .unwrap();
6779        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6780
6781        let mut exchange = Exchange::new(Message::default());
6782        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6783        let correlation_id = exchange.correlation_id().to_string();
6784
6785        let result = producer.oneshot(exchange).await.unwrap();
6786        let status = result
6787            .input
6788            .header("CamelHttpResponseCode")
6789            .and_then(|v| v.as_u64())
6790            .unwrap();
6791        assert_eq!(status, 200);
6792
6793        logs_assert(|lines: &[&str]| {
6794            let hits = lines
6795                .iter()
6796                .filter(|l| {
6797                    l.contains("dropping request body")
6798                        && l.contains("method=GET")
6799                        && l.contains(&format!("correlation_id={correlation_id}"))
6800                })
6801                .count();
6802            match hits {
6803                1 => Ok(()),
6804                n => Err(format!("expected exactly one body-drop warn, found {n}")),
6805            }
6806        });
6807    }
6808
6809    #[tracing_test::traced_test]
6810    #[tokio::test]
6811    async fn test_empty_body_get_emits_no_warn() {
6812        use tower::ServiceExt;
6813
6814        let (url, _handle, _captured) = start_capture_server().await;
6815        let ctx = test_producer_ctx();
6816
6817        let component = HttpComponent::with_config(HttpConfig::default());
6818        let endpoint_ctx = NoOpComponentContext;
6819        let endpoint = component
6820            .create_endpoint(
6821                &format!("{url}?httpMethod=GET&allowInternal=true"),
6822                &endpoint_ctx,
6823            )
6824            .unwrap();
6825        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6826
6827        let exchange = Exchange::new(Message::default());
6828        let result = producer.oneshot(exchange).await.unwrap();
6829        let status = result
6830            .input
6831            .header("CamelHttpResponseCode")
6832            .and_then(|v| v.as_u64())
6833            .unwrap();
6834        assert_eq!(status, 200);
6835
6836        logs_assert(|lines: &[&str]| {
6837            let hits = lines
6838                .iter()
6839                .filter(|l| l.contains("dropping request body"))
6840                .count();
6841            match hits {
6842                0 => Ok(()),
6843                n => Err(format!("expected no body-drop warn, found {n}")),
6844            }
6845        });
6846    }
6847
6848    #[tokio::test]
6849    async fn test_follow_redirects_false_does_not_follow() {
6850        use tower::ServiceExt;
6851
6852        let (url, _handle) = start_redirect_server().await;
6853        let ctx = test_producer_ctx();
6854
6855        let component =
6856            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6857        let endpoint_ctx = NoOpComponentContext;
6858        let endpoint = component
6859            .create_endpoint(
6860                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6861                &endpoint_ctx,
6862            )
6863            .unwrap();
6864        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6865
6866        let exchange = Exchange::new(Message::default());
6867        let result = producer.oneshot(exchange).await.unwrap();
6868
6869        // Should get 302, NOT follow redirect to 200
6870        let status = result
6871            .input
6872            .header("CamelHttpResponseCode")
6873            .and_then(|v| v.as_u64())
6874            .unwrap();
6875        assert_eq!(
6876            status, 302,
6877            "Should NOT follow redirect when followRedirects=false"
6878        );
6879    }
6880
6881    #[tokio::test]
6882    async fn test_follow_redirects_true_follows_redirect() {
6883        use tower::ServiceExt;
6884
6885        let (url, _handle) = start_redirect_server().await;
6886        let ctx = test_producer_ctx();
6887
6888        let component =
6889            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6890        let endpoint_ctx = NoOpComponentContext;
6891        let endpoint = component
6892            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6893            .unwrap();
6894        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6895
6896        let exchange = Exchange::new(Message::default());
6897        let result = producer.oneshot(exchange).await.unwrap();
6898
6899        // Should follow redirect and get 200
6900        let status = result
6901            .input
6902            .header("CamelHttpResponseCode")
6903            .and_then(|v| v.as_u64())
6904            .unwrap();
6905        assert_eq!(
6906            status, 200,
6907            "Should follow redirect when followRedirects=true"
6908        );
6909    }
6910
6911    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
6912    /// This verifies the manual redirect loop executes correctly.
6913    #[tokio::test]
6914    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6915        use tower::ServiceExt;
6916
6917        // Use the existing redirect server which redirects to /final on the same server
6918        let (url, _handle) = start_redirect_server().await;
6919        let ctx = test_producer_ctx();
6920
6921        let component =
6922            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6923        let endpoint_ctx = NoOpComponentContext;
6924        let endpoint = component
6925            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6926            .unwrap();
6927        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6928
6929        let exchange = Exchange::new(Message::default());
6930        let result = producer.oneshot(exchange).await;
6931
6932        // With allowInternal=true, the redirect should succeed
6933        assert!(
6934            result.is_ok(),
6935            "Redirect should succeed with allowInternal=true, got: {:?}",
6936            result
6937        );
6938        let exchange = result.unwrap();
6939        let status = exchange
6940            .input
6941            .header("CamelHttpResponseCode")
6942            .and_then(|v| v.as_u64())
6943            .unwrap();
6944        assert_eq!(status, 200, "Should follow redirect to /final");
6945    }
6946
6947    /// With allowInternal=true, redirects to private IPs should be followed.
6948    #[tokio::test]
6949    async fn test_redirect_to_private_ip_allowed_when_configured() {
6950        use tower::ServiceExt;
6951
6952        // Start a server that redirects to /final on the same server (127.0.0.1)
6953        let (url, _handle) = start_redirect_server().await;
6954        let ctx = test_producer_ctx();
6955
6956        let component =
6957            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6958        let endpoint_ctx = NoOpComponentContext;
6959        let endpoint = component
6960            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6961            .unwrap();
6962        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6963
6964        let exchange = Exchange::new(Message::default());
6965        let result = producer.oneshot(exchange).await.unwrap();
6966
6967        let status = result
6968            .input
6969            .header("CamelHttpResponseCode")
6970            .and_then(|v| v.as_u64())
6971            .unwrap();
6972        assert_eq!(
6973            status, 200,
6974            "Should follow redirect to private IP when allowInternal=true"
6975        );
6976    }
6977
6978    /// Integration test: with allowInternal=false (default), a redirect to a
6979    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
6980    #[tokio::test]
6981    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
6982        use tower::ServiceExt;
6983
6984        // Server that redirects to the AWS metadata endpoint (link-local private IP)
6985        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6986        let addr = listener.local_addr().unwrap();
6987        let url = format!("http://127.0.0.1:{}", addr.port());
6988
6989        let handle = tokio::spawn(async move {
6990            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6991            loop {
6992                if let Ok((mut stream, _)) = listener.accept().await {
6993                    tokio::spawn(async move {
6994                        let mut buf = vec![0u8; 4096];
6995                        let _ = stream.read(&mut buf).await;
6996                        // Always redirect to the metadata endpoint
6997                        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";
6998                        let _ = stream.write_all(response.as_bytes()).await;
6999                    });
7000                }
7001            }
7002        });
7003
7004        let ctx = test_producer_ctx();
7005        let component =
7006            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
7007        let endpoint_ctx = NoOpComponentContext;
7008        // allowInternal=false is the default — do NOT set it
7009        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
7010        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7011
7012        let exchange = Exchange::new(Message::default());
7013        let result = producer.oneshot(exchange).await;
7014
7015        // Must be an error — SSRF guard blocks the redirect target
7016        assert!(
7017            result.is_err(),
7018            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
7019        );
7020        let err = result.unwrap_err().to_string();
7021        assert!(
7022            err.contains("blocked IP")
7023                || err.contains("private IP")
7024                || err.contains("SSRF")
7025                || err.contains("not allowed"),
7026            "Error should mention SSRF/IP blocking, got: {err}"
7027        );
7028
7029        handle.abort();
7030    }
7031
7032    /// Integration test: exceeding maxRedirects produces a clear error.
7033    #[tokio::test]
7034    async fn test_too_many_redirects_returns_error() {
7035        use tower::ServiceExt;
7036
7037        // Server that always redirects to itself (infinite loop)
7038        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7039        let addr = listener.local_addr().unwrap();
7040        let url = format!("http://127.0.0.1:{}", addr.port());
7041
7042        let handle = tokio::spawn(async move {
7043            use tokio::io::{AsyncReadExt, AsyncWriteExt};
7044            loop {
7045                if let Ok((mut stream, _)) = listener.accept().await {
7046                    tokio::spawn(async move {
7047                        let mut buf = vec![0u8; 4096];
7048                        let _ = stream.read(&mut buf).await;
7049                        // Always redirect to /loop
7050                        // Connection: close stops the client pooling the
7051                        // connection the server drops right after this
7052                        // response (pooled-race, rc-u3aw).
7053                        let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
7054                        let _ = stream.write_all(response.as_bytes()).await;
7055                    });
7056                }
7057            }
7058        });
7059
7060        let ctx = test_producer_ctx();
7061        let component =
7062            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
7063        let endpoint_ctx = NoOpComponentContext;
7064        let endpoint = component
7065            .create_endpoint(
7066                &format!("{url}?allowInternal=true&maxRedirects=2"),
7067                &endpoint_ctx,
7068            )
7069            .unwrap();
7070        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7071
7072        let exchange = Exchange::new(Message::default());
7073        let result = producer.oneshot(exchange).await;
7074
7075        // With the fix, exceeding max redirects returns the redirect response
7076        // as-is instead of erroring. The 302 redirect response is returned
7077        // after followRedirects exhausts the allowed redirect count (2).
7078        // Disable throwExceptionOnFailure to inspect the raw response status.
7079        //
7080        // Old behavior: Err("Too many redirects (max 2)")
7081        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
7082        match result {
7083            Err(e) => {
7084                // If throw_exception_on_failure is on, we get HttpOperationFailed
7085                let msg = e.to_string();
7086                assert!(
7087                    msg.contains("HTTP operation failed") || msg.contains("302"),
7088                    "expected redirect-after-exhaustion error, got: {msg}"
7089                );
7090            }
7091            Ok(ex) => {
7092                let response_code = ex
7093                    .input
7094                    .header("CamelHttpResponseCode")
7095                    .and_then(|v| v.as_u64());
7096                assert_eq!(
7097                    response_code,
7098                    Some(302),
7099                    "expected 302 after exhausting redirects"
7100                );
7101            }
7102        }
7103
7104        handle.abort();
7105    }
7106
7107    #[tokio::test]
7108    async fn test_query_params_forwarded_to_http_request() {
7109        use tower::ServiceExt;
7110
7111        let (url, _handle) = start_test_server().await;
7112        let ctx = test_producer_ctx();
7113
7114        let component = HttpComponent::new();
7115        let endpoint_ctx = NoOpComponentContext;
7116        // apiKey is NOT a Camel option, should be forwarded as query param
7117        let endpoint = component
7118            .create_endpoint(
7119                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
7120                &endpoint_ctx,
7121            )
7122            .unwrap();
7123        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7124
7125        let exchange = Exchange::new(Message::default());
7126        let result = producer.oneshot(exchange).await.unwrap();
7127
7128        // The test server returns the request info in response
7129        // We just verify it succeeds (the query param was sent)
7130        let status = result
7131            .input
7132            .header("CamelHttpResponseCode")
7133            .and_then(|v| v.as_u64())
7134            .unwrap();
7135        assert_eq!(status, 200);
7136    }
7137
7138    #[test]
7139    fn test_non_camel_query_params_are_forwarded() {
7140        // Authored pairs ride raw_query (the sole carrier); query_params is
7141        // programmatic-only (http-query-wire-fidelity).
7142        let config = HttpEndpointConfig::from_uri(
7143            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
7144        )
7145        .unwrap();
7146
7147        // apiKey and token are NOT camel-http options: the authored bytes
7148        // (including the interleaved httpMethod) ride raw_query verbatim.
7149        assert_eq!(
7150            config.raw_query.as_deref(),
7151            Some("apiKey=secret123&httpMethod=GET&token=abc456")
7152        );
7153        assert!(config.query_params.is_empty());
7154    }
7155
7156    #[test]
7157    fn test_authored_query_bytes_survive_resolve_url() {
7158        let config =
7159            HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
7160        let exchange = Exchange::new(Message::default());
7161
7162        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7163
7164        // Authored bytes ride verbatim: `%20` stays `%20` (never re-encoded
7165        // to `+` or double-encoded) and `+` stays `+`.
7166        assert!(url.contains("q=hello%20world"), "url was: {url}");
7167        assert!(url.contains("tag=a+b"), "url was: {url}");
7168    }
7169
7170    // -----------------------------------------------------------------------
7171    // Timeout tests (HTTP-004)
7172    // -----------------------------------------------------------------------
7173
7174    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
7175        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7176        let addr = listener.local_addr().unwrap();
7177        let url = format!("http://127.0.0.1:{}", addr.port());
7178
7179        let handle = tokio::spawn(async move {
7180            loop {
7181                if let Ok((mut stream, _)) = listener.accept().await {
7182                    let delay = delay_ms;
7183                    tokio::spawn(async move {
7184                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
7185                        let mut buf = vec![0u8; 4096];
7186                        let _ = stream.read(&mut buf).await;
7187                        // Send headers immediately (no Content-Length → chunked)
7188                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
7189                        let _ = stream.write_all(headers.as_bytes()).await;
7190                        // Delay before sending body chunk
7191                        tokio::time::sleep(Duration::from_millis(delay)).await;
7192                        let body = r#"{"status":"slow"}"#;
7193                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
7194                        let _ = stream.write_all(chunk.as_bytes()).await;
7195                    });
7196                }
7197            }
7198        });
7199
7200        (url, handle)
7201    }
7202
7203    #[tokio::test]
7204    async fn test_http_producer_timeout() {
7205        use tower::ServiceExt;
7206
7207        // Server delays 500ms, client timeout is 100ms → should timeout
7208        let (url, _handle) = start_slow_server(500).await;
7209        let ctx = test_producer_ctx();
7210
7211        let component = HttpComponent::with_config(
7212            HttpConfig::default()
7213                .with_read_timeout_ms(100)
7214                .with_response_timeout_ms(30_000), // generous response timeout
7215        );
7216        let endpoint_ctx = NoOpComponentContext;
7217        let endpoint = component
7218            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
7219            .unwrap();
7220        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7221
7222        let exchange = Exchange::new(Message::default());
7223        let result = producer.oneshot(exchange).await;
7224
7225        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
7226        let err = result.unwrap_err().to_string();
7227        assert!(
7228            err.contains("Read timeout") || err.contains("timeout"),
7229            "Error should mention timeout, got: {}",
7230            err
7231        );
7232    }
7233
7234    #[tokio::test]
7235    async fn test_http_producer_no_timeout_when_fast() {
7236        use tower::ServiceExt;
7237
7238        let (url, _handle) = start_test_server().await;
7239        let ctx = test_producer_ctx();
7240
7241        let component =
7242            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
7243        let endpoint_ctx = NoOpComponentContext;
7244        let endpoint = component
7245            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7246            .unwrap();
7247        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7248
7249        let exchange = Exchange::new(Message::default());
7250        let result = producer.oneshot(exchange).await.unwrap();
7251
7252        let status = result
7253            .input
7254            .header("CamelHttpResponseCode")
7255            .and_then(|v| v.as_u64())
7256            .unwrap();
7257        assert_eq!(status, 200);
7258    }
7259
7260    // -----------------------------------------------------------------------
7261    // SSRF Protection tests
7262    // -----------------------------------------------------------------------
7263
7264    #[tokio::test]
7265    async fn test_http_producer_blocks_metadata_endpoint() {
7266        use tower::ServiceExt;
7267
7268        let ctx = test_producer_ctx();
7269        let component = HttpComponent::new();
7270        let endpoint_ctx = NoOpComponentContext;
7271        let endpoint = component
7272            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
7273            .unwrap();
7274        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7275
7276        let mut exchange = Exchange::new(Message::default());
7277        exchange.input.set_header(
7278            "CamelHttpUri",
7279            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
7280        );
7281
7282        let result = producer.oneshot(exchange).await;
7283        assert!(result.is_err(), "Should block AWS metadata endpoint");
7284
7285        let err = result.unwrap_err();
7286        assert!(
7287            err.to_string().contains("Private IP"),
7288            "Error should mention private IP blocking, got: {}",
7289            err
7290        );
7291    }
7292
7293    #[test]
7294    fn test_ssrf_config_defaults() {
7295        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
7296        assert!(
7297            !config.allow_internal,
7298            "Private IPs should be blocked by default"
7299        );
7300        assert!(
7301            config.blocked_hosts.is_empty(),
7302            "Blocked hosts should be empty by default"
7303        );
7304    }
7305
7306    #[test]
7307    fn test_ssrf_config_allow_internal() {
7308        let config =
7309            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
7310        assert!(
7311            config.allow_internal,
7312            "Private IPs should be allowed when explicitly set"
7313        );
7314    }
7315
7316    #[test]
7317    fn test_uri_option_allow_cleartext_parses() {
7318        let config =
7319            HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=true").unwrap();
7320        assert!(
7321            config.allow_cleartext,
7322            "allowCleartext=true must parse into the endpoint config"
7323        );
7324
7325        let plain = HttpEndpointConfig::from_uri("http://example.com/").unwrap();
7326        assert!(
7327            !plain.allow_cleartext,
7328            "cleartext consent must default to false"
7329        );
7330
7331        let err =
7332            HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=banana").unwrap_err();
7333        assert!(
7334            matches!(&err, CamelError::InvalidUri(msg) if msg.contains("allowCleartext")),
7335            "bad allowCleartext value must yield InvalidUri naming the option, got: {err:?}"
7336        );
7337    }
7338
7339    /// ADR-0081: a CamelHttpUri override to a public cleartext target is
7340    /// gated by the endpoint's `allowCleartext` consent — override URLs go
7341    /// through the same `validate_url_for_ssrf` as the base URL.
7342    #[test]
7343    fn test_camel_http_uri_override_public_cleartext_follows_endpoint_flags() {
7344        let endpoint =
7345            HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=false").unwrap();
7346        let err = crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint)
7347            .expect_err("public cleartext override must be rejected without consent");
7348        assert!(
7349            err.to_string().contains("allowCleartext"),
7350            "error must name the remedy, got: {err}"
7351        );
7352
7353        let endpoint =
7354            HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=true").unwrap();
7355        assert!(
7356            crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint).is_ok(),
7357            "endpoint consent must admit a public cleartext override"
7358        );
7359    }
7360
7361    #[test]
7362    fn test_ssrf_config_blocked_hosts() {
7363        let config = HttpEndpointConfig::from_uri(
7364            "http://example.com/api?blockedHosts=evil.com,malware.net",
7365        )
7366        .unwrap();
7367        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
7368    }
7369
7370    #[tokio::test]
7371    async fn test_http_producer_blocks_localhost() {
7372        use tower::ServiceExt;
7373
7374        let ctx = test_producer_ctx();
7375        let component = HttpComponent::new();
7376        let endpoint_ctx = NoOpComponentContext;
7377        let endpoint = component
7378            .create_endpoint("http://example.com/api", &endpoint_ctx)
7379            .unwrap();
7380        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7381
7382        let mut exchange = Exchange::new(Message::default());
7383        exchange.input.set_header(
7384            "CamelHttpUri",
7385            serde_json::Value::String("http://localhost:8080/internal".to_string()),
7386        );
7387
7388        let result = producer.oneshot(exchange).await;
7389        assert!(result.is_err(), "Should block localhost");
7390    }
7391
7392    #[tokio::test]
7393    async fn test_http_producer_blocks_loopback_ip() {
7394        use tower::ServiceExt;
7395
7396        let ctx = test_producer_ctx();
7397        let component = HttpComponent::new();
7398        let endpoint_ctx = NoOpComponentContext;
7399        let endpoint = component
7400            .create_endpoint("http://example.com/api", &endpoint_ctx)
7401            .unwrap();
7402        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7403
7404        let mut exchange = Exchange::new(Message::default());
7405        exchange.input.set_header(
7406            "CamelHttpUri",
7407            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
7408        );
7409
7410        let result = producer.oneshot(exchange).await;
7411        assert!(result.is_err(), "Should block loopback IP");
7412    }
7413
7414    #[tokio::test]
7415    async fn test_http_producer_allows_private_ip_when_enabled() {
7416        use tower::ServiceExt;
7417
7418        let ctx = test_producer_ctx();
7419        let component = HttpComponent::new();
7420        let endpoint_ctx = NoOpComponentContext;
7421        // With allowInternal=true, the validation should pass
7422        // (actual connection will fail, but that's expected)
7423        let endpoint = component
7424            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
7425            .unwrap();
7426        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7427
7428        let exchange = Exchange::new(Message::default());
7429
7430        // The request will fail because we can't connect, but it should NOT fail
7431        // due to SSRF protection
7432        let result = producer.oneshot(exchange).await;
7433        // We expect connection error, not SSRF error
7434        if let Err(ref e) = result {
7435            let err_str = e.to_string();
7436            assert!(
7437                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
7438                "Should not be SSRF error, got: {}",
7439                err_str
7440            );
7441        }
7442    }
7443
7444    // -----------------------------------------------------------------------
7445    // HttpServerConfig tests
7446    // -----------------------------------------------------------------------
7447
7448    #[test]
7449    fn test_http_server_config_parse() {
7450        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
7451        assert_eq!(cfg.host, "0.0.0.0");
7452        assert_eq!(cfg.port, 8080);
7453        assert_eq!(cfg.path, "/orders");
7454        assert_eq!(cfg.max_inflight_requests, 1024);
7455    }
7456
7457    #[test]
7458    fn test_http_server_config_scheme() {
7459        // UriConfig trait method returns "http" as primary scheme
7460        assert_eq!(HttpServerConfig::scheme(), "http");
7461    }
7462
7463    #[test]
7464    fn test_http_server_config_from_components() {
7465        // Test from_components directly (trait method)
7466        let components = camel_component_api::UriComponents {
7467            scheme: "https".to_string(),
7468            path: "//0.0.0.0:8443/api".to_string(),
7469            params: std::collections::HashMap::from([
7470                ("maxRequestBody".to_string(), "5242880".to_string()),
7471                ("maxInflightRequests".to_string(), "7".to_string()),
7472            ]),
7473            raw_query: None,
7474        };
7475        let cfg = HttpServerConfig::from_components(components).unwrap();
7476        assert_eq!(cfg.host, "0.0.0.0");
7477        assert_eq!(cfg.port, 8443);
7478        assert_eq!(cfg.path, "/api");
7479        assert_eq!(cfg.max_request_body, 5242880);
7480        assert_eq!(cfg.max_inflight_requests, 7);
7481    }
7482
7483    #[test]
7484    fn test_http_server_config_default_path() {
7485        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
7486        assert_eq!(cfg.path, "/");
7487    }
7488
7489    #[test]
7490    fn test_http_server_config_wrong_scheme() {
7491        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
7492    }
7493
7494    #[test]
7495    fn test_http_server_config_invalid_port() {
7496        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
7497    }
7498
7499    #[test]
7500    fn test_http_server_config_default_port_by_scheme() {
7501        // HTTP without explicit port should default to 80
7502        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
7503        assert_eq!(cfg_http.port, 80);
7504
7505        // HTTPS without explicit port should default to 443
7506        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
7507        assert_eq!(cfg_https.port, 443);
7508    }
7509
7510    #[test]
7511    fn test_request_envelope_and_reply_are_send() {
7512        fn assert_send<T: Send>() {}
7513        assert_send::<RequestEnvelope>();
7514        assert_send::<HttpReply>();
7515    }
7516
7517    // -----------------------------------------------------------------------
7518    // ServerRegistry tests
7519    // -----------------------------------------------------------------------
7520
7521    #[test]
7522    fn test_server_registry_global_is_singleton() {
7523        let r1 = ServerRegistry::global();
7524        let r2 = ServerRegistry::global();
7525        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
7526    }
7527
7528    #[allow(clippy::await_holding_lock)]
7529    #[tokio::test]
7530    async fn test_concurrent_get_or_spawn_returns_same_registry() {
7531        let _guard = lock_registry_test_mutex();
7532        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7533        let port = listener.local_addr().unwrap().port();
7534        drop(listener);
7535
7536        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
7537            Arc::new(std::sync::Mutex::new(Vec::new()));
7538
7539        let mut handles = Vec::new();
7540        for _ in 0..4 {
7541            let results = results.clone();
7542            handles.push(tokio::spawn(async move {
7543                let registry = ServerRegistry::global()
7544                    .get_or_spawn(
7545                        "127.0.0.1",
7546                        port,
7547                        2 * 1024 * 1024,
7548                        10 * 1024 * 1024,
7549                        1024,
7550                        test_rt(),
7551                        "test-route".into(),
7552                        None,
7553                    )
7554                    .await
7555                    .unwrap();
7556                results.lock().unwrap().push(registry);
7557            }));
7558        }
7559
7560        for h in handles {
7561            h.await.unwrap();
7562        }
7563
7564        let registries = results.lock().unwrap();
7565        assert_eq!(registries.len(), 4);
7566        for i in 1..registries.len() {
7567            assert!(
7568                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
7569                "all concurrent callers should get same route registry"
7570            );
7571        }
7572    }
7573
7574    #[test]
7575    fn test_server_registry_distinguishes_host_and_port() {
7576        let _guard = lock_registry_test_mutex();
7577        let rt = tokio::runtime::Runtime::new().expect("runtime");
7578        rt.block_on(async {
7579            let registry = ServerRegistry::global();
7580            // Use two distinct host values with same configured port key.
7581            // Port 0 is acceptable here because the registry key uses the configured
7582            // tuple, not the OS-assigned ephemeral port.
7583            let d1 = registry
7584                .get_or_spawn(
7585                    "127.0.0.1",
7586                    0,
7587                    1024 * 1024,
7588                    10 * 1024 * 1024,
7589                    1024,
7590                    test_rt(),
7591                    "test-route-1".into(),
7592                    None,
7593                )
7594                .await;
7595            let d2 = registry
7596                .get_or_spawn(
7597                    "0.0.0.0",
7598                    0,
7599                    1024 * 1024,
7600                    10 * 1024 * 1024,
7601                    1024,
7602                    test_rt(),
7603                    "test-route-2".into(),
7604                    None,
7605                )
7606                .await;
7607            assert!(d1.is_ok());
7608            assert!(d2.is_ok());
7609            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
7610        });
7611    }
7612
7613    #[allow(clippy::await_holding_lock)]
7614    #[tokio::test]
7615    async fn test_shared_server_max_request_body_policy_is_deterministic() {
7616        let _guard = lock_registry_test_mutex();
7617        let registry = ServerRegistry::global();
7618        // First registration: maxRequestBody = 1 MB
7619        let d1 = registry
7620            .get_or_spawn(
7621                "127.0.0.1",
7622                9991,
7623                1024 * 1024,
7624                10 * 1024 * 1024,
7625                1024,
7626                test_rt(),
7627                "test-route".into(),
7628                None,
7629            )
7630            .await;
7631        assert!(d1.is_ok());
7632
7633        // Second registration on same (host,port): maxRequestBody = 2 MB
7634        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
7635        let d2 = registry
7636            .get_or_spawn(
7637                "127.0.0.1",
7638                9991,
7639                2 * 1024 * 1024,
7640                10 * 1024 * 1024,
7641                1024,
7642                test_rt(),
7643                "test-route-2".into(),
7644                None,
7645            )
7646            .await;
7647        assert!(d2.is_err());
7648        let err = d2.unwrap_err();
7649        assert!(
7650            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
7651            "Expected incompatible maxRequestBody error, got: {}",
7652            err
7653        );
7654    }
7655
7656    #[test]
7657    fn test_server_registry_reset_clears_entries() {
7658        let _guard = lock_registry_test_mutex();
7659        let rt = tokio::runtime::Runtime::new().expect("runtime");
7660        rt.block_on(async {
7661            // Register something on a unique port
7662            let d1 = ServerRegistry::global()
7663                .get_or_spawn(
7664                    "127.0.0.1",
7665                    9992,
7666                    1024 * 1024,
7667                    10 * 1024 * 1024,
7668                    1024,
7669                    test_rt(),
7670                    "test-route".into(),
7671                    None,
7672                )
7673                .await;
7674            assert!(d1.is_ok());
7675
7676            // Verify entry exists
7677            let guard = ServerRegistry::global().inner.lock().expect("lock");
7678            assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
7679            drop(guard);
7680
7681            // Reset
7682            ServerRegistry::reset();
7683
7684            // Verify cleared
7685            let guard = ServerRegistry::global().inner.lock().expect("lock");
7686            assert!(
7687                guard.entries.is_empty(),
7688                "registry should be empty after reset, has {} entries",
7689                guard.entries.len()
7690            );
7691        });
7692    }
7693
7694    #[allow(clippy::await_holding_lock)]
7695    #[tokio::test]
7696    async fn registry_rejects_tls_on_plain_port() {
7697        // httpflake: this reset previously ran WITHOUT the registry test
7698        // mutex, so it could wipe another test's freshly staged entry
7699        // mid-window (traced 2026-09-14) — spec law: every reset caller
7700        // holds REGISTRY_TEST_MUTEX.
7701        let _guard = lock_registry_test_mutex();
7702        ServerRegistry::reset();
7703        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7704
7705        // First route: plain HTTP
7706        let _r1 = ServerRegistry::global()
7707            .get_or_spawn(
7708                "127.0.0.1",
7709                0,
7710                1024,
7711                1024,
7712                16,
7713                Arc::clone(&rt),
7714                "route-1".into(),
7715                None, // plain
7716            )
7717            .await;
7718
7719        // Second route: TLS on same port → must fail
7720        let result = ServerRegistry::global()
7721            .get_or_spawn(
7722                "127.0.0.1",
7723                0,
7724                1024,
7725                1024,
7726                16,
7727                Arc::clone(&rt),
7728                "route-2".into(),
7729                Some(crate::config::ServerTlsConfig {
7730                    cert_path: "/x.pem".into(),
7731                    key_path: "/y.pem".into(),
7732                }),
7733            )
7734            .await;
7735        assert!(result.is_err(), "must reject TLS on plain port");
7736    }
7737
7738    // -----------------------------------------------------------------------
7739    // D-L10: HTTP server is process-lifetime — it survives consumer
7740    // unregister (no refcount; dead servers are evicted on next spawn)
7741    // -----------------------------------------------------------------------
7742
7743    #[allow(clippy::await_holding_lock)]
7744    #[tokio::test]
7745    async fn test_unregister_last_http_route_keeps_server_alive() {
7746        let _guard = lock_registry_test_mutex();
7747        ServerRegistry::reset();
7748        let registry = ServerRegistry::global();
7749
7750        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7751        let port = listener.local_addr().unwrap().port();
7752        drop(listener); // Release — ServerRegistry will rebind
7753        let rt = test_rt();
7754
7755        // Register 2 routes on the same (host, port) — OnceCell returns the
7756        // same ServerHandle.
7757        let _r1 = registry
7758            .get_or_spawn(
7759                "127.0.0.1",
7760                port,
7761                1024 * 1024,
7762                10 * 1024 * 1024,
7763                16,
7764                rt.clone(),
7765                "test-route-1".into(),
7766                None,
7767            )
7768            .await
7769            .unwrap();
7770        let _r2 = registry
7771            .get_or_spawn(
7772                "127.0.0.1",
7773                port,
7774                1024 * 1024,
7775                10 * 1024 * 1024,
7776                16,
7777                rt,
7778                "test-route-2".into(),
7779                None,
7780            )
7781            .await
7782            .unwrap();
7783
7784        let key = ("127.0.0.1".to_string(), port);
7785        let cell = {
7786            let guard = registry.inner.lock().expect("lock");
7787            guard.entries.get(&key).expect("entry should exist").clone()
7788        };
7789
7790        // Unregister first route -> monitor still alive (count = 1).
7791        registry.unregister("127.0.0.1", port).await;
7792        {
7793            let handle = cell
7794                .get()
7795                .expect("handle should still exist after first unregister");
7796            assert!(
7797                !handle.monitor_task.is_finished(),
7798                "monitor task should still be alive after first unregister"
7799            );
7800        }
7801
7802        // Unregister second route -> server stays alive (process-lifetime).
7803        registry.unregister("127.0.0.1", port).await;
7804        tokio::time::sleep(Duration::from_millis(20)).await;
7805        {
7806            let handle = cell
7807                .get()
7808                .expect("handle should still exist after last unregister");
7809            assert!(
7810                !handle.monitor_task.is_finished(),
7811                "monitor task should still be alive — server is process-lifetime"
7812            );
7813        }
7814
7815        // Entry stays in registry for potential restart.
7816        {
7817            let guard = registry.inner.lock().expect("lock");
7818            assert!(
7819                guard.entries.contains_key(&key),
7820                "entry should remain in registry — server kept alive for restart"
7821            );
7822        }
7823    }
7824
7825    // -----------------------------------------------------------------------
7826    // Staged listeners (itest-bound-ports Task 1)
7827    // -----------------------------------------------------------------------
7828
7829    /// CLONE-FIXTURE: bind a std listener on `127.0.0.1:0`, retain a blocking
7830    /// std clone (`probe`) so the port stays reserved, and hand the original
7831    /// socket to tokio as a non-blocking listener. `tokio::net::TcpListener`
7832    /// has no `try_clone`, so clones come from the std handle.
7833    async fn clone_fixture_listener() -> (
7834        tokio::net::TcpListener,
7835        std::net::TcpListener,
7836        std::net::SocketAddr,
7837    ) {
7838        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7839        let probe = l.try_clone().expect("clone probe");
7840        l.set_nonblocking(true).expect("set_nonblocking");
7841        let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7842        let addr = listener.local_addr().expect("local_addr");
7843        (listener, probe, addr)
7844    }
7845
7846    /// Default-limit constants the existing registry tests in this file use.
7847    fn staged_limits() -> (usize, usize, usize) {
7848        (1024 * 1024, 10 * 1024 * 1024, 1024)
7849    }
7850
7851    #[allow(clippy::await_holding_lock)]
7852    #[tokio::test]
7853    async fn staged_listener_first_spawn_serves_without_second_bind() {
7854        let _guard = lock_registry_test_mutex();
7855        ServerRegistry::reset();
7856        let registry = ServerRegistry::global();
7857        let (listener, _probe, addr) = clone_fixture_listener().await;
7858        let port = addr.port();
7859        registry
7860            .stage_listener(listener)
7861            .await
7862            .expect("stage listener");
7863
7864        let (max_req, max_res, max_inflight) = staged_limits();
7865        let routes = registry
7866            .get_or_spawn(
7867                "127.0.0.1",
7868                port,
7869                max_req,
7870                max_res,
7871                max_inflight,
7872                test_rt(),
7873                "staged-first-spawn".into(),
7874                None,
7875            )
7876            .await
7877            .expect("spawn from staged listener must succeed");
7878
7879        assert_eq!(
7880            registry.bound_addr("127.0.0.1", port),
7881            Some(addr),
7882            "served socket must be the staged listener's addr"
7883        );
7884        // The probe clone shares the socket, so service is proven by an HTTP
7885        // response, not by accepting on the probe.
7886        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7887            .await
7888            .expect("http request against staged listener must connect");
7889        assert!(
7890            resp.status().as_u16() >= 200,
7891            "any status proves the staged socket serves"
7892        );
7893        drop(routes);
7894    }
7895
7896    #[allow(clippy::await_holding_lock)]
7897    #[tokio::test]
7898    async fn staged_entry_reused_by_second_caller() {
7899        let _guard = lock_registry_test_mutex();
7900        ServerRegistry::reset();
7901        let registry = ServerRegistry::global();
7902        let (listener, _probe, addr) = clone_fixture_listener().await;
7903        let port = addr.port();
7904        registry
7905            .stage_listener(listener)
7906            .await
7907            .expect("stage listener");
7908
7909        let (max_req, max_res, max_inflight) = staged_limits();
7910        let first = registry
7911            .get_or_spawn(
7912                "127.0.0.1",
7913                port,
7914                max_req,
7915                max_res,
7916                max_inflight,
7917                test_rt(),
7918                "staged-reuse-1".into(),
7919                None,
7920            )
7921            .await
7922            .expect("first spawn from staged listener");
7923        let second = registry
7924            .get_or_spawn(
7925                "127.0.0.1",
7926                port,
7927                max_req,
7928                max_res,
7929                max_inflight,
7930                test_rt(),
7931                "staged-reuse-2".into(),
7932                None,
7933            )
7934            .await
7935            .expect("second caller must reuse the entry");
7936        assert_eq!(
7937            registry.bound_addr("127.0.0.1", port),
7938            Some(addr),
7939            "entry reused — bound addr unchanged, no second bind"
7940        );
7941        drop(first);
7942        drop(second);
7943    }
7944
7945    #[allow(clippy::await_holding_lock)]
7946    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7947    async fn staged_race_two_callers_single_resolver() {
7948        let _guard = lock_registry_test_mutex();
7949        ServerRegistry::reset();
7950        let registry = ServerRegistry::global();
7951        let (listener, _probe, addr) = clone_fixture_listener().await;
7952        let port = addr.port();
7953        registry
7954            .stage_listener(listener)
7955            .await
7956            .expect("stage listener");
7957
7958        // Two racing callers for the exact staged key: the staged listener
7959        // must be consumed by the single cell-init winner and served to
7960        // both — never leave the winner binding a port the loser still
7961        // holds (EADDRINUSE).
7962        let (max_req, max_res, max_inflight) = staged_limits();
7963        let (first, second) = tokio::join!(
7964            registry.get_or_spawn(
7965                "127.0.0.1",
7966                port,
7967                max_req,
7968                max_res,
7969                max_inflight,
7970                test_rt(),
7971                "staged-race-1".into(),
7972                None,
7973            ),
7974            registry.get_or_spawn(
7975                "127.0.0.1",
7976                port,
7977                max_req,
7978                max_res,
7979                max_inflight,
7980                test_rt(),
7981                "staged-race-2".into(),
7982                None,
7983            ),
7984        );
7985        let first = first.expect("first racing caller must succeed");
7986        let second = second.expect("second racing caller must succeed");
7987        assert_eq!(
7988            registry.bound_addr("127.0.0.1", port),
7989            Some(addr),
7990            "single entry must be served from the staged socket — no EADDRINUSE path"
7991        );
7992        drop(first);
7993        drop(second);
7994    }
7995
7996    #[allow(clippy::await_holding_lock)]
7997    #[tokio::test]
7998    async fn unstaged_spawn_binds_legacy() {
7999        let _guard = lock_registry_test_mutex();
8000        ServerRegistry::reset();
8001        let registry = ServerRegistry::global();
8002        // Fresh port P2: reserve then release — the legacy path rebinds.
8003        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
8004        let port = probe.local_addr().expect("local addr").port();
8005        drop(probe);
8006
8007        let (max_req, max_res, max_inflight) = staged_limits();
8008        registry
8009            .get_or_spawn(
8010                "127.0.0.1",
8011                port,
8012                max_req,
8013                max_res,
8014                max_inflight,
8015                test_rt(),
8016                "legacy-bind".into(),
8017                None,
8018            )
8019            .await
8020            .expect("legacy bind spawn");
8021        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
8022            .await
8023            .expect("connect to freshly bound port must succeed");
8024        assert!(resp.status().as_u16() >= 200);
8025        assert_eq!(
8026            registry.bound_addr("127.0.0.1", port),
8027            Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
8028            "bound addr must be the legacy bound (host, port)"
8029        );
8030    }
8031
8032    #[allow(clippy::await_holding_lock)]
8033    #[tokio::test]
8034    async fn wrong_host_staged_port_fails_deterministically() {
8035        let _guard = lock_registry_test_mutex();
8036        ServerRegistry::reset();
8037        let registry = ServerRegistry::global();
8038        let (listener, _probe, addr) = clone_fixture_listener().await;
8039        let port = addr.port();
8040        registry
8041            .stage_listener(listener)
8042            .await
8043            .expect("stage listener under 127.0.0.1");
8044
8045        let (max_req, max_res, max_inflight) = staged_limits();
8046        let err = registry
8047            .get_or_spawn(
8048                "localhost",
8049                port,
8050                max_req,
8051                max_res,
8052                max_inflight,
8053                test_rt(),
8054                "conflict-probe".into(),
8055                None,
8056            )
8057            .await
8058            .expect_err("wrong host on staged port must fail deterministically");
8059        assert!(
8060            err.to_string().contains("staged listener conflict on port"),
8061            "unexpected error: {err}"
8062        );
8063
8064        // Slot untouched by the failed call: the correct host now consumes it.
8065        registry
8066            .get_or_spawn(
8067                "127.0.0.1",
8068                port,
8069                max_req,
8070                max_res,
8071                max_inflight,
8072                test_rt(),
8073                "conflict-after".into(),
8074                None,
8075            )
8076            .await
8077            .expect("correct host must serve the staged listener");
8078        assert_eq!(
8079            registry.bound_addr("127.0.0.1", port),
8080            Some(addr),
8081            "staged slot must be untouched by the conflicting call"
8082        );
8083    }
8084
8085    #[allow(clippy::await_holding_lock)]
8086    #[tokio::test]
8087    async fn duplicate_stage_same_key_rejected() {
8088        let _guard = lock_registry_test_mutex();
8089        ServerRegistry::reset();
8090        let registry = ServerRegistry::global();
8091        let (listener, probe, addr) = clone_fixture_listener().await;
8092        registry
8093            .stage_listener(listener)
8094            .await
8095            .expect("stage listener A");
8096
8097        // Second tokio handle to the SAME socket: clone the std probe handle.
8098        let dup = probe.try_clone().expect("clone2");
8099        dup.set_nonblocking(true).expect("set_nonblocking2");
8100        let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
8101
8102        let err = registry
8103            .stage_listener(b)
8104            .await
8105            .expect_err("duplicate stage must be rejected");
8106        assert!(
8107            err.to_string().contains("listener already staged"),
8108            "unexpected error: {err}"
8109        );
8110
8111        let (max_req, max_res, max_inflight) = staged_limits();
8112        registry
8113            .get_or_spawn(
8114                "127.0.0.1",
8115                addr.port(),
8116                max_req,
8117                max_res,
8118                max_inflight,
8119                test_rt(),
8120                "dup-stage-after".into(),
8121                None,
8122            )
8123            .await
8124            .expect("spawn from first staged listener");
8125        assert_eq!(
8126            registry.bound_addr("127.0.0.1", addr.port()),
8127            Some(addr),
8128            "first staged listener retained"
8129        );
8130    }
8131
8132    #[allow(clippy::await_holding_lock)]
8133    #[tokio::test]
8134    async fn distinct_keys_stage_independently() {
8135        let _guard = lock_registry_test_mutex();
8136        ServerRegistry::reset();
8137        let registry = ServerRegistry::global();
8138        let (l1, _p1, addr1) = clone_fixture_listener().await;
8139        let (l2, _p2, addr2) = clone_fixture_listener().await;
8140        registry.stage_listener(l1).await.expect("stage P1");
8141        registry.stage_listener(l2).await.expect("stage P2");
8142
8143        let (max_req, max_res, max_inflight) = staged_limits();
8144        registry
8145            .get_or_spawn(
8146                "127.0.0.1",
8147                addr1.port(),
8148                max_req,
8149                max_res,
8150                max_inflight,
8151                test_rt(),
8152                "distinct-1".into(),
8153                None,
8154            )
8155            .await
8156            .expect("spawn P1");
8157        registry
8158            .get_or_spawn(
8159                "127.0.0.1",
8160                addr2.port(),
8161                max_req,
8162                max_res,
8163                max_inflight,
8164                test_rt(),
8165                "distinct-2".into(),
8166                None,
8167            )
8168            .await
8169            .expect("spawn P2");
8170        assert_eq!(
8171            registry.bound_addr("127.0.0.1", addr1.port()),
8172            Some(addr1),
8173            "P1 bound addr must be its own listener"
8174        );
8175        assert_eq!(
8176            registry.bound_addr("127.0.0.1", addr2.port()),
8177            Some(addr2),
8178            "P2 bound addr must be its own listener"
8179        );
8180        let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
8181            .await
8182            .expect("connect P1");
8183        assert!(r1.status().as_u16() >= 200);
8184        let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
8185            .await
8186            .expect("connect P2");
8187        assert!(r2.status().as_u16() >= 200);
8188    }
8189
8190    #[allow(clippy::await_holding_lock)]
8191    #[tokio::test]
8192    async fn tls_prebound_listener_served() {
8193        use camel_component_api::test_support::tls;
8194
8195        // Install rustls crypto provider (aws-lc-rs — matches the existing
8196        // TLS registry tests).
8197        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
8198
8199        let _guard = lock_registry_test_mutex();
8200        ServerRegistry::reset();
8201        let registry = ServerRegistry::global();
8202        let (listener, _probe, addr) = clone_fixture_listener().await;
8203        let port = addr.port();
8204
8205        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
8206        let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
8207        let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
8208        let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
8209
8210        let (max_req, max_res, max_inflight) = staged_limits();
8211        let routes = registry
8212            .get_or_spawn_with_listener(
8213                listener,
8214                max_req,
8215                max_res,
8216                max_inflight,
8217                test_rt(),
8218                "staged-tls".into(),
8219                Some(crate::config::ServerTlsConfig {
8220                    cert_path: cert_path.to_string_lossy().into_owned(),
8221                    key_path: key_path.to_string_lossy().into_owned(),
8222                }),
8223            )
8224            .await
8225            .expect("spawn TLS server from pre-bound listener");
8226
8227        // Client with CA cert — REAL verification (no danger_accept_invalid),
8228        // same helper pattern as the existing TLS registry tests.
8229        let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
8230        let client = reqwest::Client::builder()
8231            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
8232            .build()
8233            .expect("build tls client");
8234
8235        let resp = client
8236            .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
8237            .send()
8238            .await
8239            .expect("TLS handshake + request must succeed");
8240        assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
8241        assert_eq!(
8242            registry.bound_addr("127.0.0.1", port),
8243            Some(addr),
8244            "bound addr equals the pre-bound listener addr"
8245        );
8246        drop(routes);
8247    }
8248
8249    #[allow(clippy::await_holding_lock)]
8250    #[tokio::test]
8251    async fn with_listener_direct_spawn_keyed_by_actual_addr() {
8252        let _guard = lock_registry_test_mutex();
8253        ServerRegistry::reset();
8254        let registry = ServerRegistry::global();
8255        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
8256            .await
8257            .expect("bind un-staged listener");
8258        let addr = listener.local_addr().expect("local addr");
8259        let port = addr.port();
8260
8261        let (max_req, max_res, max_inflight) = staged_limits();
8262        registry
8263            .get_or_spawn_with_listener(
8264                listener,
8265                max_req,
8266                max_res,
8267                max_inflight,
8268                test_rt(),
8269                "with-listener".into(),
8270                None,
8271            )
8272            .await
8273            .expect("direct spawn from un-staged listener");
8274        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
8275            .await
8276            .expect("connect on actual port");
8277        assert!(resp.status().as_u16() >= 200);
8278        assert_eq!(
8279            registry.bound_addr("127.0.0.1", port),
8280            Some(addr),
8281            "registry key is the listener's actual port"
8282        );
8283
8284        registry
8285            .get_or_spawn(
8286                "127.0.0.1",
8287                port,
8288                max_req,
8289                max_res,
8290                max_inflight,
8291                test_rt(),
8292                "with-listener-reuse".into(),
8293                None,
8294            )
8295            .await
8296            .expect("legacy caller must reuse the entry");
8297        assert_eq!(
8298            registry.bound_addr("127.0.0.1", port),
8299            Some(addr),
8300            "entry reused — no second bind"
8301        );
8302    }
8303
8304    // -----------------------------------------------------------------------
8305    // Axum dispatch handler tests
8306    // -----------------------------------------------------------------------
8307
8308    #[tokio::test]
8309    async fn test_dispatch_handler_returns_404_for_unknown_path() {
8310        let registry = HttpRouteRegistry::new();
8311        // Nothing registered in route registry
8312        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8313        let port = listener.local_addr().unwrap().port();
8314        tokio::spawn(run_axum_server(
8315            listener,
8316            registry,
8317            2 * 1024 * 1024,
8318            10 * 1024 * 1024,
8319            Arc::new(tokio::sync::Semaphore::new(1024)),
8320            test_rt(),
8321            "test-route".into(),
8322        ));
8323
8324        // Wait for server to start
8325        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8326
8327        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
8328            .await
8329            .unwrap();
8330        assert_eq!(resp.status().as_u16(), 404);
8331    }
8332
8333    // -----------------------------------------------------------------------
8334    // HttpConsumer tests
8335    // -----------------------------------------------------------------------
8336
8337    #[tokio::test]
8338    async fn test_http_consumer_start_registers_path() {
8339        use camel_component_api::ConsumerContext;
8340
8341        // Get an OS-assigned free port
8342        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8343        let port = listener.local_addr().unwrap().port();
8344        drop(listener); // Release port — ServerRegistry will rebind it
8345
8346        let consumer_cfg = HttpServerConfig {
8347            scheme: "http".to_string(),
8348            host: "127.0.0.1".to_string(),
8349            port,
8350            path: "/ping".to_string(),
8351            max_request_body: 2 * 1024 * 1024,
8352            max_response_body: 10 * 1024 * 1024,
8353            max_inflight_requests: 1024,
8354            method: None,
8355            tls_config: None,
8356        };
8357        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8358
8359        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8360        let token = tokio_util::sync::CancellationToken::new();
8361        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8362
8363        tokio::spawn(async move {
8364            consumer.start(ctx).await.unwrap();
8365        });
8366
8367        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8368
8369        let client = plain_http_test_client();
8370        let resp_future = client
8371            .post(format!("http://127.0.0.1:{port}/ping"))
8372            .body("hello world")
8373            .send();
8374
8375        let (http_result, _) = tokio::join!(resp_future, async {
8376            if let Some(mut envelope) = rx.recv().await {
8377                // Set a custom status code
8378                envelope.exchange.input.set_header(
8379                    "CamelHttpResponseCode",
8380                    serde_json::Value::Number(201.into()),
8381                );
8382                if let Some(reply_tx) = envelope.reply_tx {
8383                    let _ = reply_tx.send(Ok(envelope.exchange));
8384                }
8385            }
8386        });
8387
8388        let resp = http_result.unwrap();
8389        assert_eq!(resp.status().as_u16(), 201);
8390
8391        token.cancel();
8392    }
8393
8394    /// rc-nftni (drainclaim): the raw-sender dispatch path mints a claim at
8395    /// the acceptance dequeue and carries it on the envelope. Exact totals:
8396    /// 1 while the envelope is held, 0 after release. No wall-clock sleeps —
8397    /// readiness is the startup signal, the recv IS the barrier.
8398    #[tokio::test]
8399    async fn http_consumer_raw_dispatch_carries_in_flight_claim() {
8400        use std::sync::atomic::{AtomicU64, Ordering};
8401
8402        use camel_component_api::ConsumerContext;
8403        use camel_component_api::StartupSignal;
8404
8405        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8406        let port = listener.local_addr().unwrap().port();
8407        drop(listener);
8408
8409        let consumer_cfg = HttpServerConfig {
8410            scheme: "http".to_string(),
8411            host: "127.0.0.1".to_string(),
8412            port,
8413            path: "/claim".to_string(),
8414            max_request_body: 2 * 1024 * 1024,
8415            max_response_body: 10 * 1024 * 1024,
8416            max_inflight_requests: 1024,
8417            method: None,
8418            tls_config: None,
8419        };
8420        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8421
8422        let counter = std::sync::Arc::new(AtomicU64::new(0));
8423        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8424        let token = tokio_util::sync::CancellationToken::new();
8425        let (signal, startup_rx) = StartupSignal::pair();
8426        let ctx = ConsumerContext::new(tx, token.clone(), "http-claim-route".to_string())
8427            .with_startup(signal)
8428            .with_in_flight_counter(std::sync::Arc::clone(&counter));
8429
8430        tokio::spawn(async move {
8431            consumer.start(ctx).await.unwrap();
8432        });
8433
8434        // Deterministic readiness: the consumer marks ready only after the
8435        // listener is bound and the path registered.
8436        tokio::time::timeout(std::time::Duration::from_secs(5), startup_rx.await_ready())
8437            .await
8438            .expect("startup must resolve within 5s")
8439            .expect("startup must be Ok");
8440
8441        let client = plain_http_test_client();
8442        let (http_result, claim) = tokio::join!(
8443            client
8444                .post(format!("http://127.0.0.1:{port}/claim"))
8445                .body("hello")
8446                .send(),
8447            async {
8448                let mut envelope = rx.recv().await.expect("envelope must arrive");
8449                let claim = envelope
8450                    .in_flight_claim
8451                    .take()
8452                    .expect("raw dispatch must carry an acceptance-minted claim");
8453                assert_eq!(
8454                    counter.load(Ordering::Acquire),
8455                    1,
8456                    "exact total: the acceptance mint is the only live claim"
8457                );
8458                // Materialized reply body: echoing the request's Stream body
8459                // back would tie the response to the request-body stream
8460                // (not what this test exercises).
8461                let reply_tx = envelope.reply_tx.take().expect("reply channel must be set");
8462                let reply_exchange = Exchange::new(camel_component_api::Message::new(
8463                    camel_component_api::Body::Text("done".to_string()),
8464                ));
8465                reply_tx
8466                    .send(Ok(reply_exchange))
8467                    .expect("reply must be taken");
8468                claim
8469            },
8470        );
8471
8472        let resp = http_result.expect("http roundtrip must complete");
8473        assert_eq!(resp.status().as_u16(), 200);
8474
8475        drop(claim);
8476        assert_eq!(
8477            counter.load(Ordering::Acquire),
8478            0,
8479            "release exactly once when the holder drops"
8480        );
8481        token.cancel();
8482    }
8483
8484    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
8485    /// dispatcher's inflight semaphore so the semaphore stays the single
8486    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
8487    #[test]
8488    fn test_envelope_channel_capacity_follows_max_inflight() {
8489        assert_eq!(envelope_channel_capacity(0), 1);
8490        assert_eq!(envelope_channel_capacity(1), 1);
8491        assert_eq!(envelope_channel_capacity(7), 7);
8492        assert_eq!(envelope_channel_capacity(64), 64);
8493        assert_eq!(envelope_channel_capacity(1024), 1024);
8494    }
8495
8496    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
8497    /// configuration. Consumer start must not panic on it (the channel guard)
8498    /// and every request must get 503 from the empty semaphore.
8499    #[tokio::test]
8500    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
8501        use camel_component_api::ConsumerContext;
8502
8503        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8504        let port = listener.local_addr().unwrap().port();
8505        drop(listener);
8506
8507        let consumer_cfg = HttpServerConfig {
8508            scheme: "http".to_string(),
8509            host: "127.0.0.1".to_string(),
8510            port,
8511            path: "/ping".to_string(),
8512            max_request_body: 2 * 1024 * 1024,
8513            max_response_body: 10 * 1024 * 1024,
8514            max_inflight_requests: 0,
8515            method: None,
8516            tls_config: None,
8517        };
8518        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8519
8520        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8521        let token = tokio_util::sync::CancellationToken::new();
8522        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8523
8524        let start_handle = tokio::spawn(async move {
8525            consumer.start(ctx).await.unwrap();
8526        });
8527
8528        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8529
8530        let client = plain_http_test_client();
8531        let resp = client
8532            .post(format!("http://127.0.0.1:{port}/ping"))
8533            .body("hello world")
8534            .send()
8535            .await
8536            .unwrap();
8537        assert_eq!(resp.status().as_u16(), 503);
8538
8539        token.cancel();
8540        let _ = start_handle.await;
8541    }
8542
8543    // -----------------------------------------------------------------------
8544    // maxInflightRequests upper bound (bd rc-ns3yc — mirror of the grpc
8545    // consumer_concurrency_limit fix, commit 101327e5)
8546    // -----------------------------------------------------------------------
8547
8548    /// bd rc-ns3yc: `L-1` and `L` are accepted unchanged, `L+1` is rejected
8549    /// with a typed config error naming the parameter, the configured value,
8550    /// and the limit, and `0` stays a representable reject-everything value
8551    /// (no `.max(1)` normalization — rc-3y6j). A semaphore of exactly L
8552    /// permits must construct without panicking: the accepted bound is
8553    /// exactly the primitive's bound.
8554    #[test]
8555    fn test_max_inflight_requests_limit_boundary() {
8556        let l = tokio::sync::Semaphore::MAX_PERMITS;
8557        assert_eq!(max_inflight_requests_limit(l - 1).unwrap(), l - 1);
8558        assert_eq!(max_inflight_requests_limit(l).unwrap(), l);
8559        assert_eq!(max_inflight_requests_limit(0).unwrap(), 0);
8560        match max_inflight_requests_limit(l + 1) {
8561            Err(CamelError::Config(msg)) => {
8562                assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8563                assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8564                assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8565            }
8566            other => panic!("expected CamelError::Config, got: {other:?}"),
8567        }
8568        let _ = tokio::sync::Semaphore::new(l);
8569    }
8570
8571    /// bd rc-ns3yc: the accepted upper bound is exactly
8572    /// `tokio::sync::Semaphore::MAX_PERMITS`; URI parse must accept L
8573    /// unchanged. Boundary-regression guard (green before and after the
8574    /// fix — red-first is not definable for an accepted value).
8575    #[test]
8576    fn test_parse_http_uri_max_inflight_at_limit_accepted() {
8577        let l = tokio::sync::Semaphore::MAX_PERMITS;
8578        let uri = format!("http://localhost:8080/api?maxInflightRequests={l}");
8579        let cfg = HttpServerConfig::from_uri(&uri).unwrap();
8580        assert_eq!(cfg.max_inflight_requests, l);
8581    }
8582
8583    /// bd rc-ns3yc: values above `tokio::sync::Semaphore::MAX_PERMITS` are
8584    /// rejected at parse with a typed config error naming the parameter,
8585    /// the configured value, and the limit.
8586    #[test]
8587    fn test_parse_http_uri_max_inflight_above_limit_rejected() {
8588        let l = tokio::sync::Semaphore::MAX_PERMITS;
8589        let uri = format!("http://localhost:8080/api?maxInflightRequests={}", l + 1);
8590        match HttpServerConfig::from_uri(&uri) {
8591            Err(CamelError::Config(msg)) => {
8592                assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8593                assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8594                assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8595            }
8596            other => panic!("expected CamelError::Config, got: {other:?}"),
8597        }
8598    }
8599
8600    /// bd rc-ns3yc: an oversized maxInflightRequests must be rejected at
8601    /// create_consumer even when the endpoint's HttpServerConfig was
8602    /// constructed directly (not via URI parse). Mirrors the
8603    /// https_consumer_without_tls_cert_errors direct-endpoint fixture.
8604    #[test]
8605    fn test_create_consumer_rejects_oversized_max_inflight() {
8606        let l = tokio::sync::Semaphore::MAX_PERMITS;
8607        let endpoint = HttpEndpoint {
8608            uri: "http://0.0.0.0:8080/api".to_string(),
8609            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
8610            server_config: HttpServerConfig {
8611                scheme: "http".to_string(),
8612                host: "0.0.0.0".to_string(),
8613                port: 8080,
8614                path: "/api".to_string(),
8615                max_request_body: 2 * 1024 * 1024,
8616                max_response_body: 10 * 1024 * 1024,
8617                max_inflight_requests: l + 1,
8618                method: None,
8619                tls_config: None,
8620            },
8621            client: reqwest::Client::new(),
8622            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
8623                PINNED_CLIENT_TTL,
8624                PINNED_CLIENT_MAX_ENTRIES,
8625            )),
8626            http_config: HttpConfig::default(),
8627        };
8628        match endpoint.create_consumer(rt()) {
8629            Err(CamelError::Config(msg)) => {
8630                assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8631                assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8632                assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8633            }
8634            Err(other) => panic!("expected CamelError::Config, got: {other:?}"),
8635            Ok(_) => panic!("expected error, got Ok"),
8636        }
8637    }
8638
8639    /// bd rc-ns3yc: `start` must reject an oversized maxInflightRequests
8640    /// BEFORE shared-server registry interaction, listener binding,
8641    /// envelope-channel construction, or semaphore construction — a typed
8642    /// config error, no panic. Mirrors the
8643    /// test_http_consumer_start_with_zero_max_inflight_rejects_503 fixture.
8644    #[tokio::test]
8645    async fn test_http_consumer_start_rejects_oversized_before_spawn() {
8646        use camel_component_api::ConsumerContext;
8647
8648        let l = tokio::sync::Semaphore::MAX_PERMITS;
8649        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8650        let port = listener.local_addr().unwrap().port();
8651        drop(listener);
8652
8653        let consumer_cfg = HttpServerConfig {
8654            scheme: "http".to_string(),
8655            host: "127.0.0.1".to_string(),
8656            port,
8657            path: "/ping".to_string(),
8658            max_request_body: 2 * 1024 * 1024,
8659            max_response_body: 10 * 1024 * 1024,
8660            max_inflight_requests: l + 1,
8661            method: None,
8662            tls_config: None,
8663        };
8664        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8665
8666        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8667        let token = tokio_util::sync::CancellationToken::new();
8668        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8669
8670        match consumer.start(ctx).await {
8671            Err(CamelError::Config(msg)) => {
8672                assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8673                assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8674                assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8675            }
8676            other => panic!("expected CamelError::Config, got: {other:?}"),
8677        }
8678        token.cancel();
8679    }
8680
8681    /// bd rc-ns3yc: defense-in-depth — `spawn_entry` must reject an
8682    /// oversized max_inflight_requests before any listener side effect and
8683    /// before `tokio::sync::Semaphore::new` is reached (which would panic).
8684    #[tokio::test]
8685    async fn test_spawn_entry_rejects_oversized_before_semaphore() {
8686        let l = tokio::sync::Semaphore::MAX_PERMITS;
8687        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8688        let addr = listener.local_addr().unwrap();
8689        let key: ServerKey = (addr.ip().to_string(), addr.port());
8690
8691        let result = spawn_entry(
8692            key,
8693            ListenerSource::Staged(listener),
8694            1,
8695            1,
8696            l + 1,
8697            test_rt(),
8698            "route-test".into(),
8699            None,
8700        )
8701        .await;
8702
8703        match result {
8704            Err(CamelError::Config(msg)) => {
8705                assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8706                assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8707                assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8708            }
8709            Err(other) => panic!("expected CamelError::Config, got: {other:?}"),
8710            Ok(_) => panic!("expected CamelError::Config, got Ok"),
8711        }
8712    }
8713
8714    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
8715    /// waits for the listener bind before publishing RouteStarted.
8716    #[test]
8717    fn test_http_consumer_startup_mode_is_explicit() {
8718        use camel_component_api::ConsumerStartupMode;
8719        let consumer_cfg = HttpServerConfig {
8720            scheme: "http".to_string(),
8721            host: "127.0.0.1".to_string(),
8722            port: 0,
8723            path: "/x".to_string(),
8724            max_request_body: 2 * 1024 * 1024,
8725            max_response_body: 10 * 1024 * 1024,
8726            max_inflight_requests: 1024,
8727            method: None,
8728            tls_config: None,
8729        };
8730        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
8731        assert_eq!(
8732            consumer.startup_mode(),
8733            ConsumerStartupMode::Explicit,
8734            "HttpConsumer must opt into Explicit startup"
8735        );
8736    }
8737
8738    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
8739    /// + route registration. The StartupSignal resolves Ok only when that
8740    /// happens. Verified here by injecting our own signal pair into the
8741    /// ConsumerContext and asserting the receiver resolves within a bounded
8742    /// window even before any HTTP request is made.
8743    #[allow(clippy::await_holding_lock)]
8744    #[tokio::test]
8745    async fn test_http_consumer_emits_mark_ready_after_bind() {
8746        use camel_component_api::{ConsumerContext, StartupSignal};
8747
8748        let _guard = lock_registry_test_mutex();
8749
8750        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8751        let port = listener.local_addr().unwrap().port();
8752        drop(listener);
8753
8754        let consumer_cfg = HttpServerConfig {
8755            scheme: "http".to_string(),
8756            host: "127.0.0.1".to_string(),
8757            port,
8758            path: "/ready-probe".to_string(),
8759            max_request_body: 2 * 1024 * 1024,
8760            max_response_body: 10 * 1024 * 1024,
8761            max_inflight_requests: 1024,
8762            method: None,
8763            tls_config: None,
8764        };
8765        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8766
8767        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8768        let token = tokio_util::sync::CancellationToken::new();
8769        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
8770
8771        // Inject our own startup signal so we can observe mark_ready.
8772        let (signal, startup_rx) = StartupSignal::pair();
8773        let ctx = ctx.with_startup(signal);
8774
8775        // Spawn start() — it MUST call mark_ready once the listener is bound
8776        // and the path is registered.
8777        tokio::spawn(async move {
8778            let _ = consumer.start(ctx).await;
8779        });
8780
8781        // The receiver MUST resolve Ok within a bounded window — proving
8782        // mark_ready was called by start(). A short timeout catches the
8783        // regression where mark_ready is never called (the old behaviour
8784        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
8785        let result =
8786            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
8787                .await
8788                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
8789        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
8790
8791        // Cancellation tears down the spawned start() loop.
8792        token.cancel();
8793    }
8794
8795    // -----------------------------------------------------------------------
8796    // Shared-server death supervision (rc-szmob / ADR-0007)
8797    // -----------------------------------------------------------------------
8798
8799    /// RuntimeObservability stub that records every `increment_errors`
8800    /// `(route_id, label)` pair so tests can assert error counters.
8801    #[derive(Default, Clone)]
8802    struct ErrorRecordingRuntime {
8803        errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
8804    }
8805
8806    impl camel_api::MetricsCollector for ErrorRecordingRuntime {
8807        fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
8808        fn increment_errors(&self, route_id: &str, error_type: &str) {
8809            self.errors
8810                .lock()
8811                .expect("error recorder lock")
8812                .push((route_id.to_string(), error_type.to_string()));
8813        }
8814        fn increment_exchanges(&self, _route_id: &str) {}
8815        fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
8816        fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
8817    }
8818
8819    impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
8820        fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
8821    }
8822
8823    impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
8824        fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
8825            std::sync::Arc::new(self.clone())
8826        }
8827        fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
8828            std::sync::Arc::new(self.clone())
8829        }
8830    }
8831
8832    /// rc-szmob (ADR-0007 parity): when the shared Axum server task for a
8833    /// host:port dies, EVERY HttpConsumer hosted on that port must fail its
8834    /// `start()` with an Err — that Err is the signal camel-core's consumer
8835    /// watcher turns into a per-route CrashNotification → FailRoute →
8836    /// supervision backoff restart. Before the fix the consumers hung in
8837    /// `Running` forever (zombie routes): neither `ctx.cancelled()` nor
8838    /// `env_rx.recv()` fires when the server task dies, because the envelope
8839    /// senders live in the (still-alive) registry, not in the dead task.
8840    ///
8841    /// Deterministic by construction: readiness is awaited via the injected
8842    /// StartupSignal (no sleeps), the server is killed via its AbortHandle
8843    /// (real JoinError → monitor's unexpected-exit branch), and consumer
8844    /// resolution is bounded by a timeout — on unmodified behavior the
8845    /// timeout trips, which is exactly the zombie this test pins down.
8846    #[allow(clippy::await_holding_lock)]
8847    #[tokio::test]
8848    async fn shared_server_death_fails_every_hosted_consumer() {
8849        use camel_component_api::{ConsumerContext, StartupSignal};
8850
8851        let _guard = lock_registry_test_mutex();
8852        ServerRegistry::reset();
8853
8854        // Reserve a port, release it, let get_or_spawn bind it.
8855        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8856        let port = listener.local_addr().unwrap().port();
8857        drop(listener);
8858
8859        let rt = ErrorRecordingRuntime::default();
8860
8861        let make_consumer = |path: &str| {
8862            HttpConsumer::new(
8863                HttpServerConfig {
8864                    scheme: "http".to_string(),
8865                    host: "127.0.0.1".to_string(),
8866                    port,
8867                    path: path.to_string(),
8868                    max_request_body: 2 * 1024 * 1024,
8869                    max_response_body: 10 * 1024 * 1024,
8870                    max_inflight_requests: 16,
8871                    method: None,
8872                    tls_config: None,
8873                },
8874                std::sync::Arc::new(rt.clone()),
8875            )
8876        };
8877
8878        let spawn_consumer = |path: &str, route_id: &str| {
8879            let mut consumer = make_consumer(path);
8880            let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8881            let token = tokio_util::sync::CancellationToken::new();
8882            let ctx = ConsumerContext::new(tx, token, route_id.to_string());
8883            let (signal, startup_rx) = StartupSignal::pair();
8884            let ctx = ctx.with_startup(signal);
8885            let task = tokio::spawn(async move { consumer.start(ctx).await });
8886            (task, startup_rx)
8887        };
8888
8889        // Two routes hosted on the SAME shared server (same host:port).
8890        let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
8891        let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
8892
8893        // Both consumers registered and the server is up (bounded, no sleeps).
8894        for (name, ready) in [("a", ready_a), ("b", ready_b)] {
8895            let result =
8896                tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
8897                    .await
8898                    .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
8899            assert!(
8900                result.is_ok(),
8901                "consumer {name} readiness must resolve Ok (bind + registration complete)"
8902            );
8903        }
8904
8905        // Kill the shared server task: abort → JoinError → the monitor's
8906        // unexpected-exit branch. This is the real crash path (no mock).
8907        {
8908            let registry = ServerRegistry::global();
8909            let guard = registry.inner.lock().expect("ServerRegistry lock");
8910            let cell = guard
8911                .entries
8912                .get(&("127.0.0.1".to_string(), port))
8913                .expect("shared server entry must exist");
8914            let handle = cell.get().expect("server handle must be initialized");
8915            handle.server_abort.abort();
8916        }
8917
8918        // THE assertion: both hosted consumers must fail (bounded). On the
8919        // zombie bug they never resolve and this timeout trips.
8920        let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
8921            .await
8922            .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
8923        let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
8924            .await
8925            .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
8926
8927        let err_a = outcome_a
8928            .expect("consumer-a task must join")
8929            .expect_err("consumer-a start() must return Err when the shared server dies");
8930        let err_b = outcome_b
8931            .expect("consumer-b task must join")
8932            .expect_err("consumer-b start() must return Err when the shared server dies");
8933
8934        // The error must identify the dead shared transport (it flows into the
8935        // CrashNotification message camel-core records against the route).
8936        for (name, err) in [("a", &err_a), ("b", &err_b)] {
8937            assert!(
8938                err.to_string().contains("127.0.0.1")
8939                    && err.to_string().contains(&port.to_string()),
8940                "consumer-{name} error must name the dead shared server, got: {err}"
8941            );
8942        }
8943
8944        // Error counter regression guard: the monitor still records
8945        // `e:http:server-task-exited` for the route that spawned the server.
8946        let recorded = rt.errors.lock().expect("error recorder lock").clone();
8947        assert!(
8948            recorded
8949                .iter()
8950                .any(|(route, label)| label == "e:http:server-task-exited"
8951                    && route == "zombie-route-a"),
8952            "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
8953        );
8954    }
8955
8956    #[tokio::test]
8957    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
8958        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8959
8960        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8961        let port = listener.local_addr().unwrap().port();
8962        drop(listener);
8963
8964        let consumer_cfg = HttpServerConfig {
8965            scheme: "http".to_string(),
8966            host: "127.0.0.1".to_string(),
8967            port,
8968            path: "/saturation".to_string(),
8969            max_request_body: 2 * 1024 * 1024,
8970            max_response_body: 10 * 1024 * 1024,
8971            max_inflight_requests: 1,
8972            method: None,
8973            tls_config: None,
8974        };
8975        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8976
8977        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8978        let token = tokio_util::sync::CancellationToken::new();
8979        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8980        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8981        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8982
8983        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
8984        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
8985
8986        tokio::spawn(async move {
8987            let mut first_seen_tx = Some(first_seen_tx);
8988            let mut unblock_first_rx = Some(unblock_first_rx);
8989
8990            loop {
8991                match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
8992                    Ok(Some(envelope)) => {
8993                        if let Some(tx) = first_seen_tx.take() {
8994                            let _ = tx.send(());
8995                            if let Some(rx_unblock) = unblock_first_rx.take() {
8996                                // Deadline-bound like the recv above: a
8997                                // parked first exchange fails loudly
8998                                // instead of hanging the pipeline task.
8999                                let _ = tokio::time::timeout(Duration::from_secs(2), rx_unblock)
9000                                    .await
9001                                    .expect("first exchange unblock within 2s");
9002                            }
9003                        }
9004
9005                        if let Some(reply_tx) = envelope.reply_tx {
9006                            let _ = reply_tx.send(Ok(envelope.exchange));
9007                        }
9008                    }
9009                    // Channel closed: drainer ends.
9010                    Ok(None) => break, // closed: drain complete
9011                    // Stalled: drainer ends.
9012                    Err(_) => break, // stalled: drainer ends
9013                }
9014            }
9015        });
9016
9017        let client = plain_http_test_client();
9018        let first_req = {
9019            let client = client.clone();
9020            async move {
9021                client
9022                    .get(format!("http://127.0.0.1:{port}/saturation"))
9023                    .send()
9024                    .await
9025                    .unwrap()
9026            }
9027        };
9028
9029        let first_handle = tokio::spawn(first_req);
9030        first_seen_rx.await.unwrap();
9031
9032        let second_resp = client
9033            .get(format!("http://127.0.0.1:{port}/saturation"))
9034            .send()
9035            .await
9036            .unwrap();
9037
9038        assert_eq!(second_resp.status().as_u16(), 503);
9039
9040        let _ = unblock_first_tx.send(());
9041        let first_resp = first_handle.await.unwrap();
9042        assert_eq!(first_resp.status().as_u16(), 200);
9043
9044        token.cancel();
9045    }
9046
9047    /// Audit 2026-08-31, F2-1: a chunked (no Content-Length) request body must
9048    /// still be capped — the byte limit travels with the stream, so any
9049    /// downstream materialization fails closed past `max_request_body`.
9050    #[tokio::test]
9051    async fn test_http_consumer_chunked_body_is_capped() {
9052        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9053
9054        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9055        let port = listener.local_addr().unwrap().port();
9056        drop(listener);
9057
9058        let consumer_cfg = HttpServerConfig {
9059            scheme: "http".to_string(),
9060            host: "127.0.0.1".to_string(),
9061            port,
9062            path: "/chunked-cap".to_string(),
9063            max_request_body: 1024, // tiny cap for the test
9064            max_response_body: 10 * 1024 * 1024,
9065            max_inflight_requests: 16,
9066            method: None,
9067            tls_config: None,
9068        };
9069        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9070
9071        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9072        let token = tokio_util::sync::CancellationToken::new();
9073        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9074        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9075        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9076
9077        // Chunked body: reqwest streams it without Content-Length.
9078        let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
9079            .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
9080            .collect();
9081        let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
9082
9083        let client = plain_http_test_client();
9084        let send_fut = client
9085            .post(format!("http://127.0.0.1:{port}/chunked-cap"))
9086            .body(stream_body)
9087            .send();
9088
9089        let (http_result, _) = tokio::join!(send_fut, async {
9090            if let Some(mut envelope) = rx.recv().await {
9091                // The route materializes the body — the cap must fire.
9092                let materialized = envelope
9093                    .exchange
9094                    .input
9095                    .body
9096                    .clone()
9097                    .into_bytes(64 * 1024)
9098                    .await;
9099                assert!(
9100                    materialized.is_err(),
9101                    "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
9102                );
9103                let err = materialized.unwrap_err().to_string();
9104                assert!(
9105                    err.contains("limit") || err.contains("exceeds"),
9106                    "error should mention the limit: {err}"
9107                );
9108                if let Some(reply_tx) = envelope.reply_tx {
9109                    envelope.exchange.input.body =
9110                        camel_component_api::Body::Text("handled".to_string());
9111                    let _ = reply_tx.send(Ok(envelope.exchange));
9112                }
9113            }
9114        });
9115
9116        let resp = http_result.unwrap();
9117        assert_eq!(resp.status().as_u16(), 200);
9118
9119        token.cancel();
9120    }
9121
9122    #[tokio::test]
9123    #[allow(clippy::await_holding_lock)]
9124    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
9125        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9126
9127        let _guard = lock_registry_test_mutex();
9128
9129        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9130        let port = listener.local_addr().unwrap().port();
9131        drop(listener);
9132
9133        let consumer_cfg = HttpServerConfig {
9134            scheme: "http".to_string(),
9135            host: "127.0.0.1".to_string(),
9136            port,
9137            path: "/limit-bytes".to_string(),
9138            max_request_body: 2 * 1024 * 1024,
9139            max_response_body: 16,
9140            max_inflight_requests: 1024,
9141            method: None,
9142            tls_config: None,
9143        };
9144        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9145
9146        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9147        let token = tokio_util::sync::CancellationToken::new();
9148        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9149        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9150        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9151
9152        let client = plain_http_test_client();
9153        let send_fut = client
9154            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
9155            .send();
9156
9157        let (http_result, _) = tokio::join!(send_fut, async {
9158            if let Some(mut envelope) = rx.recv().await {
9159                envelope.exchange.input.body =
9160                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
9161                if let Some(reply_tx) = envelope.reply_tx {
9162                    let _ = reply_tx.send(Ok(envelope.exchange));
9163                }
9164            }
9165        });
9166
9167        let resp = http_result.unwrap();
9168        assert_eq!(resp.status().as_u16(), 500);
9169        let body = resp.text().await.unwrap();
9170        assert_eq!(body, "Response body exceeds configured limit");
9171        token.cancel();
9172    }
9173
9174    #[tokio::test]
9175    #[allow(clippy::await_holding_lock)]
9176    async fn test_http_consumer_enforces_max_response_body_for_json() {
9177        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9178
9179        let _guard = lock_registry_test_mutex();
9180
9181        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9182        let port = listener.local_addr().unwrap().port();
9183        drop(listener);
9184
9185        let consumer_cfg = HttpServerConfig {
9186            scheme: "http".to_string(),
9187            host: "127.0.0.1".to_string(),
9188            port,
9189            path: "/limit-json".to_string(),
9190            max_request_body: 2 * 1024 * 1024,
9191            max_response_body: 16,
9192            max_inflight_requests: 1024,
9193            method: None,
9194            tls_config: None,
9195        };
9196        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9197
9198        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9199        let token = tokio_util::sync::CancellationToken::new();
9200        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9201        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9202        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9203
9204        let client = plain_http_test_client();
9205        let send_fut = client
9206            .get(format!("http://127.0.0.1:{port}/limit-json"))
9207            .send();
9208
9209        let (http_result, _) = tokio::join!(send_fut, async {
9210            if let Some(mut envelope) = rx.recv().await {
9211                envelope.exchange.input.body = camel_component_api::Body::Json(
9212                    serde_json::json!({"message":"this response is bigger than sixteen"}),
9213                );
9214                if let Some(reply_tx) = envelope.reply_tx {
9215                    let _ = reply_tx.send(Ok(envelope.exchange));
9216                }
9217            }
9218        });
9219
9220        let resp = http_result.unwrap();
9221        assert_eq!(resp.status().as_u16(), 500);
9222        let body = resp.text().await.unwrap();
9223        assert_eq!(body, "Response body exceeds configured limit");
9224        token.cancel();
9225    }
9226
9227    #[tokio::test]
9228    #[allow(clippy::await_holding_lock)]
9229    async fn test_http_consumer_enforces_max_response_body_for_xml() {
9230        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9231
9232        let _guard = lock_registry_test_mutex();
9233
9234        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9235        let port = listener.local_addr().unwrap().port();
9236        drop(listener);
9237
9238        let consumer_cfg = HttpServerConfig {
9239            scheme: "http".to_string(),
9240            host: "127.0.0.1".to_string(),
9241            port,
9242            path: "/limit-xml".to_string(),
9243            max_request_body: 2 * 1024 * 1024,
9244            max_response_body: 16,
9245            max_inflight_requests: 1024,
9246            method: None,
9247            tls_config: None,
9248        };
9249        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9250
9251        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9252        let token = tokio_util::sync::CancellationToken::new();
9253        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9254        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9255        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9256
9257        let client = plain_http_test_client();
9258        let send_fut = client
9259            .get(format!("http://127.0.0.1:{port}/limit-xml"))
9260            .send();
9261
9262        let (http_result, _) = tokio::join!(send_fut, async {
9263            if let Some(mut envelope) = rx.recv().await {
9264                envelope.exchange.input.body = camel_component_api::Body::Xml(
9265                    "<root><value>way-too-large</value></root>".into(),
9266                );
9267                if let Some(reply_tx) = envelope.reply_tx {
9268                    let _ = reply_tx.send(Ok(envelope.exchange));
9269                }
9270            }
9271        });
9272
9273        let resp = http_result.unwrap();
9274        assert_eq!(resp.status().as_u16(), 500);
9275        let body = resp.text().await.unwrap();
9276        assert_eq!(body, "Response body exceeds configured limit");
9277        token.cancel();
9278    }
9279
9280    #[tokio::test]
9281    #[allow(clippy::await_holding_lock)]
9282    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
9283        use camel_component_api::{
9284            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
9285        };
9286        use futures::stream;
9287
9288        let _guard = lock_registry_test_mutex();
9289
9290        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
9291        let port = listener.local_addr().unwrap().port();
9292        drop(listener);
9293
9294        let consumer_cfg = HttpServerConfig {
9295            scheme: "http".to_string(),
9296            host: "0.0.0.0".to_string(),
9297            port,
9298            path: "/limit-stream".to_string(),
9299            max_request_body: 2 * 1024 * 1024,
9300            max_response_body: 16,
9301            max_inflight_requests: 1024,
9302            method: None,
9303            tls_config: None,
9304        };
9305        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9306
9307        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9308        let token = tokio_util::sync::CancellationToken::new();
9309        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9310        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9311        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9312
9313        let client = plain_http_test_client();
9314        let send_fut = client
9315            .get(format!("http://127.0.0.1:{port}/limit-stream"))
9316            .send();
9317
9318        let (http_result, _) = tokio::join!(send_fut, async {
9319            if let Some(mut envelope) = rx.recv().await {
9320                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
9321                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
9322                let stream = Box::pin(stream::iter(chunks));
9323                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
9324                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
9325                    metadata: StreamMetadata {
9326                        size_hint: Some(32),
9327                        content_type: Some("application/octet-stream".into()),
9328                        origin: None,
9329                    },
9330                });
9331                if let Some(reply_tx) = envelope.reply_tx {
9332                    let _ = reply_tx.send(Ok(envelope.exchange));
9333                }
9334            }
9335        });
9336
9337        let resp = http_result.unwrap();
9338        assert_eq!(resp.status().as_u16(), 200);
9339        let body = resp.bytes().await.unwrap();
9340        assert_eq!(body.len(), 32);
9341        token.cancel();
9342    }
9343
9344    // -----------------------------------------------------------------------
9345    // Integration tests
9346    // -----------------------------------------------------------------------
9347
9348    #[tokio::test]
9349    #[allow(clippy::await_holding_lock)]
9350    async fn test_integration_single_consumer_round_trip() {
9351        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9352
9353        // Spawns an HTTP consumer on the global ServerRegistry
9354        // (HttpConsumer::start → get_or_spawn). Serialize against the other
9355        // registry tests so parallel runs do not race on shared global state.
9356        let _guard = lock_registry_test_mutex();
9357
9358        // Get an OS-assigned free port (ephemeral)
9359        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9360        let port = listener.local_addr().unwrap().port();
9361        drop(listener); // Release — ServerRegistry will rebind
9362
9363        let component = HttpComponent::new();
9364        let endpoint_ctx = NoOpComponentContext;
9365        let endpoint = component
9366            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
9367            .unwrap();
9368        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9369
9370        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9371        let token = tokio_util::sync::CancellationToken::new();
9372        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9373
9374        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9375        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9376
9377        let client = plain_http_test_client();
9378        let send_fut = client
9379            .post(format!("http://127.0.0.1:{port}/echo"))
9380            .header("Content-Type", "text/plain")
9381            .body("ping")
9382            .send();
9383
9384        let (http_result, _) = tokio::join!(send_fut, async {
9385            if let Some(mut envelope) = rx.recv().await {
9386                assert_eq!(
9387                    envelope.exchange.input.header("CamelHttpMethod"),
9388                    Some(&serde_json::Value::String("POST".into()))
9389                );
9390                assert_eq!(
9391                    envelope.exchange.input.header("CamelHttpPath"),
9392                    Some(&serde_json::Value::String("/echo".into()))
9393                );
9394                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
9395                if let Some(reply_tx) = envelope.reply_tx {
9396                    let _ = reply_tx.send(Ok(envelope.exchange));
9397                }
9398            }
9399        });
9400
9401        let resp = http_result.unwrap();
9402        assert_eq!(resp.status().as_u16(), 200);
9403        let body = resp.text().await.unwrap();
9404        assert_eq!(body, "pong");
9405
9406        token.cancel();
9407    }
9408
9409    #[tokio::test]
9410    #[allow(clippy::await_holding_lock)]
9411    async fn test_integration_two_consumers_shared_port() {
9412        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9413
9414        let _guard = lock_registry_test_mutex();
9415
9416        // Get an OS-assigned free port (ephemeral)
9417        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9418        let port = listener.local_addr().unwrap().port();
9419        drop(listener);
9420
9421        let component = HttpComponent::new();
9422        let endpoint_ctx = NoOpComponentContext;
9423
9424        // Consumer A: /hello
9425        let endpoint_a = component
9426            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
9427            .unwrap();
9428        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
9429
9430        // Consumer B: /world
9431        let endpoint_b = component
9432            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
9433            .unwrap();
9434        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
9435
9436        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9437        let token_a = tokio_util::sync::CancellationToken::new();
9438        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
9439
9440        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9441        let token_b = tokio_util::sync::CancellationToken::new();
9442        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
9443
9444        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
9445        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
9446        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9447
9448        let client = plain_http_test_client();
9449
9450        // Request to /hello
9451        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
9452        let (resp_hello, _) = tokio::join!(fut_hello, async {
9453            if let Some(mut envelope) = rx_a.recv().await {
9454                envelope.exchange.input.body =
9455                    camel_component_api::Body::Text("hello-response".to_string());
9456                if let Some(reply_tx) = envelope.reply_tx {
9457                    let _ = reply_tx.send(Ok(envelope.exchange));
9458                }
9459            }
9460        });
9461
9462        // Request to /world
9463        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
9464        let (resp_world, _) = tokio::join!(fut_world, async {
9465            if let Some(mut envelope) = rx_b.recv().await {
9466                envelope.exchange.input.body =
9467                    camel_component_api::Body::Text("world-response".to_string());
9468                if let Some(reply_tx) = envelope.reply_tx {
9469                    let _ = reply_tx.send(Ok(envelope.exchange));
9470                }
9471            }
9472        });
9473
9474        let body_a = resp_hello.unwrap().text().await.unwrap();
9475        let body_b = resp_world.unwrap().text().await.unwrap();
9476
9477        assert_eq!(body_a, "hello-response");
9478        assert_eq!(body_b, "world-response");
9479
9480        token_a.cancel();
9481        token_b.cancel();
9482    }
9483
9484    #[tokio::test]
9485    #[allow(clippy::await_holding_lock)]
9486    async fn test_integration_unregistered_path_returns_404() {
9487        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9488
9489        let _guard = lock_registry_test_mutex();
9490
9491        // Get an OS-assigned free port (ephemeral)
9492        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9493        let port = listener.local_addr().unwrap().port();
9494        drop(listener);
9495
9496        let component = HttpComponent::new();
9497        let endpoint_ctx = NoOpComponentContext;
9498        let endpoint = component
9499            .create_endpoint(
9500                &format!("http://127.0.0.1:{port}/registered"),
9501                &endpoint_ctx,
9502            )
9503            .unwrap();
9504        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9505
9506        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9507        let token = tokio_util::sync::CancellationToken::new();
9508        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9509
9510        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9511
9512        // Wait until the server is actually accepting connections (CI runners can be slow).
9513        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9514        loop {
9515            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
9516                .await
9517                .is_ok()
9518            {
9519                break;
9520            }
9521            if std::time::Instant::now() >= deadline {
9522                panic!("HTTP server did not start within 5s on port {port}");
9523            }
9524            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
9525        }
9526
9527        let client = plain_http_test_client();
9528        let resp = client
9529            .get(format!("http://127.0.0.1:{port}/not-there"))
9530            .send()
9531            .await
9532            .unwrap();
9533        assert_eq!(resp.status().as_u16(), 404);
9534
9535        token.cancel();
9536    }
9537
9538    #[test]
9539    fn test_http_consumer_declares_concurrent() {
9540        use camel_component_api::ConcurrencyModel;
9541
9542        let config = HttpServerConfig {
9543            scheme: "http".to_string(),
9544            host: "127.0.0.1".to_string(),
9545            port: 19999,
9546            path: "/test".to_string(),
9547            max_request_body: 2 * 1024 * 1024,
9548            max_response_body: 10 * 1024 * 1024,
9549            max_inflight_requests: 1024,
9550            method: None,
9551            tls_config: None,
9552        };
9553        let consumer = HttpConsumer::new(config, test_rt());
9554        assert_eq!(
9555            consumer.concurrency_model(),
9556            ConcurrencyModel::Concurrent { max: None }
9557        );
9558    }
9559
9560    #[test]
9561    fn server_config_parses_tls_cert_and_key() {
9562        let cfg = HttpServerConfig::from_uri(
9563            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
9564        )
9565        .unwrap();
9566        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
9567        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
9568    }
9569
9570    #[test]
9571    fn server_config_no_tls_when_params_absent() {
9572        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
9573        assert!(cfg.tls_config.is_none());
9574    }
9575
9576    // -----------------------------------------------------------------------
9577    // HttpReplyBody streaming tests
9578    // -----------------------------------------------------------------------
9579
9580    #[tokio::test]
9581    async fn test_http_reply_body_stream_variant_exists() {
9582        use bytes::Bytes;
9583        use camel_component_api::CamelError;
9584        use futures::stream;
9585
9586        let chunks: Vec<Result<Bytes, CamelError>> =
9587            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
9588        let stream = Box::pin(stream::iter(chunks));
9589        let reply_body = HttpReplyBody::Stream(stream);
9590        // Si compila y el match funciona, el test pasa
9591        match reply_body {
9592            HttpReplyBody::Stream(_) => {}
9593            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
9594        }
9595    }
9596
9597    // -----------------------------------------------------------------------
9598    // OpenTelemetry propagation tests (only compiled with "otel" feature)
9599    // -----------------------------------------------------------------------
9600
9601    #[cfg(feature = "otel")]
9602    mod otel_tests {
9603        use super::*;
9604        use camel_component_api::Message;
9605        use tower::ServiceExt;
9606
9607        #[tokio::test]
9608        async fn test_producer_injects_traceparent_header() {
9609            let (url, _handle) = start_test_server_with_header_capture().await;
9610            let ctx = test_producer_ctx();
9611
9612            let component = HttpComponent::new();
9613            let endpoint_ctx = NoOpComponentContext;
9614            let endpoint = component
9615                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
9616                .unwrap();
9617            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9618
9619            // Create exchange with an OTel context by extracting from a traceparent header
9620            let mut exchange = Exchange::new(Message::default());
9621            let mut headers = std::collections::HashMap::new();
9622            headers.insert(
9623                "traceparent".to_string(),
9624                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
9625            );
9626            camel_otel::extract_into_exchange(&mut exchange, &headers);
9627
9628            let result = producer.oneshot(exchange).await.unwrap();
9629
9630            // Verify request succeeded
9631            let status = result
9632                .input
9633                .header("CamelHttpResponseCode")
9634                .and_then(|v| v.as_u64())
9635                .unwrap();
9636            assert_eq!(status, 200);
9637
9638            // The test server echoes back the received traceparent header
9639            let traceparent = result.input.header("X-Received-Traceparent");
9640            assert!(
9641                traceparent.is_some(),
9642                "traceparent header should have been sent"
9643            );
9644
9645            let traceparent_str = traceparent.unwrap().as_str().unwrap();
9646            // Verify format: version-traceid-spanid-flags
9647            let parts: Vec<&str> = traceparent_str.split('-').collect();
9648            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9649            assert_eq!(parts[0], "00", "version should be 00");
9650            assert_eq!(
9651                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9652                "trace-id should match"
9653            );
9654            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
9655            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
9656        }
9657
9658        #[tokio::test]
9659        async fn test_consumer_extracts_traceparent_header() {
9660            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9661
9662            // Get an OS-assigned free port
9663            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9664            let port = listener.local_addr().unwrap().port();
9665            drop(listener);
9666
9667            let component = HttpComponent::new();
9668            let endpoint_ctx = NoOpComponentContext;
9669            let endpoint = component
9670                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
9671                .unwrap();
9672            let mut consumer = endpoint.create_consumer(rt()).unwrap();
9673
9674            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9675            let token = tokio_util::sync::CancellationToken::new();
9676            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9677
9678            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9679            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9680
9681            // Send request with traceparent header
9682            let client = plain_http_test_client();
9683            let send_fut = client
9684                .post(format!("http://127.0.0.1:{port}/trace"))
9685                .header(
9686                    "traceparent",
9687                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9688                )
9689                .body("test")
9690                .send();
9691
9692            let (http_result, _) = tokio::join!(send_fut, async {
9693                if let Some(envelope) = rx.recv().await {
9694                    // Verify the exchange has a valid OTel context by re-injecting it
9695                    // and checking the traceparent matches
9696                    let mut injected_headers = std::collections::HashMap::new();
9697                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9698
9699                    assert!(
9700                        injected_headers.contains_key("traceparent"),
9701                        "Exchange should have traceparent after extraction"
9702                    );
9703
9704                    let traceparent = injected_headers.get("traceparent").unwrap();
9705                    let parts: Vec<&str> = traceparent.split('-').collect();
9706                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9707                    assert_eq!(
9708                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9709                        "Trace ID should match the original traceparent header"
9710                    );
9711
9712                    if let Some(reply_tx) = envelope.reply_tx {
9713                        let _ = reply_tx.send(Ok(envelope.exchange));
9714                    }
9715                }
9716            });
9717
9718            let resp = http_result.unwrap();
9719            assert_eq!(resp.status().as_u16(), 200);
9720
9721            token.cancel();
9722        }
9723
9724        #[tokio::test]
9725        async fn test_consumer_extracts_mixed_case_traceparent_header() {
9726            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9727
9728            // Get an OS-assigned free port
9729            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9730            let port = listener.local_addr().unwrap().port();
9731            drop(listener);
9732
9733            let component = HttpComponent::new();
9734            let endpoint_ctx = NoOpComponentContext;
9735            let endpoint = component
9736                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
9737                .unwrap();
9738            let mut consumer = endpoint.create_consumer(rt()).unwrap();
9739
9740            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9741            let token = tokio_util::sync::CancellationToken::new();
9742            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9743
9744            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9745            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9746
9747            // Send request with MIXED-CASE TraceParent header (not lowercase)
9748            let client = plain_http_test_client();
9749            let send_fut = client
9750                .post(format!("http://127.0.0.1:{port}/trace"))
9751                .header(
9752                    "TraceParent",
9753                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9754                )
9755                .body("test")
9756                .send();
9757
9758            let (http_result, _) = tokio::join!(send_fut, async {
9759                if let Some(envelope) = rx.recv().await {
9760                    // Verify the exchange has a valid OTel context by re-injecting it
9761                    // and checking the traceparent matches
9762                    let mut injected_headers = HashMap::new();
9763                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9764
9765                    assert!(
9766                        injected_headers.contains_key("traceparent"),
9767                        "Exchange should have traceparent after extraction from mixed-case header"
9768                    );
9769
9770                    let traceparent = injected_headers.get("traceparent").unwrap();
9771                    let parts: Vec<&str> = traceparent.split('-').collect();
9772                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9773                    assert_eq!(
9774                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9775                        "Trace ID should match the original mixed-case TraceParent header"
9776                    );
9777
9778                    if let Some(reply_tx) = envelope.reply_tx {
9779                        let _ = reply_tx.send(Ok(envelope.exchange));
9780                    }
9781                }
9782            });
9783
9784            let resp = http_result.unwrap();
9785            assert_eq!(resp.status().as_u16(), 200);
9786
9787            token.cancel();
9788        }
9789
9790        #[tokio::test]
9791        async fn test_producer_no_trace_context_no_crash() {
9792            let (url, _handle) = start_test_server().await;
9793            let ctx = test_producer_ctx();
9794
9795            let component = HttpComponent::new();
9796            let endpoint_ctx = NoOpComponentContext;
9797            let endpoint = component
9798                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
9799                .unwrap();
9800            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9801
9802            // Create exchange with default (empty) otel_context - no trace context
9803            let exchange = Exchange::new(Message::default());
9804
9805            // Should succeed without panic
9806            let result = producer.oneshot(exchange).await.unwrap();
9807
9808            // Verify request succeeded
9809            let status = result
9810                .input
9811                .header("CamelHttpResponseCode")
9812                .and_then(|v| v.as_u64())
9813                .unwrap();
9814            assert_eq!(status, 200);
9815        }
9816
9817        /// Test server that captures and echoes back the traceparent header
9818        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
9819            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9820            let addr = listener.local_addr().unwrap();
9821            let url = format!("http://127.0.0.1:{}", addr.port());
9822
9823            let handle = tokio::spawn(async move {
9824                loop {
9825                    if let Ok((mut stream, _)) = listener.accept().await {
9826                        tokio::spawn(async move {
9827                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
9828                            let mut buf = vec![0u8; 8192];
9829                            let n = stream.read(&mut buf).await.unwrap_or(0);
9830                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
9831
9832                            // Extract traceparent header from request
9833                            let traceparent = request
9834                                .lines()
9835                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
9836                                .map(|line| {
9837                                    line.split(':')
9838                                        .nth(1)
9839                                        .map(|s| s.trim().to_string())
9840                                        .unwrap_or_default()
9841                                })
9842                                .unwrap_or_default();
9843
9844                            let body =
9845                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
9846                            let response = format!(
9847                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
9848                                body.len(),
9849                                traceparent,
9850                                body
9851                            );
9852                            let _ = stream.write_all(response.as_bytes()).await;
9853                        });
9854                    }
9855                }
9856            });
9857
9858            (url, handle)
9859        }
9860    }
9861
9862    // -----------------------------------------------------------------------
9863    // Response streaming tests (Eje A - Task 2)
9864    // -----------------------------------------------------------------------
9865
9866    // -----------------------------------------------------------------------
9867    // Request streaming tests (Eje B - Task 3)
9868    // -----------------------------------------------------------------------
9869
9870    #[tokio::test]
9871    async fn test_request_body_arrives_as_stream() {
9872        use camel_component_api::Body;
9873        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9874
9875        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9876        let port = listener.local_addr().unwrap().port();
9877        drop(listener);
9878
9879        let component = HttpComponent::new();
9880        let endpoint_ctx = NoOpComponentContext;
9881        let endpoint = component
9882            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
9883            .unwrap();
9884        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9885
9886        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9887        let token = tokio_util::sync::CancellationToken::new();
9888        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9889
9890        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9891        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9892
9893        let client = plain_http_test_client();
9894        let send_fut = client
9895            .post(format!("http://127.0.0.1:{port}/upload"))
9896            .body("hello streaming world")
9897            .send();
9898
9899        let (http_result, _) = tokio::join!(send_fut, async {
9900            if let Some(mut envelope) = rx.recv().await {
9901                // Body must be Body::Stream, not Body::Text or Body::Bytes
9902                assert!(
9903                    matches!(envelope.exchange.input.body, Body::Stream(_)),
9904                    "expected Body::Stream, got discriminant {:?}",
9905                    std::mem::discriminant(&envelope.exchange.input.body)
9906                );
9907                // Materialize to verify content
9908                let bytes = envelope
9909                    .exchange
9910                    .input
9911                    .body
9912                    .into_bytes(1024 * 1024)
9913                    .await
9914                    .unwrap();
9915                assert_eq!(&bytes[..], b"hello streaming world");
9916
9917                envelope.exchange.input.body = camel_component_api::Body::Empty;
9918                if let Some(reply_tx) = envelope.reply_tx {
9919                    let _ = reply_tx.send(Ok(envelope.exchange));
9920                }
9921            }
9922        });
9923
9924        let resp = http_result.unwrap();
9925        assert_eq!(resp.status().as_u16(), 200);
9926
9927        token.cancel();
9928    }
9929
9930    // -----------------------------------------------------------------------
9931    // Response streaming tests (Eje A - Task 2)
9932    // -----------------------------------------------------------------------
9933
9934    #[tokio::test]
9935    async fn test_streaming_response_chunked() {
9936        use bytes::Bytes;
9937        use camel_component_api::Body;
9938        use camel_component_api::CamelError;
9939        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9940        use camel_component_api::{StreamBody, StreamMetadata};
9941        use futures::stream;
9942        use std::sync::Arc;
9943        use tokio::sync::Mutex;
9944
9945        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9946        let port = listener.local_addr().unwrap().port();
9947        drop(listener);
9948
9949        let component = HttpComponent::new();
9950        let endpoint_ctx = NoOpComponentContext;
9951        let endpoint = component
9952            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
9953            .unwrap();
9954        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9955
9956        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9957        let token = tokio_util::sync::CancellationToken::new();
9958        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9959
9960        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9961        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9962
9963        let client = plain_http_test_client();
9964        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
9965
9966        let (http_result, _) = tokio::join!(send_fut, async {
9967            if let Some(mut envelope) = rx.recv().await {
9968                // Respond with Body::Stream
9969                let chunks: Vec<Result<Bytes, CamelError>> =
9970                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
9971                let stream = Box::pin(stream::iter(chunks));
9972                envelope.exchange.input.body = Body::Stream(StreamBody {
9973                    stream: Arc::new(Mutex::new(Some(stream))),
9974                    metadata: StreamMetadata::default(),
9975                });
9976                if let Some(reply_tx) = envelope.reply_tx {
9977                    let _ = reply_tx.send(Ok(envelope.exchange));
9978                }
9979            }
9980        });
9981
9982        let resp = http_result.unwrap();
9983        assert_eq!(resp.status().as_u16(), 200);
9984        let body = resp.text().await.unwrap();
9985        assert_eq!(body, "chunk1chunk2");
9986
9987        token.cancel();
9988    }
9989
9990    // -----------------------------------------------------------------------
9991    // 413 Content-Length limit test (Task 4)
9992    // -----------------------------------------------------------------------
9993
9994    #[tokio::test]
9995    async fn test_413_when_content_length_exceeds_limit() {
9996        use camel_component_api::ConsumerContext;
9997
9998        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9999        let port = listener.local_addr().unwrap().port();
10000        drop(listener);
10001
10002        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
10003        let component = HttpComponent::new();
10004        let endpoint_ctx = NoOpComponentContext;
10005        let endpoint = component
10006            .create_endpoint(
10007                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
10008                &endpoint_ctx,
10009            )
10010            .unwrap();
10011        let mut consumer = endpoint.create_consumer(rt()).unwrap();
10012
10013        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10014        let token = tokio_util::sync::CancellationToken::new();
10015        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10016
10017        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10018        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10019
10020        let client = plain_http_test_client();
10021        let resp = client
10022            .post(format!("http://127.0.0.1:{port}/upload"))
10023            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
10024            .body("x".repeat(1000))
10025            .send()
10026            .await
10027            .unwrap();
10028
10029        assert_eq!(resp.status().as_u16(), 413);
10030
10031        token.cancel();
10032    }
10033
10034    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
10035    /// The spec says: "If there is no Content-Length, the limit does not apply at the
10036    /// consumer level — the route is responsible."
10037    #[tokio::test]
10038    async fn test_chunked_upload_without_content_length_bypasses_limit() {
10039        use bytes::Bytes;
10040        use camel_component_api::Body;
10041        use camel_component_api::ConsumerContext;
10042        use futures::stream;
10043
10044        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10045        let port = listener.local_addr().unwrap().port();
10046        drop(listener);
10047
10048        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
10049        let component = HttpComponent::new();
10050        let endpoint_ctx = NoOpComponentContext;
10051        let endpoint = component
10052            .create_endpoint(
10053                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
10054                &endpoint_ctx,
10055            )
10056            .unwrap();
10057        let mut consumer = endpoint.create_consumer(rt()).unwrap();
10058
10059        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10060        let token = tokio_util::sync::CancellationToken::new();
10061        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10062
10063        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10064        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10065
10066        let client = plain_http_test_client();
10067
10068        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
10069        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
10070        // but since there's no Content-Length the 413 check must NOT fire.
10071        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
10072            Ok(Bytes::from("y".repeat(50))),
10073            Ok(Bytes::from("y".repeat(50))),
10074        ];
10075        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
10076        let send_fut = client
10077            .post(format!("http://127.0.0.1:{port}/upload"))
10078            .body(stream_body)
10079            .send();
10080
10081        let consumer_fut = async {
10082            // Use timeout to avoid deadlock if the handler rejects before enqueueing
10083            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
10084                Ok(Some(mut envelope)) => {
10085                    assert!(
10086                        matches!(envelope.exchange.input.body, Body::Stream(_)),
10087                        "expected Body::Stream"
10088                    );
10089                    envelope.exchange.input.body = camel_component_api::Body::Empty;
10090                    if let Some(reply_tx) = envelope.reply_tx {
10091                        let _ = reply_tx.send(Ok(envelope.exchange));
10092                    }
10093                }
10094                Ok(None) => panic!("consumer channel closed unexpectedly"),
10095                Err(_) => {
10096                    // Timeout: the request was rejected before reaching the consumer.
10097                    // The HTTP response will carry the real status code (we check below).
10098                }
10099            }
10100        };
10101
10102        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
10103
10104        let resp = http_result.unwrap();
10105        // Audit 2026-08-31 (F2-1): the request is no longer rejected at the door
10106        // (no Content-Length to pre-check), but the byte cap now travels with the
10107        // stream: ANY materialization past maxRequestBody fails closed. This test
10108        // does not consume the body, so the request still completes with 200 —
10109        // enforcement happens at consumption time (see
10110        // test_http_consumer_chunked_body_is_capped).
10111        assert_ne!(
10112            resp.status().as_u16(),
10113            413,
10114            "chunked upload has no Content-Length to pre-check"
10115        );
10116        assert_eq!(resp.status().as_u16(), 200);
10117
10118        token.cancel();
10119    }
10120
10121    #[test]
10122    fn test_is_private_ip_ranges() {
10123        use camel_api::is_ssrf_blocked_ip;
10124        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
10125        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
10126        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
10127        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
10128        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
10129        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
10130
10131        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
10132        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
10133        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
10134        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
10135        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
10136        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
10137        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
10138        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
10139
10140        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
10141        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
10142        assert!(!is_ssrf_blocked_ip(
10143            &"2001:4860:4860::8888".parse().unwrap()
10144        )); // allow-unwrap
10145    }
10146
10147    #[test]
10148    fn test_title_case_header() {
10149        assert_eq!(title_case_header("content-type"), "Content-Type");
10150        assert_eq!(title_case_header("authorization"), "Authorization");
10151        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
10152        assert_eq!(title_case_header("host"), "Host");
10153        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
10154        assert_eq!(title_case_header("single"), "Single");
10155        assert_eq!(title_case_header(""), "");
10156    }
10157
10158    #[test]
10159    fn test_resolve_url_combines_path_and_query_sources() {
10160        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
10161        let mut exchange = Exchange::new(Message::default());
10162        exchange.input.set_header(
10163            "CamelHttpPath",
10164            serde_json::Value::String("next".to_string()),
10165        );
10166        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10167        assert!(url.starts_with("http://example.com/base/next?"));
10168        assert!(url.contains("foo=bar"));
10169
10170        exchange.input.set_header(
10171            "CamelHttpUri",
10172            serde_json::Value::String("http://other.test/root".to_string()),
10173        );
10174        exchange.input.set_header(
10175            "CamelHttpQuery",
10176            serde_json::Value::String("a=1&b=2".to_string()),
10177        );
10178
10179        let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10180        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
10181    }
10182
10183    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
10184        let mut exchange = Exchange::new(Message::default());
10185        exchange
10186            .input
10187            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
10188        exchange.input.set_header(
10189            "CamelHttpQuery",
10190            serde_json::Value::String(query.to_string()),
10191        );
10192        exchange
10193    }
10194
10195    #[test]
10196    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
10197        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10198        cfg.bridge_endpoint = true;
10199        cfg.query_params
10200            .push(("token".to_string(), "secret".to_string()));
10201        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
10202        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10203        // Verbatim assembly: the old round-trip normalized the empty base
10204        // path to `/` (`http://x/?token=secret`); authored bytes end-to-end
10205        // no longer insert it.
10206        assert_eq!(url, "http://x?token=secret");
10207        assert!(!url.contains("/foo"));
10208        assert!(!url.contains("dropme"));
10209    }
10210
10211    #[test]
10212    fn resolve_url_bridge_endpoint_false_merges_path() {
10213        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10214        cfg.bridge_endpoint = false;
10215        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
10216        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10217        assert!(url.contains("/foo"), "url should contain /foo: {url}");
10218        assert!(
10219            url.contains("dropme=1"),
10220            "url should contain dropme=1: {url}"
10221        );
10222    }
10223
10224    #[test]
10225    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
10226        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10227        cfg.bridge_endpoint = true;
10228        let mut exchange = Exchange::new(Message::default());
10229        exchange.input.set_header(
10230            "CamelHttpPath",
10231            serde_json::Value::String("/foo".to_string()),
10232        );
10233        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10234        assert_eq!(url, "http://x");
10235        assert!(!url.contains("/foo"));
10236    }
10237
10238    #[test]
10239    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
10240        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10241        cfg.bridge_endpoint = true;
10242        // query_params stays empty ([])
10243        let mut exchange = Exchange::new(Message::default());
10244        exchange.input.set_header(
10245            "CamelHttpUri",
10246            serde_json::Value::String("http://dest/explicit".to_string()),
10247        );
10248        exchange.input.set_header(
10249            "CamelHttpPath",
10250            serde_json::Value::String("/foo".to_string()),
10251        );
10252        exchange.input.set_header(
10253            "CamelHttpQuery",
10254            serde_json::Value::String("x=1".to_string()),
10255        );
10256        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10257        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
10258        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
10259        // wins verbatim.
10260        assert_eq!(url, "http://x");
10261    }
10262
10263    #[test]
10264    fn bridge_programmatic_params_use_percent20() {
10265        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10266        cfg.bridge_endpoint = true;
10267        cfg.query_params = vec![("b".to_string(), "x y".to_string())];
10268        let exchange = Exchange::new(Message::default());
10269
10270        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10271
10272        // `%20 never +` is global for programmatic values — the bridge arm
10273        // uses the same encoder as the non-bridge path. Bridging
10274        // semantics (what gets bridged, precedence) are unchanged.
10275        assert_eq!(url, "http://x?b=x%20y");
10276        assert!(!url.contains('+'));
10277    }
10278
10279    #[test]
10280    fn bridge_arm_carries_authored_raw_query() {
10281        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
10282        // bridgeEndpoint is consumed as an endpoint option; a=1 is the
10283        // authored leftover riding raw_query.
10284        let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
10285
10286        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10287
10288        // Authored leftovers ride under bridging (Apache Camel semantics):
10289        // query is a=1 in authored bytes; exchange path/query stay ignored.
10290        assert_eq!(url, "http://h/p?a=1");
10291        assert!(!url.contains("dropme"), "exchange query leaked: {url}");
10292        assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
10293    }
10294
10295    // -----------------------------------------------------------------------
10296    // Bridge arm verbatim assembly (Papal Direction A): the bridged base is
10297    // never round-tripped through `url::Url` normalization — authored bytes
10298    // end-to-end, identical assembly to every other resolve_url arm.
10299    // -----------------------------------------------------------------------
10300
10301    #[test]
10302    fn resolve_url_bridge_preserves_dot_segments() {
10303        let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
10304        cfg.bridge_endpoint = true;
10305        cfg.query_params.push(("k".to_string(), "1".to_string()));
10306        let exchange = Exchange::new(Message::default());
10307
10308        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10309
10310        // Dot segments are authored bytes; the old round-trip collapsed
10311        // them (`/a/../b` → `/b`). Verbatim keeps them.
10312        assert_eq!(url, "http://h/a/../b?k=1");
10313    }
10314
10315    #[test]
10316    fn resolve_url_bridge_preserves_default_port() {
10317        let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
10318        cfg.bridge_endpoint = true;
10319        cfg.query_params.push(("k".to_string(), "1".to_string()));
10320        let exchange = Exchange::new(Message::default());
10321
10322        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10323
10324        // The old round-trip stripped the default port `:80`. Verbatim
10325        // keeps it.
10326        assert_eq!(url, "http://h:80/p?k=1");
10327    }
10328
10329    #[test]
10330    fn resolve_url_bridge_preserves_scheme_and_host_case() {
10331        let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
10332        cfg.bridge_endpoint = true;
10333        cfg.query_params.push(("k".to_string(), "1".to_string()));
10334        // `from_uri`'s scheme validation is case-sensitive, so the scheme
10335        // case is applied on the stored base directly — the resolve path
10336        // must carry whatever bytes the operator authored.
10337        cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
10338        let exchange = Exchange::new(Message::default());
10339
10340        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10341
10342        // The old round-trip lowercased scheme and host. Verbatim keeps
10343        // both authored.
10344        assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
10345    }
10346
10347    #[test]
10348    fn resolve_url_bridge_no_query_emits_base_verbatim() {
10349        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10350        cfg.bridge_endpoint = true;
10351        let exchange = Exchange::new(Message::default());
10352
10353        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10354
10355        // No resolved query: exactly the authored base — no synthetic `/`,
10356        // no dangling `?`.
10357        assert_eq!(url, "http://h/p");
10358    }
10359
10360    #[test]
10361    fn resolve_url_bridge_and_non_bridge_byte_identical() {
10362        // (a) Bridged arm: the effective query comes from programmatic
10363        // query_params.
10364        let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
10365        bridged.bridge_endpoint = true;
10366        bridged
10367            .query_params
10368            .push(("k".to_string(), "1".to_string()));
10369        let bridge_url =
10370            HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
10371
10372        // (b) Non-bridge CamelHttpQuery composition path: same effective
10373        // query riding the exchange header.
10374        let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
10375        let mut exchange = Exchange::new(Message::default());
10376        exchange.input.set_header(
10377            "CamelHttpQuery",
10378            serde_json::Value::String("k=1".to_string()),
10379        );
10380        let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
10381
10382        assert_eq!(bridge_url, plain_url);
10383        assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
10384    }
10385
10386    #[test]
10387    fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
10388        let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
10389        cfg.bridge_endpoint = true;
10390        cfg.query_params.push(("k".to_string(), "1".to_string()));
10391        let exchange = Exchange::new(Message::default());
10392
10393        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10394
10395        assert_eq!(url, "http://[::1]:8080/p?k=1");
10396    }
10397
10398    #[test]
10399    fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
10400        let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
10401        let exchange = Exchange::new(Message::default());
10402
10403        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10404
10405        // Authored query on an empty base path: the old round-trip
10406        // inserted a synthetic `/` (`http://h/?x=1`); verbatim does not.
10407        assert_eq!(url, "http://h?x=1");
10408    }
10409
10410    // -----------------------------------------------------------------------
10411    // Raw-preserving outbound query serialization (http-query-wire-fidelity)
10412    // -----------------------------------------------------------------------
10413
10414    #[test]
10415    fn resolve_url_preserves_authored_query_order_and_bytes() {
10416        let config =
10417            HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
10418        let exchange = Exchange::new(Message::default());
10419
10420        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10421
10422        // Authored order, authored separators, no %2C/%3A re-encoding,
10423        // consumed option (connectTimeout) removed.
10424        assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
10425    }
10426
10427    #[test]
10428    fn resolve_url_consumes_encoded_option_key() {
10429        let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
10430        let exchange = Exchange::new(Message::default());
10431
10432        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10433
10434        // The raw filter matches the decoded key, not the encoded bytes.
10435        assert_eq!(url, "http://h/p?a=1");
10436    }
10437
10438    #[test]
10439    fn resolve_url_all_options_consumed_drops_query() {
10440        let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
10441        let exchange = Exchange::new(Message::default());
10442
10443        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10444
10445        // A non-empty query whose every pair was consumed drops the query
10446        // component entirely — no dangling `?`.
10447        assert_eq!(url, "http://h/p");
10448        assert!(!url.contains('?'));
10449    }
10450
10451    #[test]
10452    fn resolve_url_preserves_empty_query_marker() {
10453        let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
10454        let exchange = Exchange::new(Message::default());
10455
10456        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10457
10458        // A bare `?` marker is preserved distinctly, never conflated with
10459        // an all-consumed query.
10460        assert_eq!(url, "http://h/p?");
10461    }
10462
10463    #[test]
10464    fn resolve_url_raw_wrapper_not_re_encoded() {
10465        let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
10466        let exchange = Exchange::new(Message::default());
10467
10468        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10469
10470        // RAW(...) wrapper bytes survive exactly as authored (rc-g4isv).
10471        assert_eq!(url, "http://h/p?token=RAW(abc)");
10472        assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
10473    }
10474
10475    #[test]
10476    fn resolve_url_camel_http_query_composes_verbatim_span() {
10477        let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
10478        let mut exchange = Exchange::new(Message::default());
10479        exchange.input.set_header(
10480            "CamelHttpQuery",
10481            serde_json::Value::String("userFilter=a%2Cb".to_string()),
10482        );
10483
10484        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10485
10486        // Policy change (ADR-0071): the header no longer replaces the
10487        // endpoint query — it composes, the endpoint winning collisions.
10488        // The header span bytes still ride verbatim: `a%2Cb` is carried
10489        // as-authored, never re-encoded (no %252C).
10490        assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
10491        assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
10492    }
10493
10494    // -----------------------------------------------------------------------
10495    // Outbound query composition (http-contract-surface, ADR-0071)
10496    // -----------------------------------------------------------------------
10497
10498    #[test]
10499    fn header_composes_with_endpoint_query() {
10500        let config =
10501            HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
10502        let mut exchange = Exchange::new(Message::default());
10503        exchange.input.set_header(
10504            "CamelHttpQuery",
10505            serde_json::Value::String("lang=es&page=2".to_string()),
10506        );
10507
10508        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10509
10510        // Higher precedence (endpoint) wins collisions: `lang` stays `en`;
10511        // the header appends only its absent keys.
10512        assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
10513    }
10514
10515    #[test]
10516    fn header_alone_still_rides() {
10517        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10518        let mut exchange = Exchange::new(Message::default());
10519        exchange.input.set_header(
10520            "CamelHttpQuery",
10521            serde_json::Value::String("page=2".to_string()),
10522        );
10523
10524        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10525
10526        // No endpoint query: the header pairs are the whole query.
10527        assert_eq!(url, "http://upstream/api?page=2");
10528    }
10529
10530    #[test]
10531    fn empty_reflected_query_leaves_endpoint_query_intact() {
10532        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
10533        let mut exchange = Exchange::new(Message::default());
10534        // The consumer installs an empty CamelHttpQuery on requests that
10535        // arrived without a query string.
10536        exchange
10537            .input
10538            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10539
10540        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10541
10542        // No second `?` marker, no dropped endpoint pair.
10543        assert_eq!(url, "http://upstream/api?apiKey=secret");
10544        assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
10545    }
10546
10547    #[test]
10548    fn forbidden_byte_in_header_query_errors() {
10549        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10550        let mut exchange = Exchange::new(Message::default());
10551        exchange.input.set_header(
10552            "CamelHttpQuery",
10553            serde_json::Value::String("q=ab<cd".to_string()),
10554        );
10555
10556        let err = HttpProducer::resolve_url(&exchange, &config)
10557            .unwrap_err()
10558            .to_string();
10559
10560        // Fail loud naming the forbidden byte (`<` = 0x3C); a resolve
10561        // error means no URL is emitted, never a re-encoded one.
10562        assert!(err.contains("0x3C"), "error must name the byte: {err}");
10563    }
10564
10565    #[test]
10566    fn override_uri_with_query_plus_header_query() {
10567        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10568        let mut exchange = Exchange::new(Message::default());
10569        exchange.input.set_header(
10570            "CamelHttpUri",
10571            serde_json::Value::String("http://host/api?a=1".to_string()),
10572        );
10573        exchange.input.set_header(
10574            "CamelHttpQuery",
10575            serde_json::Value::String("a=2&b=3".to_string()),
10576        );
10577
10578        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10579
10580        // Pair-level merge with a single `?`: the override's `a=1` wins
10581        // the collision, the header appends `b=3` — no `?a=1?a=2` concat.
10582        assert_eq!(url, "http://host/api?a=1&b=3");
10583    }
10584
10585    #[test]
10586    fn path_applies_before_query_composition() {
10587        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10588        let mut exchange = Exchange::new(Message::default());
10589        exchange.input.set_header(
10590            "CamelHttpUri",
10591            serde_json::Value::String("http://host/api?a=1".to_string()),
10592        );
10593        exchange.input.set_header(
10594            "CamelHttpPath",
10595            serde_json::Value::String("/extra".to_string()),
10596        );
10597        exchange.input.set_header(
10598            "CamelHttpQuery",
10599            serde_json::Value::String("b=2".to_string()),
10600        );
10601
10602        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10603
10604        // CamelHttpPath applies to the override base without its query,
10605        // then the query composes.
10606        assert_eq!(url, "http://host/api/extra?a=1&b=2");
10607    }
10608
10609    #[test]
10610    fn plain_proxy_reflection_composes() {
10611        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
10612        // Headers as the consumer installs them from the wire.
10613        let exchange = exchange_with_path_and_query("/in/extra", "page=2");
10614
10615        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10616
10617        // Reflection rides by default and composes: the operator pair is
10618        // not replaced (rc-k3pir parity).
10619        assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
10620    }
10621
10622    #[test]
10623    fn bridge_endpoint_ignores_url_headers() {
10624        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
10625        let mut exchange = Exchange::new(Message::default());
10626        exchange.input.set_header(
10627            "CamelHttpUri",
10628            serde_json::Value::String("http://evil.test/x".to_string()),
10629        );
10630        exchange.input.set_header(
10631            "CamelHttpPath",
10632            serde_json::Value::String("/foo".to_string()),
10633        );
10634        exchange.input.set_header(
10635            "CamelHttpQuery",
10636            serde_json::Value::String("z=9".to_string()),
10637        );
10638
10639        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10640
10641        // All three URL headers ignored; the endpoint base plus its own
10642        // (consumed-option-filtered) query is sent, exactly as before.
10643        assert_eq!(url, "http://h/p?a=1");
10644        assert!(!url.contains("evil"), "override leaked: {url}");
10645        assert!(!url.contains("z=9"), "header query leaked: {url}");
10646        assert!(!url.contains("/foo"), "header path leaked: {url}");
10647    }
10648
10649    #[test]
10650    fn resolve_url_programmatic_params_use_percent20_deterministic() {
10651        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10652        config.query_params = vec![
10653            ("b".to_string(), "x y".to_string()),
10654            ("a".to_string(), "1".to_string()),
10655        ];
10656        let exchange = Exchange::new(Message::default());
10657
10658        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10659
10660        // Declaration order (not lexical), minimal RFC-3986 encoding,
10661        // `%20` — never `+` — for spaces.
10662        assert_eq!(url, "http://h/p?b=x%20y&a=1");
10663        assert!(!url.contains('+'));
10664    }
10665
10666    #[test]
10667    fn resolve_url_authored_and_programmatic_merge() {
10668        let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
10669        config.query_params = vec![
10670            ("b".to_string(), "2".to_string()),
10671            ("a".to_string(), "9".to_string()),
10672        ];
10673        let exchange = Exchange::new(Message::default());
10674
10675        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10676
10677        // Programmatic `b` appended (absent from raw); programmatic `a=9`
10678        // ignored (authored key wins); no duplication.
10679        assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
10680    }
10681
10682    #[test]
10683    fn from_uri_no_longer_fills_query_params_from_uri() {
10684        let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
10685
10686        // Authored pairs live in raw_query ONLY (provenance pin).
10687        assert!(
10688            config.query_params.is_empty(),
10689            "query_params is programmatic-only: {:?}",
10690            config.query_params
10691        );
10692        assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
10693    }
10694
10695    #[test]
10696    fn resolve_url_forbidden_raw_byte_errors() {
10697        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10698        config.raw_query = Some("a=x y".to_string());
10699        let exchange = Exchange::new(Message::default());
10700
10701        let err = HttpProducer::resolve_url(&exchange, &config)
10702            .expect_err("literal space in raw query must error");
10703
10704        // The error names the forbidden byte; no output string is produced.
10705        assert!(
10706            err.to_string().contains("0x20"),
10707            "error must name the forbidden byte: {err}"
10708        );
10709    }
10710
10711    /// rc-m4xk1: the override URI's own query is span-validated at resolve
10712    /// time — a forbidden byte in the override arm errors naming the byte,
10713    /// instead of riding verbatim to a reqwest send error.
10714    #[test]
10715    fn resolve_url_override_query_forbidden_byte_errors() {
10716        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10717        let mut exchange = Exchange::new(Message::default());
10718        exchange.input.set_header(
10719            "CamelHttpUri",
10720            serde_json::Value::String("http://h2/p?a=x y".to_string()),
10721        );
10722
10723        let err = HttpProducer::resolve_url(&exchange, &config)
10724            .expect_err("literal space in the override URI's query must error");
10725
10726        assert!(
10727            err.to_string().contains("0x20"),
10728            "error must name the forbidden byte from the override query: {err}"
10729        );
10730    }
10731
10732    /// rc-m4xk1 pin: decoded-key collision — a header pair whose key decodes
10733    /// to a key already present in the higher-precedence query (here
10734    /// `%61=2`, decoding to `a`) is dropped by the shared decoded-key
10735    /// matching; the higher-precedence authored span rides verbatim.
10736    #[test]
10737    fn merge_header_query_decoded_key_collision_drops_header_pair() {
10738        let merged = merge_header_query(Some("a=1"), "%61=2")
10739            .expect("decoded-key collision must not be a parse error");
10740        assert_eq!(
10741            merged.as_deref(),
10742            Some("a=1"),
10743            "the higher-precedence span wins and the colliding header pair is dropped"
10744        );
10745    }
10746
10747    /// rc-m4xk1 pin: duplicate keys within ONE header query are not
10748    /// deduplicated — both spans ride verbatim in authored order.
10749    #[test]
10750    fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
10751        let merged = merge_header_query(None, "k=1&k=2")
10752            .expect("duplicate header keys must not be a parse error");
10753        assert_eq!(
10754            merged.as_deref(),
10755            Some("k=1&k=2"),
10756            "intra-header duplicate keys ride verbatim"
10757        );
10758    }
10759
10760    /// rc-dhkeo: the Debug surface masks userinfo-style bytes in
10761    /// `base_url` and leaves a userinfo-free base untouched, byte-for-byte.
10762    /// rc-yvjp3 (ADR-0076 strictest-wins): `base_url` routes through the
10763    /// canonical `camel_api::redact::redact_url` — query and fragment bytes
10764    /// now drop behind their sentinels and later `//user:pass@` windows
10765    /// mask too, dimensions the former byte-preserving local variant kept.
10766    #[test]
10767    fn endpoint_config_debug_masks_base_url_userinfo() {
10768        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10769        config.base_url = "http://user:pass@h.example/p".to_string();
10770        let rendered = format!("{config:?}");
10771        assert!(
10772            rendered.contains("***@h.example"),
10773            "userinfo must render masked: {rendered}"
10774        );
10775        assert!(
10776            !rendered.contains("user:pass"),
10777            "no credentials in Debug output: {rendered}"
10778        );
10779
10780        let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10781        let rendered_plain = format!("{plain:?}");
10782        assert!(
10783            rendered_plain.contains("http://h.example/p"),
10784            "a base without userinfo renders unchanged: {rendered_plain}"
10785        );
10786    }
10787
10788    /// rc-yvjp3 convergence: an authored query and fragment on `base_url`
10789    /// render as sentinels, never as raw bytes (strictest-wins over the
10790    /// former byte-preserving variant), and the rendered value is
10791    /// byte-identical to the canonical helper.
10792    #[test]
10793    fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
10794        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10795
10796        config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
10797        let rendered = format!("{config:?}");
10798        assert!(
10799            rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
10800            "query and fragment must render as composed sentinels: {rendered}"
10801        );
10802        assert!(
10803            !rendered.contains("token=secret") && !rendered.contains("access_token"),
10804            "query/fragment credential bytes must not render: {rendered}"
10805        );
10806
10807        config.base_url = "http://h.example//u2:p2@evil/".to_string();
10808        let rendered = format!("{config:?}");
10809        assert!(
10810            rendered.contains("base_url: \"http://h.example//***@evil/\""),
10811            "later //window userinfo must mask (canonical window rule): {rendered}"
10812        );
10813        assert!(
10814            !rendered.contains("u2:p2"),
10815            "later-window credentials must not render: {rendered}"
10816        );
10817
10818        // Cross-surface identity: the Debug field is byte-identical to the
10819        // canonical helper output for the same input.
10820        config.base_url = "http://user:pass@h.example/p?token=x".to_string();
10821        let canonical = camel_api::redact::redact_url(&config.base_url);
10822        assert_eq!(canonical, "http://***@h.example/p?[redacted]");
10823        let rendered = format!("{config:?}");
10824        assert!(
10825            rendered.contains(&format!("base_url: \"{canonical}\"")),
10826            "Debug base_url must equal canonical redact_url output: {rendered}"
10827        );
10828    }
10829
10830    /// rc-nmupb: authored apostrophe (0x27) is RFC 3986 pchar-legal, but
10831    /// reqwest's WHATWG parser re-encodes it as `%27` in every http/https
10832    /// query — the raw byte can never ride the wire verbatim. Resolve
10833    /// rejects it naming the byte; the authored `%27` escape is the
10834    /// wire-faithful form and rides verbatim.
10835    #[test]
10836    fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
10837        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10838
10839        config.raw_query = Some("q=it's".to_string());
10840        let exchange = Exchange::new(Message::default());
10841        let err = HttpProducer::resolve_url(&exchange, &config)
10842            .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
10843        assert!(
10844            err.to_string().contains("0x27"),
10845            "error must name the apostrophe byte: {err}"
10846        );
10847
10848        config.raw_query = Some("q=it%27s".to_string());
10849        let url = HttpProducer::resolve_url(&exchange, &config)
10850            .expect("authored %27 escape is wire-legal");
10851        assert!(
10852            url.contains("q=it%27s"),
10853            "the authored escape must ride byte-for-byte: {url}"
10854        );
10855
10856        // The rest of reqwest's WHATWG special-query set shares the same
10857        // rationale and is rejected alongside (`"` and backtick are not
10858        // RFC 3986 query-legal bytes; `<`/`>` likewise).
10859        for &byte in b"\"`<>" {
10860            config.raw_query = Some(format!("k={}x", byte as char));
10861            let err = HttpProducer::resolve_url(&exchange, &config)
10862                .expect_err("WHATWG special-query byte must be rejected");
10863            assert!(
10864                err.to_string().contains(&format!("0x{byte:02X}")),
10865                "error must name byte 0x{byte:02X}: {err}"
10866            );
10867        }
10868    }
10869
10870    #[test]
10871    fn armed_fence_rejects_unknown_host_redacted() {
10872        let cfg = HttpEndpointConfig::from_uri(
10873            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10874        )
10875        .unwrap();
10876        let mut exchange = Exchange::new(Message::default());
10877        exchange.input.set_header(
10878            "CamelHttpUri",
10879            serde_json::Value::String(
10880                "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
10881            ),
10882        );
10883
10884        let err = HttpProducer::resolve_url(&exchange, &cfg)
10885            .expect_err("override host outside the fence must fail resolution");
10886
10887        let message = err.to_string();
10888        assert!(!message.contains("pass"), "userinfo leaked: {message}");
10889        assert!(!message.contains("s3cret"), "query leaked: {message}");
10890    }
10891
10892    #[test]
10893    fn armed_fence_rejects_unparseable_override_redacted() {
10894        let cfg = HttpEndpointConfig::from_uri(
10895            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10896        )
10897        .unwrap();
10898        let mut exchange = Exchange::new(Message::default());
10899        exchange.input.set_header(
10900            "CamelHttpUri",
10901            serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
10902        );
10903
10904        let err = HttpProducer::resolve_url(&exchange, &cfg)
10905            .expect_err("unparseable override outside the fence must fail resolution");
10906
10907        let message = err.to_string();
10908        assert!(
10909            message.contains("allowedUriHosts fence"),
10910            "fence must be named: {message}"
10911        );
10912        assert!(
10913            message.contains("[redacted]"),
10914            "suppression sentinel missing: {message}"
10915        );
10916        assert!(
10917            !message.contains("evil.example.com"),
10918            "host leaked: fail-closed arm must render only the sentinel: {message}"
10919        );
10920        assert!(
10921            !message.contains("fencesecret"),
10922            "password leaked: {message}"
10923        );
10924        assert!(!message.contains("u:"), "userinfo leaked: {message}");
10925    }
10926
10927    #[test]
10928    fn armed_fence_rejects_password_only_userinfo_redacted() {
10929        let cfg = HttpEndpointConfig::from_uri(
10930            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10931        )
10932        .unwrap();
10933        let mut exchange = Exchange::new(Message::default());
10934        exchange.input.set_header(
10935            "CamelHttpUri",
10936            serde_json::Value::String(
10937                "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
10938            ),
10939        );
10940
10941        let err = HttpProducer::resolve_url(&exchange, &cfg)
10942            .expect_err("password-only override outside the fence must fail resolution");
10943
10944        let message = err.to_string();
10945        assert!(
10946            !message.contains("passwordonly"),
10947            "password-only userinfo leaked: {message}"
10948        );
10949        assert!(!message.contains("querysecret"), "query leaked: {message}");
10950        assert!(
10951            message.contains("http://***@evil.example.com/x?[redacted]"),
10952            "masked shape missing: {message}"
10953        );
10954    }
10955
10956    #[test]
10957    fn armed_fence_allows_listed_host() {
10958        let cfg = HttpEndpointConfig::from_uri(
10959            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10960        )
10961        .unwrap();
10962        let mut exchange = Exchange::new(Message::default());
10963        exchange.input.set_header(
10964            "CamelHttpUri",
10965            serde_json::Value::String("http://cdn.example.com/x".to_string()),
10966        );
10967
10968        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10969        assert_eq!(url, "http://cdn.example.com/x");
10970    }
10971
10972    #[test]
10973    fn host_only_entry_permits_any_port() {
10974        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
10975        let mut exchange = Exchange::new(Message::default());
10976        exchange.input.set_header(
10977            "CamelHttpUri",
10978            serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
10979        );
10980
10981        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10982        assert_eq!(url, "http://cdn.example.com:9443/x");
10983    }
10984
10985    #[test]
10986    fn unarmed_endpoint_unchanged() {
10987        let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10988        let mut exchange = Exchange::new(Message::default());
10989        exchange.input.set_header(
10990            "CamelHttpUri",
10991            serde_json::Value::String("http://any.example.com/path".to_string()),
10992        );
10993
10994        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10995        assert_eq!(url, "http://any.example.com/path");
10996    }
10997
10998    #[test]
10999    fn empty_allowlist_fails_endpoint_creation() {
11000        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
11001    }
11002
11003    #[test]
11004    fn malformed_entry_fails_endpoint_creation() {
11005        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
11006    }
11007
11008    #[test]
11009    fn fence_entry_with_path_fails_creation() {
11010        // A trailing path is a typo'd entry: silently narrowing it to the
11011        // hostname would widen or skew the fence. Reject loudly.
11012        assert!(
11013            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
11014        );
11015    }
11016
11017    #[test]
11018    fn fence_entry_with_userinfo_fails_creation() {
11019        assert!(
11020            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
11021        );
11022    }
11023
11024    #[test]
11025    fn ipv6_fence_entry_allows_bracketed_host() {
11026        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
11027        // The textual host forms differ; both parse to the same bracketed
11028        // canonical host (`[::1]`) that the entry stores, so both ride.
11029        for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
11030            let mut exchange = Exchange::new(Message::default());
11031            exchange
11032                .input
11033                .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
11034            let url = HttpProducer::resolve_url(&exchange, &cfg)
11035                .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
11036            assert_eq!(url, uri, "bracketed IPv6 override not honored");
11037        }
11038    }
11039
11040    #[test]
11041    fn dns_case_insensitive_fence_match() {
11042        // The entry is stored ASCII-lowercased, so the mixed-case option
11043        // matches the lowercase override host.
11044        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
11045        let mut exchange = Exchange::new(Message::default());
11046        exchange.input.set_header(
11047            "CamelHttpUri",
11048            serde_json::Value::String("http://cdn.example.com/x".to_string()),
11049        );
11050        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11051        assert_eq!(url, "http://cdn.example.com/x");
11052    }
11053
11054    #[test]
11055    fn fence_allowed_override_query_merges_with_header() {
11056        // Fence pass plus full composition: the override URI query is the
11057        // higher-precedence source, the header pair appends.
11058        let cfg =
11059            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
11060        let mut exchange = Exchange::new(Message::default());
11061        exchange.input.set_header(
11062            "CamelHttpUri",
11063            serde_json::Value::String("http://host.example/api?a=1".to_string()),
11064        );
11065        exchange.input.set_header(
11066            "CamelHttpQuery",
11067            serde_json::Value::String("b=2".to_string()),
11068        );
11069
11070        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11071        assert_eq!(url, "http://host.example/api?a=1&b=2");
11072    }
11073
11074    #[test]
11075    fn empty_header_with_armed_fence_leaves_no_query() {
11076        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
11077        let mut exchange = Exchange::new(Message::default());
11078        exchange.input.set_header(
11079            "CamelHttpUri",
11080            serde_json::Value::String("http://host.example/api".to_string()),
11081        );
11082        exchange
11083            .input
11084            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
11085
11086        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11087        assert_eq!(url, "http://host.example/api");
11088        assert!(!url.contains('?'), "query marker leaked: {url}");
11089    }
11090
11091    #[test]
11092    fn fence_option_is_consumed() {
11093        // A raw query on the base URI plus the fence option; no override
11094        // header. The option is consumed at parse time and must never
11095        // appear in the outbound query.
11096        let cfg =
11097            HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
11098        let exchange = Exchange::new(Message::default());
11099
11100        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11101        assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
11102        assert!(url.contains("x=1"), "authored query lost: {url}");
11103    }
11104
11105    #[tokio::test]
11106    async fn resolve_url_malformed_base_url_errors_no_panic() {
11107        use tower::ServiceExt;
11108
11109        let (url, _handle) = start_test_server().await;
11110        let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
11111        config.allow_internal = true; // test server binds 127.0.0.1
11112        let producer = HttpProducer {
11113            config: Arc::new(config),
11114            client: build_client(&HttpConfig::default(), None).expect("client must build"), // allow-unwrap(test)
11115            pinned_cache: Arc::new(PinnedClientCache::new(
11116                PINNED_CLIENT_TTL,
11117                PINNED_CLIENT_MAX_ENTRIES,
11118            )),
11119            http_config: Arc::new(HttpConfig::default()),
11120            runtime: rt(),
11121        };
11122
11123        // First call: malformed base URL propagates as an error through the
11124        // real producer path — no panic, no poisoned state (rc-ph7z2).
11125        let first = producer
11126            .clone()
11127            .oneshot(Exchange::new(Message::default()))
11128            .await;
11129        let err = first.expect_err("malformed base URL must error, not panic");
11130        assert!(
11131            err.to_string().to_lowercase().contains("url"),
11132            "error must name the malformed URL: {err}"
11133        );
11134
11135        // Second call through the SAME producer succeeds — the failure
11136        // left no poisoned state.
11137        let mut exchange = Exchange::new(Message::default());
11138        exchange.input.set_header(
11139            "CamelHttpUri",
11140            serde_json::Value::String(format!("{url}/api")),
11141        );
11142        let response = producer
11143            .oneshot(exchange)
11144            .await
11145            .expect("valid request through same producer must succeed");
11146        let status = response
11147            .input
11148            .header("CamelHttpResponseCode")
11149            .and_then(|v| v.as_u64())
11150            .unwrap();
11151        assert_eq!(status, 200);
11152    }
11153
11154    #[test]
11155    fn resolve_url_bridge_malformed_base_errors_no_panic() {
11156        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
11157        cfg.bridge_endpoint = true;
11158        cfg.query_params.push(("k".to_string(), "1".to_string()));
11159        // `from_uri` rejects the malformed authority, so the base is set on
11160        // the stored config directly (same build shape as the scheme-case
11161        // test). The bridge arm's validation-only parse (rc-ph7z2) must
11162        // surface it as an error — no panic.
11163        cfg.base_url = "http://[::1:bad".to_string();
11164        let exchange = Exchange::new(Message::default());
11165
11166        let err = HttpProducer::resolve_url(&exchange, &cfg)
11167            .expect_err("malformed bridge base URL must error");
11168        assert!(
11169            err.to_string().contains("invalid base URL"),
11170            "error must name the invalid base URL: {err}"
11171        );
11172    }
11173
11174    #[test]
11175    fn test_http_producer_helpers_status_and_size_boundaries() {
11176        assert!(HttpProducer::is_ok_status(200, (200, 299)));
11177        assert!(HttpProducer::is_ok_status(299, (200, 299)));
11178        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
11179        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
11180
11181        assert!(!exceeds_max_response_body(10, 10));
11182        assert!(exceeds_max_response_body(11, 10));
11183    }
11184
11185    // -----------------------------------------------------------------------
11186    // Content-Type inference tests
11187    // -----------------------------------------------------------------------
11188
11189    #[allow(clippy::await_holding_lock)]
11190    async fn setup_consumer_on_free_port(
11191        path: &str,
11192    ) -> (
11193        u16,
11194        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
11195        tokio_util::sync::CancellationToken,
11196    ) {
11197        use camel_component_api::ConsumerContext;
11198
11199        // ADR-0070 staged-listener law: bind, KEEP the socket, and stage it
11200        // in the ServerRegistry; the consumer's `get_or_spawn` consumes the
11201        // staged listener, so the port never returns to the ephemeral pool
11202        // between probe and serve (no bind-read-drop race).
11203        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11204        let port = listener.local_addr().unwrap().port();
11205
11206        // Hold the registry test mutex across the whole stage→spawn→ready
11207        // window so a concurrent `ServerRegistry::reset()` cannot evict the
11208        // staged listener between staging and readiness. The guard covers
11209        // stage_listener, the consumer spawn, the readiness poll and the
11210        // tail-yield loop; it releases when this helper returns.
11211        // Poison-recovering acquire: a failed sibling test must not
11212        // cascade — the mutex guards test serialization only, no
11213        // structural invariant, so recovery via into_inner is safe.
11214        let _registry_guard = lock_registry_test_mutex();
11215
11216        ServerRegistry::global()
11217            .stage_listener(listener)
11218            .await
11219            .expect("stage consumer test listener");
11220
11221        let consumer_cfg = HttpServerConfig {
11222            scheme: "http".to_string(),
11223            host: "127.0.0.1".to_string(),
11224            port,
11225            path: path.to_string(),
11226            max_request_body: 2 * 1024 * 1024,
11227            max_response_body: 10 * 1024 * 1024,
11228            max_inflight_requests: 1024,
11229            method: None,
11230            tls_config: None,
11231        };
11232        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
11233
11234        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
11235        let token = tokio_util::sync::CancellationToken::new();
11236        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
11237
11238        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11239
11240        // Readiness without a fixed wall-clock sleep: poll the registry
11241        // entry live (1ms doubling backoff, 10s deadline), then yield so
11242        // the spawned `start()` completes route registration (that tail
11243        // path has no pending timers — only the registry lock — so
11244        // scheduler yields order it deterministically behind this loop).
11245        wait_for_registry_ready("127.0.0.1", port).await;
11246        for _ in 0..8 {
11247            tokio::task::yield_now().await;
11248        }
11249
11250        (port, rx, token)
11251    }
11252
11253    /// Poll `ServerRegistry::bound_addr(host, port)` until the entry
11254    /// appears: 1ms backoff doubling per iteration, capped at 64ms, with
11255    /// a 10s deadline. Panics with a hint naming the likely causes when
11256    /// the deadline fires.
11257    async fn wait_for_registry_ready(host: &str, port: u16) {
11258        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
11259        let mut backoff = std::time::Duration::from_millis(1);
11260        while ServerRegistry::global().bound_addr(host, port).is_none() {
11261            assert!(
11262                tokio::time::Instant::now() < deadline,
11263                "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
11264            );
11265            tokio::time::sleep(backoff).await;
11266            backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
11267        }
11268    }
11269
11270    #[tokio::test(start_paused = true)]
11271    #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
11272    async fn readiness_deadline_fires_loud_with_hint() {
11273        // start_paused: the backoff sleeps and the 10s deadline run on
11274        // the mocked clock (tokio test-util dev-feature), so the loud
11275        // path costs no wall time.
11276        // Poll a key no writer can produce. Registry keys come from
11277        // either the listener's resolved IP string (staged path) or the
11278        // caller-provided host verbatim (legacy get_or_spawn path), so a
11279        // synthetic host literal that no test passes is unreachable on
11280        // BOTH paths. Binding and HOLDING the listener (never dropped,
11281        // never staged) additionally keeps its port out of the ephemeral
11282        // pool, so no concurrent test can register that port either.
11283        // (Earlier drafts polled 127.0.0.2 — rejected: macOS exposes only
11284        // 127.0.0.1 and the bind fails there, rc-dwmd; and "localhost" —
11285        // rejected: the legacy host-verbatim path could produce it.)
11286        let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11287        let port = held.local_addr().unwrap().port();
11288        wait_for_registry_ready("httpflake-unreachable-host", port).await;
11289    }
11290
11291    // -----------------------------------------------------------------------
11292    // Readiness vs concurrent registry reset (httpflake, regression RED)
11293    // -----------------------------------------------------------------------
11294
11295    #[tokio::test]
11296    async fn readiness_survives_concurrent_registry_reset() {
11297        let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11298        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
11299
11300        // Hammer thread: loop legal resets, counting a contention only
11301        // when its try-lock on the registry test mutex reports WouldBlock
11302        // (someone else held it). Poison is a sibling's panic, not
11303        // contention: recover through the poison-recovering helper
11304        // without counting it. The guard is dropped at each iteration
11305        // end.
11306        let contended_hammer = std::sync::Arc::clone(&contended);
11307        let stop_hammer = std::sync::Arc::clone(&stop);
11308        let handle = std::thread::spawn(move || {
11309            while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
11310                let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
11311                    Err(std::sync::TryLockError::WouldBlock) => {
11312                        contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
11313                        lock_registry_test_mutex()
11314                    }
11315                    Err(std::sync::TryLockError::Poisoned(_)) => lock_registry_test_mutex(),
11316                    Ok(guard) => guard,
11317                };
11318                ServerRegistry::reset();
11319            }
11320        });
11321
11322        // Drop guard: even if a setup panics, stop the hammer and join it so
11323        // the thread never outlives the test.
11324        struct StopHammerOnDrop {
11325            handle: Option<std::thread::JoinHandle<()>>,
11326            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
11327        }
11328        impl Drop for StopHammerOnDrop {
11329            fn drop(&mut self) {
11330                self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
11331                if let Some(handle) = self.handle.take() {
11332                    let _ = handle.join();
11333                }
11334            }
11335        }
11336        let _hammer_guard = StopHammerOnDrop {
11337            handle: Some(handle),
11338            stop,
11339        };
11340
11341        // Always at least 25 setups on fresh ephemeral ports; continue past
11342        // 25 only until one contended reset is observed; hard cap 50.
11343        let mut setups = 0;
11344        loop {
11345            setups += 1;
11346            let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
11347            drop(rx);
11348            token.cancel();
11349            if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
11350                || setups >= 50
11351            {
11352                break;
11353            }
11354        }
11355
11356        let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
11357        assert!(
11358            contended_hits >= 1,
11359            "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
11360        );
11361    }
11362
11363    #[tokio::test]
11364    async fn test_content_type_inferred_for_json_body() {
11365        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
11366
11367        let client = plain_http_test_client();
11368        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
11369
11370        let (http_result, _) = tokio::join!(send_fut, async {
11371            if let Some(mut envelope) = rx.recv().await {
11372                envelope.exchange.input.body =
11373                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
11374                if let Some(reply_tx) = envelope.reply_tx {
11375                    let _ = reply_tx.send(Ok(envelope.exchange));
11376                }
11377            }
11378        });
11379
11380        let resp = http_result.unwrap();
11381        assert_eq!(resp.status().as_u16(), 200);
11382        let ct = resp
11383            .headers()
11384            .get("content-type")
11385            .expect("Content-Type header should be present");
11386        assert_eq!(ct, "application/json");
11387        let body = resp.text().await.unwrap();
11388        assert_eq!(body, r#"{"message":"hello"}"#);
11389
11390        token.cancel();
11391    }
11392
11393    #[tokio::test]
11394    async fn test_content_type_inferred_for_text_body() {
11395        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
11396
11397        // build_client (not bare plain_http_test_client()): CA-store test
11398        // windows are process-visible and Client::new() panics when the
11399        // native store loads zero roots; the crate builder falls back to
11400        // webpki roots instead.
11401        let client = build_client(&HttpConfig::default(), None).expect("client must build"); // allow-unwrap(test)
11402        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).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::Text("plain text response".to_string());
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, "text/plain; charset=utf-8");
11421        let body = resp.text().await.unwrap();
11422        assert_eq!(body, "plain text response");
11423
11424        token.cancel();
11425    }
11426
11427    #[tokio::test]
11428    async fn test_content_type_inferred_for_xml_body() {
11429        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
11430
11431        let client = plain_http_test_client();
11432        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
11433
11434        let (http_result, _) = tokio::join!(send_fut, async {
11435            if let Some(mut envelope) = rx.recv().await {
11436                envelope.exchange.input.body =
11437                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
11438                if let Some(reply_tx) = envelope.reply_tx {
11439                    let _ = reply_tx.send(Ok(envelope.exchange));
11440                }
11441            }
11442        });
11443
11444        let resp = http_result.unwrap();
11445        assert_eq!(resp.status().as_u16(), 200);
11446        let ct = resp
11447            .headers()
11448            .get("content-type")
11449            .expect("Content-Type header should be present");
11450        assert_eq!(ct, "application/xml");
11451        let body = resp.text().await.unwrap();
11452        assert_eq!(body, "<root><item>value</item></root>");
11453
11454        token.cancel();
11455    }
11456
11457    #[tokio::test]
11458    async fn test_no_content_type_for_empty_body() {
11459        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
11460
11461        let client = plain_http_test_client();
11462        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
11463
11464        let (http_result, _) = tokio::join!(send_fut, async {
11465            if let Some(mut envelope) = rx.recv().await {
11466                envelope.exchange.input.body = camel_component_api::Body::Empty;
11467                if let Some(reply_tx) = envelope.reply_tx {
11468                    let _ = reply_tx.send(Ok(envelope.exchange));
11469                }
11470            }
11471        });
11472
11473        let resp = http_result.unwrap();
11474        assert_eq!(resp.status().as_u16(), 200);
11475        assert!(
11476            resp.headers().get("content-type").is_none(),
11477            "Empty body should not set Content-Type"
11478        );
11479
11480        token.cancel();
11481    }
11482
11483    #[tokio::test]
11484    async fn test_no_content_type_for_raw_bytes_body() {
11485        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
11486
11487        let client = plain_http_test_client();
11488        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
11489
11490        let (http_result, _) = tokio::join!(send_fut, async {
11491            if let Some(mut envelope) = rx.recv().await {
11492                envelope.exchange.input.body =
11493                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
11494                if let Some(reply_tx) = envelope.reply_tx {
11495                    let _ = reply_tx.send(Ok(envelope.exchange));
11496                }
11497            }
11498        });
11499
11500        let resp = http_result.unwrap();
11501        assert_eq!(resp.status().as_u16(), 200);
11502        assert!(
11503            resp.headers().get("content-type").is_none(),
11504            "Raw Bytes body should not set Content-Type"
11505        );
11506
11507        token.cancel();
11508    }
11509
11510    #[tokio::test]
11511    async fn test_content_type_from_stream_metadata() {
11512        use camel_component_api::{StreamBody, StreamMetadata};
11513        use futures::stream;
11514
11515        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
11516
11517        let client = plain_http_test_client();
11518        let send_fut = client
11519            .get(format!("http://127.0.0.1:{port}/stream-ct"))
11520            .send();
11521
11522        let (http_result, _) = tokio::join!(send_fut, async {
11523            if let Some(mut envelope) = rx.recv().await {
11524                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
11525                    vec![Ok(bytes::Bytes::from("audio data"))];
11526                let stream = Box::pin(stream::iter(chunks));
11527                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
11528                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
11529                    metadata: StreamMetadata {
11530                        size_hint: None,
11531                        content_type: Some("audio/mpeg".to_string()),
11532                        origin: None,
11533                    },
11534                });
11535                if let Some(reply_tx) = envelope.reply_tx {
11536                    let _ = reply_tx.send(Ok(envelope.exchange));
11537                }
11538            }
11539        });
11540
11541        let resp = http_result.unwrap();
11542        assert_eq!(resp.status().as_u16(), 200);
11543        let ct = resp
11544            .headers()
11545            .get("content-type")
11546            .expect("Content-Type header should be present");
11547        assert_eq!(ct, "audio/mpeg");
11548        let body = resp.text().await.unwrap();
11549        assert_eq!(body, "audio data");
11550
11551        token.cancel();
11552    }
11553
11554    #[tokio::test]
11555    async fn test_user_content_type_overrides_inferred() {
11556        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
11557
11558        let client = plain_http_test_client();
11559        let send_fut = client
11560            .get(format!("http://127.0.0.1:{port}/override-ct"))
11561            .send();
11562
11563        let (http_result, _) = tokio::join!(send_fut, async {
11564            if let Some(mut envelope) = rx.recv().await {
11565                envelope.exchange.input.body =
11566                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
11567                envelope.exchange.input.set_header(
11568                    "Content-Type",
11569                    serde_json::Value::String("text/html".to_string()),
11570                );
11571                if let Some(reply_tx) = envelope.reply_tx {
11572                    let _ = reply_tx.send(Ok(envelope.exchange));
11573                }
11574            }
11575        });
11576
11577        let resp = http_result.unwrap();
11578        assert_eq!(resp.status().as_u16(), 200);
11579        let ct = resp
11580            .headers()
11581            .get("content-type")
11582            .expect("Content-Type header should be present");
11583        assert_eq!(
11584            ct, "text/html",
11585            "User-set Content-Type should take precedence over inferred type"
11586        );
11587
11588        token.cancel();
11589    }
11590
11591    #[tokio::test]
11592    async fn test_user_content_type_with_bytes_body() {
11593        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
11594
11595        let client = plain_http_test_client();
11596        let send_fut = client
11597            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
11598            .send();
11599
11600        let (http_result, _) = tokio::join!(send_fut, async {
11601            if let Some(mut envelope) = rx.recv().await {
11602                envelope.exchange.input.body =
11603                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
11604                envelope.exchange.input.set_header(
11605                    "Content-Type",
11606                    serde_json::Value::String("application/json".to_string()),
11607                );
11608                if let Some(reply_tx) = envelope.reply_tx {
11609                    let _ = reply_tx.send(Ok(envelope.exchange));
11610                }
11611            }
11612        });
11613
11614        let resp = http_result.unwrap();
11615        assert_eq!(resp.status().as_u16(), 200);
11616        let ct = resp
11617            .headers()
11618            .get("content-type")
11619            .expect("Content-Type header should be present for Bytes body with user header");
11620        assert_eq!(
11621            ct, "application/json",
11622            "User Content-Type should be sent for Bytes body"
11623        );
11624
11625        token.cancel();
11626    }
11627
11628    // -----------------------------------------------------------------------
11629    // Server monitor tests (GRL-005)
11630    // -----------------------------------------------------------------------
11631
11632    #[tokio::test]
11633    async fn monitor_task_silent_on_clean_exit() {
11634        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
11635        let server_exited = tokio_util::sync::CancellationToken::new();
11636        // Clean exit should complete without panicking or logging errors
11637        monitor_axum_task(
11638            handle,
11639            "127.0.0.1:0".to_string(),
11640            noop_rt(),
11641            "test-monitor".into(),
11642            server_exited.clone(),
11643        )
11644        .await;
11645        // rc-szmob: a clean exit must NOT fail hosted consumers — route
11646        // stops own their termination (no CrashNotification storm on
11647        // graceful process shutdown).
11648        assert!(
11649            !server_exited.is_cancelled(),
11650            "clean server exit must not cancel server_exited"
11651        );
11652    }
11653
11654    #[tokio::test]
11655    async fn monitor_task_handles_panicked_task() {
11656        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
11657            panic!("simulated server crash");
11658        });
11659        let server_exited = tokio_util::sync::CancellationToken::new();
11660        // Should complete without panicking even though the inner task panicked
11661        monitor_axum_task(
11662            handle,
11663            "127.0.0.1:9999".to_string(),
11664            noop_rt(),
11665            "test-monitor".into(),
11666            server_exited.clone(),
11667        )
11668        .await;
11669        // rc-szmob: unexpected exit must cancel the token so every hosted
11670        // consumer fails and supervision engages (ADR-0007).
11671        assert!(
11672            server_exited.is_cancelled(),
11673            "crashed server must cancel server_exited"
11674        );
11675    }
11676
11677    // -----------------------------------------------------------------------
11678    // Credential redaction tests
11679    // -----------------------------------------------------------------------
11680
11681    #[test]
11682    fn http_auth_basic_debug_redacts_password() {
11683        let auth = HttpAuth::Basic {
11684            username: "admin".to_string(),
11685            password: "hunter2".to_string(),
11686        };
11687        let debug = format!("{:?}", auth);
11688        assert!(
11689            !debug.contains("hunter2"),
11690            "password must be redacted: {debug}"
11691        );
11692        assert!(debug.contains("admin"), "username should appear: {debug}");
11693    }
11694
11695    #[test]
11696    fn http_auth_bearer_debug_redacts_token() {
11697        let auth = HttpAuth::Bearer {
11698            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
11699        };
11700        let debug = format!("{:?}", auth);
11701        assert!(
11702            !debug.contains("eyJhbGci"),
11703            "token must be redacted: {debug}"
11704        );
11705    }
11706
11707    #[test]
11708    fn http_auth_none_debug_shows_variant() {
11709        let debug = format!("{:?}", HttpAuth::None);
11710        assert!(
11711            debug.contains("None"),
11712            "None variant should appear: {debug}"
11713        );
11714    }
11715
11716    #[test]
11717    fn http_endpoint_config_debug_redacts_auth_credentials() {
11718        let config = HttpEndpointConfig::from_uri(
11719            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
11720        )
11721        .unwrap();
11722        let debug = format!("{:?}", config);
11723        assert!(
11724            !debug.contains("secret123"),
11725            "password must be redacted in HttpEndpointConfig debug: {debug}"
11726        );
11727    }
11728
11729    #[test]
11730    fn debug_lists_all_public_fields() {
11731        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
11732        let debug = format!("{:?}", config);
11733        for field in [
11734            "base_url",
11735            "http_method",
11736            "throw_exception_on_failure",
11737            "ok_status_code_range",
11738            "response_timeout",
11739            "query_params",
11740            "raw_query",
11741            "allow_internal",
11742            "allow_cleartext",
11743            "blocked_hosts",
11744            "max_body_size",
11745            "read_timeout_ms",
11746            "max_response_bytes",
11747            "auth",
11748            "token_provider",
11749            "user_agent",
11750            "bridge_endpoint",
11751            "connection_close",
11752            "skip_request_headers",
11753            "skip_response_headers",
11754            "follow_redirects",
11755            "max_redirects",
11756        ] {
11757            assert!(
11758                debug.contains(field),
11759                "Debug output missing field '{field}': {debug}"
11760            );
11761        }
11762    }
11763
11764    // -----------------------------------------------------------------------
11765    // Static file serving tests (Task 5)
11766    // -----------------------------------------------------------------------
11767
11768    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
11769    use tower_http::services::ServeDir;
11770
11771    fn make_test_registry() -> HttpRouteRegistry {
11772        HttpRouteRegistry::new()
11773    }
11774
11775    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
11776        AppState {
11777            registry,
11778            max_request_body: 2 * 1024 * 1024,
11779            max_response_body: 10 * 1024 * 1024,
11780            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
11781        }
11782    }
11783
11784    #[allow(clippy::await_holding_lock)]
11785    #[tokio::test]
11786    async fn test_static_file_serving_serves_file_contents() {
11787        let _guard = lock_registry_test_mutex();
11788        ServerRegistry::reset();
11789
11790        // Create temp dir with test files
11791        let temp_dir =
11792            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
11793        std::fs::create_dir_all(&temp_dir).unwrap();
11794        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
11795        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
11796
11797        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11798
11799        let registry = make_test_registry();
11800        let serve_dir = ServeDir::new(&canonical_dir)
11801            .precompressed_gzip()
11802            .precompressed_br()
11803            .append_index_html_on_directories(true);
11804
11805        let mount = StaticMount {
11806            mount_path: "/".to_string(),
11807            mode: MountMode::Static,
11808            dir: canonical_dir.clone(),
11809            cache_control: "public, max-age=3600".to_string(),
11810            error_pages: std::collections::HashMap::new(),
11811            serve_dir,
11812        };
11813        registry.register_static_mount(mount).await.unwrap();
11814
11815        let state = make_test_state(registry);
11816
11817        // Test serving hello.txt
11818        let req = Request::builder()
11819            .uri("/hello.txt")
11820            .body(AxumBody::empty())
11821            .unwrap();
11822        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
11823        assert_eq!(resp.status(), StatusCode::OK);
11824        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11825            .await
11826            .unwrap();
11827        assert_eq!(&body[..], b"Hello, static world!");
11828
11829        // Test serving style.css
11830        let req = Request::builder()
11831            .uri("/style.css")
11832            .body(AxumBody::empty())
11833            .unwrap();
11834        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11835        assert_eq!(resp.status(), StatusCode::OK);
11836        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11837            .await
11838            .unwrap();
11839        assert_eq!(&body[..], b"body { color: red; }");
11840
11841        // Test 404 for non-existent file
11842        let req = Request::builder()
11843            .uri("/missing.txt")
11844            .body(AxumBody::empty())
11845            .unwrap();
11846        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
11847        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11848
11849        // Cleanup
11850        std::fs::remove_dir_all(&temp_dir).ok();
11851    }
11852
11853    #[allow(clippy::await_holding_lock)]
11854    #[tokio::test]
11855    async fn test_spa_fallback_serves_index_for_unknown_paths() {
11856        let _guard = lock_registry_test_mutex();
11857        ServerRegistry::reset();
11858
11859        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
11860        std::fs::create_dir_all(&temp_dir).unwrap();
11861        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
11862        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
11863
11864        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11865
11866        let registry = make_test_registry();
11867        let serve_dir = ServeDir::new(&canonical_dir)
11868            .precompressed_gzip()
11869            .precompressed_br()
11870            .append_index_html_on_directories(true);
11871
11872        let mount = StaticMount {
11873            mount_path: "/".to_string(),
11874            mode: MountMode::Spa,
11875            dir: canonical_dir.clone(),
11876            cache_control: "public, max-age=0".to_string(),
11877            error_pages: std::collections::HashMap::new(),
11878            serve_dir,
11879        };
11880        // Register as SPA mount
11881        registry.register_static_mount(mount).await.unwrap();
11882
11883        let state = make_test_state(registry);
11884
11885        // SPA fallback: GET /dashboard with Accept: text/html → index.html
11886        let req = Request::builder()
11887            .method("GET")
11888            .uri("/dashboard")
11889            .header("Accept", "text/html")
11890            .body(AxumBody::empty())
11891            .unwrap();
11892        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
11893        assert_eq!(resp.status(), StatusCode::OK);
11894        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11895            .await
11896            .unwrap();
11897        assert_eq!(&body[..], b"<h1>SPA App</h1>");
11898
11899        // Static file still works: GET /app.js
11900        let req = Request::builder()
11901            .method("GET")
11902            .uri("/app.js")
11903            .body(AxumBody::empty())
11904            .unwrap();
11905        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
11906        assert_eq!(resp.status(), StatusCode::OK);
11907        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11908            .await
11909            .unwrap();
11910        assert_eq!(&body[..], b"console.log('app')");
11911
11912        // No SPA fallback for JSON accept → 404
11913        let req = Request::builder()
11914            .method("GET")
11915            .uri("/api/data")
11916            .header("Accept", "application/json")
11917            .body(AxumBody::empty())
11918            .unwrap();
11919        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
11920        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11921
11922        // No SPA fallback for file extensions → 404
11923        let req = Request::builder()
11924            .method("GET")
11925            .uri("/style.css")
11926            .header("Accept", "text/html")
11927            .body(AxumBody::empty())
11928            .unwrap();
11929        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11930        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11931
11932        // Cleanup
11933        std::fs::remove_dir_all(&temp_dir).ok();
11934    }
11935
11936    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
11937    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
11938    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
11939    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
11940    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
11941    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
11942    #[allow(clippy::await_holding_lock)]
11943    async fn run_conditional_get_returns_304(mode: MountMode) {
11944        let _guard = lock_registry_test_mutex();
11945        ServerRegistry::reset();
11946
11947        let temp_dir = std::env::temp_dir().join(format!(
11948            "http_cond_get_{}_{}",
11949            if mode == MountMode::Spa {
11950                "spa"
11951            } else {
11952                "static"
11953            },
11954            std::process::id()
11955        ));
11956        std::fs::create_dir_all(&temp_dir).unwrap();
11957        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11958
11959        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11960
11961        let registry = make_test_registry();
11962        let serve_dir = ServeDir::new(&canonical_dir)
11963            .precompressed_gzip()
11964            .precompressed_br()
11965            .append_index_html_on_directories(true);
11966
11967        let mount = StaticMount {
11968            mount_path: "/".to_string(),
11969            mode,
11970            dir: canonical_dir.clone(),
11971            cache_control: "public, max-age=3600".to_string(),
11972            error_pages: std::collections::HashMap::new(),
11973            serve_dir,
11974        };
11975        registry.register_static_mount(mount).await.unwrap();
11976
11977        let state = make_test_state(registry);
11978
11979        // 1st request: normal GET → 200, capture validators.
11980        let req = Request::builder()
11981            .method("GET")
11982            .uri("/index.html")
11983            .body(AxumBody::empty())
11984            .unwrap();
11985        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11986        assert_eq!(
11987            resp.status(),
11988            StatusCode::OK,
11989            "first GET should return 200, got {}",
11990            resp.status()
11991        );
11992        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
11993        assert!(
11994            resp.headers().contains_key(http::header::CACHE_CONTROL),
11995            "200 response missing Cache-Control"
11996        );
11997        let etag = resp
11998            .headers()
11999            .get(http::header::ETAG)
12000            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
12001            .clone();
12002        let last_modified = resp
12003            .headers()
12004            .get(http::header::LAST_MODIFIED)
12005            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
12006            .clone();
12007        // Consume the body so the response is fully drained.
12008        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
12009            .await
12010            .unwrap();
12011
12012        // 2nd request: If-None-Match with the captured ETag → 304.
12013        // Unconditional: ETag presence is required (asserted above) so this
12014        // sub-test cannot silently skip on a ServeDir etag_method change.
12015        let req = Request::builder()
12016            .method("GET")
12017            .uri("/index.html")
12018            .header(http::header::IF_NONE_MATCH, etag.clone())
12019            .body(AxumBody::empty())
12020            .unwrap();
12021        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12022        assert_eq!(
12023            resp.status(),
12024            StatusCode::NOT_MODIFIED,
12025            "If-None-Match with matching ETag should return 304, got {}",
12026            resp.status()
12027        );
12028        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
12029        assert!(
12030            resp.headers().contains_key(http::header::CACHE_CONTROL),
12031            "304 (If-None-Match) missing Cache-Control"
12032        );
12033        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
12034        // response parts rebuild in serve_via_serve_dir preserves them.
12035        assert_eq!(
12036            resp.headers().get(http::header::ETAG),
12037            Some(&etag),
12038            "304 (If-None-Match) must echo the ETag validator"
12039        );
12040        assert_eq!(
12041            resp.headers().get(http::header::LAST_MODIFIED),
12042            Some(&last_modified),
12043            "304 (If-None-Match) must carry Last-Modified"
12044        );
12045
12046        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
12047        let req = Request::builder()
12048            .method("GET")
12049            .uri("/index.html")
12050            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
12051            .body(AxumBody::empty())
12052            .unwrap();
12053        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12054        assert_eq!(
12055            resp.status(),
12056            StatusCode::NOT_MODIFIED,
12057            "If-Modified-Since with matching timestamp should return 304, got {}",
12058            resp.status()
12059        );
12060        assert!(
12061            resp.headers().contains_key(http::header::CACHE_CONTROL),
12062            "304 (If-Modified-Since) missing Cache-Control"
12063        );
12064        assert_eq!(
12065            resp.headers().get(http::header::ETAG),
12066            Some(&etag),
12067            "304 (If-Modified-Since) must carry the ETag validator"
12068        );
12069        assert_eq!(
12070            resp.headers().get(http::header::LAST_MODIFIED),
12071            Some(&last_modified),
12072            "304 (If-Modified-Since) must echo Last-Modified"
12073        );
12074
12075        // Negative control: a PAST If-Modified-Since (before the file's mtime)
12076        // MUST return 200 — proving the 304 path is validator-aware, not a
12077        // blanket "always 304" regression. A future date would correctly yield
12078        // 304 since the file's mtime precedes it; that is RFC-correct 304
12079        // behaviour, not a negative control.
12080        let req = Request::builder()
12081            .method("GET")
12082            .uri("/index.html")
12083            .header(
12084                http::header::IF_MODIFIED_SINCE,
12085                "Wed, 21 Oct 2000 07:28:00 GMT",
12086            )
12087            .body(AxumBody::empty())
12088            .unwrap();
12089        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12090        assert_eq!(
12091            resp.status(),
12092            StatusCode::OK,
12093            "past If-Modified-Since should return 200 (file modified after it), got {}",
12094            resp.status()
12095        );
12096
12097        // Cleanup
12098        std::fs::remove_dir_all(&temp_dir).ok();
12099    }
12100
12101    #[tokio::test]
12102    async fn test_conditional_get_returns_304_static_mode() {
12103        run_conditional_get_returns_304(MountMode::Static).await;
12104    }
12105
12106    #[tokio::test]
12107    async fn test_conditional_get_returns_304_spa_mode() {
12108        run_conditional_get_returns_304(MountMode::Spa).await;
12109    }
12110
12111    #[allow(clippy::await_holding_lock)]
12112    #[tokio::test]
12113    async fn test_error_page_mapping_serves_custom_404() {
12114        let _guard = lock_registry_test_mutex();
12115        ServerRegistry::reset();
12116
12117        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
12118        let errors_dir = temp_dir.join("errors");
12119        std::fs::create_dir_all(&errors_dir).unwrap();
12120        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
12121        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
12122
12123        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
12124        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
12125
12126        let registry = make_test_registry();
12127        let serve_dir = ServeDir::new(&canonical_dir)
12128            .precompressed_gzip()
12129            .precompressed_br()
12130            .append_index_html_on_directories(true);
12131
12132        let mut error_pages = std::collections::HashMap::new();
12133        error_pages.insert(404, canonical_404);
12134
12135        let mount = StaticMount {
12136            mount_path: "/".to_string(),
12137            mode: MountMode::Static,
12138            dir: canonical_dir.clone(),
12139            cache_control: "public, max-age=0".to_string(),
12140            error_pages,
12141            serve_dir,
12142        };
12143        registry.register_static_mount(mount).await.unwrap();
12144
12145        let state = make_test_state(registry);
12146
12147        // Request non-existent file → custom 404 page
12148        let req = Request::builder()
12149            .method("GET")
12150            .uri("/missing.html")
12151            .body(AxumBody::empty())
12152            .unwrap();
12153        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
12154        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
12155        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12156            .await
12157            .unwrap();
12158        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
12159
12160        // Existing file still works
12161        let req = Request::builder()
12162            .method("GET")
12163            .uri("/index.html")
12164            .body(AxumBody::empty())
12165            .unwrap();
12166        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12167        assert_eq!(resp.status(), StatusCode::OK);
12168        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12169            .await
12170            .unwrap();
12171        assert_eq!(&body[..], b"<h1>Home</h1>");
12172
12173        // Cleanup
12174        std::fs::remove_dir_all(&temp_dir).ok();
12175    }
12176
12177    #[tokio::test]
12178    async fn http_consumer_returns_body_and_code_on_stop() {
12179        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
12180        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
12181        use tower::ServiceExt;
12182
12183        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
12184        let set_body_step = CompiledStep::Process {
12185            kind_hint: camel_api::SpanKindHint::Internal,
12186            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
12187                ex.input.body = Body::Text("nope".into());
12188                Box::pin(async move { Ok(ex) })
12189            }),
12190            body_contract: None,
12191            lifecycle: None,
12192            label: None,
12193            to_uri: None,
12194        };
12195        let set_status_step = CompiledStep::Process {
12196            kind_hint: camel_api::SpanKindHint::Internal,
12197            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
12198                ex.input.set_header(
12199                    "CamelHttpResponseCode",
12200                    serde_json::Value::Number(409.into()),
12201                );
12202                Box::pin(async move { Ok(ex) })
12203            }),
12204            body_contract: None,
12205            lifecycle: None,
12206            label: None,
12207            to_uri: None,
12208        };
12209        let pipeline = compose_pipeline_with_handler(
12210            vec![set_body_step, set_status_step, CompiledStep::Stop],
12211            None,
12212            PipelineRuntimeCtx::compile_time(),
12213        );
12214
12215        let ex = Exchange::new(Message::default());
12216        let result = pipeline.oneshot(ex).await;
12217        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
12218        let returned = result.unwrap();
12219        assert_eq!(returned.input.body.as_text(), Some("nope"));
12220        assert_eq!(
12221            returned
12222                .input
12223                .header("CamelHttpResponseCode")
12224                .and_then(|v| v.as_u64()),
12225            Some(409)
12226        );
12227    }
12228
12229    #[tokio::test]
12230    async fn http_consumer_returns_200_when_body_empty_on_stop() {
12231        // After ADR-0024: Stop with no body + no status header produces 200 (same as
12232        // a normal completion with no body). The 204 default is gone — users who
12233        // want 204 set CamelHttpResponseCode=204 explicitly.
12234        //
12235        // This test stays at the pipeline level (consistent with the test above).
12236        // E2E coverage of the full HTTP dispatch path is in
12237        // crates/camel-test/tests/integration_test.rs.
12238        use camel_api::{Exchange, Message};
12239        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
12240        use tower::ServiceExt;
12241
12242        let pipeline = compose_pipeline_with_handler(
12243            vec![CompiledStep::Stop],
12244            None,
12245            PipelineRuntimeCtx::compile_time(),
12246        );
12247        let ex = Exchange::new(Message::default());
12248        let result = pipeline.oneshot(ex).await;
12249        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
12250        // Body is default (empty); no CamelHttpResponseCode header was set.
12251        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
12252    }
12253
12254    // -----------------------------------------------------------------------
12255    // Task 5: Method-aware REST dispatch tests
12256    // -----------------------------------------------------------------------
12257
12258    /// Spins up an axum server on a free port with a fresh registry.
12259    /// Returns the port plus the registry so the caller can register
12260    /// REST endpoints directly.
12261    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
12262        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12263        let port = listener.local_addr().unwrap().port();
12264        let registry = HttpRouteRegistry::new();
12265        tokio::spawn(run_axum_server(
12266            listener,
12267            registry.clone(),
12268            2 * 1024 * 1024,
12269            10 * 1024 * 1024,
12270            Arc::new(tokio::sync::Semaphore::new(1024)),
12271            test_rt(),
12272            "test-route".into(),
12273        ));
12274        // Give the server a moment to start accepting.
12275        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
12276        (port, registry)
12277    }
12278
12279    /// Helper for REST integration tests: spawns a responder task that
12280    /// reads from `rx`, writes a fixed `(status, body)` back via the
12281    /// envelope's reply channel, and returns once the test request is
12282    /// satisfied.
12283    fn spawn_responder(
12284        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
12285        status: u16,
12286        body: String,
12287    ) -> tokio::task::JoinHandle<()> {
12288        tokio::spawn(async move {
12289            if let Some(envelope) = rx.recv().await {
12290                let _ = envelope.reply_tx.send(HttpReply {
12291                    status,
12292                    headers: vec![],
12293                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
12294                });
12295            }
12296        })
12297    }
12298
12299    #[tokio::test]
12300    async fn method_aware_dispatch_same_path_different_verbs() {
12301        let (port, registry) = spawn_test_server().await;
12302
12303        // Register two REST endpoints on the same path with different
12304        // methods. This is the core scenario REST DSL needs to support:
12305        // GET /users (list) and POST /users (create) must not overwrite
12306        // each other.
12307        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12308        registry
12309            .register_rest_endpoint(
12310                "GET".into(),
12311                vec![PathSegment::Literal("users".into())],
12312                get_tx,
12313            )
12314            .await;
12315
12316        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12317        registry
12318            .register_rest_endpoint(
12319                "POST".into(),
12320                vec![PathSegment::Literal("users".into())],
12321                post_tx,
12322            )
12323            .await;
12324
12325        let get_handle = spawn_responder(get_rx, 200, "list".into());
12326        let post_handle = spawn_responder(post_rx, 201, "create".into());
12327
12328        let client = plain_http_test_client();
12329
12330        // GET /users → list route
12331        let resp = client
12332            .get(format!("http://127.0.0.1:{port}/users"))
12333            .send()
12334            .await
12335            .unwrap();
12336        assert_eq!(resp.status().as_u16(), 200);
12337        let body = resp.text().await.unwrap();
12338        assert_eq!(body, "list");
12339
12340        // POST /users → create route
12341        let resp = client
12342            .post(format!("http://127.0.0.1:{port}/users"))
12343            .send()
12344            .await
12345            .unwrap();
12346        assert_eq!(resp.status().as_u16(), 201);
12347        let body = resp.text().await.unwrap();
12348        assert_eq!(body, "create");
12349
12350        let _ = tokio::join!(get_handle, post_handle);
12351    }
12352
12353    #[tokio::test]
12354    async fn method_aware_dispatch_templated_path_extracts_params() {
12355        let (port, registry) = spawn_test_server().await;
12356
12357        // Register GET /users/{id} as a templated endpoint. The
12358        // dispatcher should match `/users/42` against the template and
12359        // attach `id=42` to the envelope's path_params.
12360        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12361        registry
12362            .register_rest_endpoint(
12363                "GET".into(),
12364                vec![
12365                    PathSegment::Literal("users".into()),
12366                    PathSegment::Param("id".into()),
12367                ],
12368                tx,
12369            )
12370            .await;
12371
12372        // Spawn a responder that echoes the captured id back in the body
12373        // so the test can verify the param was set.
12374        let handle = tokio::spawn(async move {
12375            match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
12376                Ok(Some(envelope)) => {
12377                    let id = envelope.path_params.get("id").cloned().unwrap_or_default();
12378                    let _ = envelope.reply_tx.send(HttpReply {
12379                        status: 200,
12380                        headers: vec![],
12381                        body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
12382                    });
12383                }
12384                // Channel closed: task ends as today.
12385                Ok(None) => {}
12386                // Stalled: responder ends.
12387                Err(_) => {}
12388            }
12389        });
12390
12391        let client = plain_http_test_client();
12392        let resp = client
12393            .get(format!("http://127.0.0.1:{port}/users/42"))
12394            .send()
12395            .await
12396            .unwrap();
12397        assert_eq!(resp.status().as_u16(), 200);
12398        let body = resp.text().await.unwrap();
12399        assert_eq!(body, "id=42");
12400
12401        let _ = handle.await;
12402    }
12403
12404    #[tokio::test]
12405    async fn method_aware_dispatch_unmatched_method_falls_through() {
12406        // If no REST endpoint matches the method, dispatch must fall
12407        // through to the legacy api_routes lookup or static mounts. With
12408        // nothing else registered, the request gets 404 from static
12409        // dispatch.
12410        let (port, _registry) = spawn_test_server().await;
12411
12412        // Register only GET /users; a DELETE /users request has no match.
12413        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12414        _registry
12415            .register_rest_endpoint(
12416                "GET".into(),
12417                vec![PathSegment::Literal("users".into())],
12418                get_tx,
12419            )
12420            .await;
12421
12422        // Drain the GET channel in the background so the consumer side
12423        // doesn't block (we don't expect any envelopes here).
12424        let drain = tokio::spawn(async move {
12425            let mut get_rx = get_rx;
12426            loop {
12427                match tokio::time::timeout(Duration::from_secs(2), get_rx.recv()).await {
12428                    Ok(Some(_)) => continue,
12429                    Ok(None) => break, // closed: drain complete
12430                    Err(_) => break,   // stalled: drainer ends
12431                }
12432            }
12433        });
12434
12435        let client = plain_http_test_client();
12436        let resp = client
12437            .delete(format!("http://127.0.0.1:{port}/users"))
12438            .send()
12439            .await
12440            .unwrap();
12441        assert_eq!(resp.status().as_u16(), 404);
12442
12443        drop(drain);
12444    }
12445
12446    #[tokio::test]
12447    async fn regression_legacy_exact_api_route_still_works() {
12448        // A `http:` route registered without an `httpMethod=` URI param
12449        // lands in the legacy api_routes registry. The dispatcher must
12450        // still find it via exact path lookup. This guards against
12451        // regressions introduced by the new REST-aware dispatch.
12452        let (port, registry) = spawn_test_server().await;
12453
12454        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12455        registry.register_api_route("/legacy/path".into(), tx).await;
12456
12457        let handle = tokio::spawn(async move {
12458            match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
12459                Ok(Some(envelope)) => {
12460                    let _ = envelope.reply_tx.send(HttpReply {
12461                        status: 200,
12462                        headers: vec![],
12463                        body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
12464                    });
12465                }
12466                // Channel closed: task ends as today.
12467                Ok(None) => {}
12468                // Stalled: responder ends.
12469                Err(_) => {}
12470            }
12471        });
12472
12473        let client = plain_http_test_client();
12474        let resp = client
12475            .get(format!("http://127.0.0.1:{port}/legacy/path"))
12476            .send()
12477            .await
12478            .unwrap();
12479        assert_eq!(resp.status().as_u16(), 200);
12480        let body = resp.text().await.unwrap();
12481        assert_eq!(body, "legacy ok");
12482
12483        let _ = handle.await;
12484    }
12485
12486    #[allow(clippy::await_holding_lock)]
12487    #[tokio::test]
12488    async fn regression_static_mount_still_works() {
12489        // Verify that static file serving still works after the
12490        // dispatch refactor. We register a temp-dir mount and request
12491        // a file from it; the static dispatcher should serve it.
12492        let _guard = lock_registry_test_mutex();
12493        ServerRegistry::reset();
12494
12495        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
12496        std::fs::create_dir_all(&temp_dir).unwrap();
12497        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
12498        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
12499
12500        let registry = make_test_registry();
12501        let serve_dir = ServeDir::new(&canonical_dir)
12502            .precompressed_gzip()
12503            .precompressed_br()
12504            .append_index_html_on_directories(true);
12505        let mount = StaticMount {
12506            mount_path: "/".to_string(),
12507            mode: MountMode::Static,
12508            dir: canonical_dir.clone(),
12509            cache_control: "public, max-age=3600".to_string(),
12510            error_pages: std::collections::HashMap::new(),
12511            serve_dir,
12512        };
12513        registry.register_static_mount(mount).await.unwrap();
12514
12515        let state = make_test_state(registry);
12516        let req = Request::builder()
12517            .uri("/regress.txt")
12518            .body(AxumBody::empty())
12519            .unwrap();
12520        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
12521        assert_eq!(resp.status(), StatusCode::OK);
12522        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12523            .await
12524            .unwrap();
12525        assert_eq!(&body[..], b"static works");
12526
12527        std::fs::remove_dir_all(&temp_dir).ok();
12528    }
12529
12530    // -----------------------------------------------------------------------
12531    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
12532    // templated from-URI round-trip. These exercise the real axum dispatch
12533    // path (register → HTTP request → reply) so a regression in any of the
12534    // three critical fixes surfaces as a test failure rather than a silent
12535    // production 404/500.
12536    // -----------------------------------------------------------------------
12537
12538    #[tokio::test]
12539    async fn deregister_one_method_keeps_sibling_verbs() {
12540        // Review C1: stopping the GET /users consumer must NOT tear down the
12541        // live POST /users endpoint. Register both, deregister GET only,
12542        // then verify POST still dispatches.
12543        let (port, registry) = spawn_test_server().await;
12544
12545        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12546        registry
12547            .register_rest_endpoint(
12548                "GET".into(),
12549                vec![PathSegment::Literal("users".into())],
12550                get_tx,
12551            )
12552            .await;
12553
12554        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12555        registry
12556            .register_rest_endpoint(
12557                "POST".into(),
12558                vec![PathSegment::Literal("users".into())],
12559                post_tx,
12560            )
12561            .await;
12562
12563        // Drain GET in the background (no requests expected after deregister).
12564        let drain = tokio::spawn(async move {
12565            let mut get_rx = get_rx;
12566            loop {
12567                match tokio::time::timeout(Duration::from_secs(2), get_rx.recv()).await {
12568                    Ok(Some(_)) => continue,
12569                    Ok(None) => break, // closed: drain complete
12570                    Err(_) => break,   // stalled: drainer ends
12571                }
12572            }
12573        });
12574
12575        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
12576        registry.unregister_rest_endpoint("GET", "/users").await;
12577        drop(drain);
12578
12579        let post_handle = spawn_responder(post_rx, 201, "create".into());
12580
12581        let client = plain_http_test_client();
12582        // POST /users must still reach its consumer after GET was removed.
12583        let resp = client
12584            .post(format!("http://127.0.0.1:{port}/users"))
12585            .send()
12586            .await
12587            .unwrap();
12588        assert_eq!(resp.status().as_u16(), 201);
12589        assert_eq!(resp.text().await.unwrap(), "create");
12590
12591        let _ = post_handle.await;
12592    }
12593
12594    #[tokio::test]
12595    async fn dispatch_exact_legacy_beats_rest_template() {
12596        // Review C2: an exact legacy API route (`GET /api/users`, no
12597        // httpMethod) must win over a templated REST route
12598        // (`GET /api/{resource}`) for the request `/api/users`, per spec
12599        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
12600        let (port, registry) = spawn_test_server().await;
12601
12602        // Exact legacy route.
12603        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12604        registry
12605            .register_api_route("/api/users".into(), exact_tx)
12606            .await;
12607        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
12608
12609        // Templated REST route that would ALSO match /api/users.
12610        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12611        registry
12612            .register_rest_endpoint(
12613                "GET".into(),
12614                vec![
12615                    PathSegment::Literal("api".into()),
12616                    PathSegment::Param("resource".into()),
12617                ],
12618                tpl_tx,
12619            )
12620            .await;
12621        // The templated handler must NOT receive the /api/users request. If
12622        // it does, it replies "template-leak" so a future assertion could
12623        // catch it. We do NOT await this task: the exact-match branch wins
12624        // and the templated channel never receives, so awaiting would block
12625        // until the test runtime tears down.
12626        let _tpl_drain = tokio::spawn(async move {
12627            let mut tpl_rx = tpl_rx;
12628            match tokio::time::timeout(Duration::from_secs(2), tpl_rx.recv()).await {
12629                Ok(Some(env)) => {
12630                    let _ = env.reply_tx.send(HttpReply {
12631                        status: 200,
12632                        headers: vec![],
12633                        body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
12634                    });
12635                }
12636                // Channel closed: task ends as today.
12637                Ok(None) => {}
12638                // Stalled: responder ends.
12639                Err(_) => {}
12640            }
12641        });
12642
12643        let client = plain_http_test_client();
12644        let resp = client
12645            .get(format!("http://127.0.0.1:{port}/api/users"))
12646            .send()
12647            .await
12648            .unwrap();
12649        assert_eq!(resp.status().as_u16(), 200);
12650        // Exact-match handler answered — not the templated one.
12651        assert_eq!(resp.text().await.unwrap(), "exact");
12652
12653        let _ = exact_handle.await;
12654    }
12655
12656    #[tokio::test]
12657    async fn ambiguous_rest_templates_return_500_not_silent_404() {
12658        // Review C3: two equal-specificity templates that both match one
12659        // request are an ambiguous registration. At runtime this must
12660        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
12661        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
12662        let (port, registry) = spawn_test_server().await;
12663
12664        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12665        registry
12666            .register_rest_endpoint(
12667                "GET".into(),
12668                vec![
12669                    PathSegment::Literal("users".into()),
12670                    PathSegment::Param("id".into()),
12671                ],
12672                a_tx,
12673            )
12674            .await;
12675
12676        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12677        registry
12678            .register_rest_endpoint(
12679                "GET".into(),
12680                vec![
12681                    PathSegment::Literal("users".into()),
12682                    PathSegment::Param("name".into()),
12683                ],
12684                b_tx,
12685            )
12686            .await;
12687
12688        let client = plain_http_test_client();
12689        let resp = client
12690            .get(format!("http://127.0.0.1:{port}/users/42"))
12691            .send()
12692            .await
12693            .unwrap();
12694        // Ambiguous → 500 (previously a silent 404).
12695        assert_eq!(resp.status().as_u16(), 500);
12696    }
12697
12698    #[test]
12699    fn from_uri_round_trips_templated_path_with_http_method() {
12700        // Review I4: a REST-lowered from-URI like
12701        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
12702        // through HttpServerConfig::from_uri, preserving the templated path
12703        // and the (uppercased) method. This is the binding the DSL lowering
12704        // emits and the consumer reads; it was previously unasserted.
12705        use crate::UriConfig;
12706        let cfg =
12707            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
12708        assert_eq!(cfg.host, "0.0.0.0");
12709        assert_eq!(cfg.port, 8080);
12710        assert_eq!(cfg.path, "/users/{id}");
12711        assert_eq!(cfg.method.as_deref(), Some("GET"));
12712
12713        // Lower-case httpMethod is uppercased (review I5).
12714        let cfg_lc =
12715            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
12716        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
12717        assert_eq!(cfg_lc.path, "/orders");
12718    }
12719
12720    // -----------------------------------------------------------------------
12721    // rc-1dk4: TypeConversionFailed → 400 Bad Request
12722    // -----------------------------------------------------------------------
12723
12724    #[test]
12725    fn type_conversion_failed_maps_to_400() {
12726        let reply = pipeline_error_to_reply(
12727            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
12728            "/api/users",
12729        );
12730        assert_eq!(reply.status, 400);
12731        // Exactly one Content-Type header, application/json
12732        let json_ct = reply
12733            .headers
12734            .iter()
12735            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12736            .count();
12737        assert_eq!(json_ct, 1);
12738        // Body must be structured error JSON with the expected fields
12739        let body = match &reply.body {
12740            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12741            _ => panic!("expected bytes body"),
12742        };
12743        let parsed: serde_json::Value =
12744            serde_json::from_str(&body).expect("body must be valid JSON");
12745        assert_eq!(parsed["error"], "bad_request");
12746        assert_eq!(parsed["message"], "invalid JSON at line 1");
12747    }
12748
12749    #[test]
12750    fn other_error_still_maps_to_500() {
12751        let reply =
12752            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
12753        assert_eq!(reply.status, 500);
12754    }
12755
12756    #[test]
12757    fn unauthenticated_maps_to_401() {
12758        let reply = pipeline_error_to_reply(
12759            CamelError::Unauthenticated("no token".to_string()),
12760            "/api/users",
12761        );
12762        assert_eq!(reply.status, 401);
12763    }
12764
12765    #[test]
12766    fn unauthorized_maps_to_403() {
12767        let reply = pipeline_error_to_reply(
12768            CamelError::Unauthorized("forbidden".to_string()),
12769            "/api/users",
12770        );
12771        assert_eq!(reply.status, 403);
12772    }
12773
12774    #[test]
12775    fn validation_error_maps_to_400() {
12776        let reply = pipeline_error_to_reply(
12777            CamelError::ValidationError("body does not match schema".to_string()),
12778            "/api/users",
12779        );
12780        assert_eq!(reply.status, 400);
12781        let json_ct = reply
12782            .headers
12783            .iter()
12784            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12785            .count();
12786        assert_eq!(json_ct, 1);
12787        let body = match &reply.body {
12788            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12789            _ => panic!("expected bytes body"),
12790        };
12791        let parsed: serde_json::Value =
12792            serde_json::from_str(&body).expect("body must be valid JSON");
12793        assert_eq!(parsed["error"], "validation_error");
12794        assert_eq!(parsed["message"], "body does not match schema");
12795    }
12796
12797    // -----------------------------------------------------------------------
12798    // rc-hlb1q: media negotiation errors → 415 / 406
12799    // -----------------------------------------------------------------------
12800
12801    #[test]
12802    fn finalizer_maps_unsupported_media_type() {
12803        let reply = pipeline_error_to_reply(
12804            CamelError::UnsupportedMediaType {
12805                consumed: "text/plain".to_string(),
12806                declared: "application/json".to_string(),
12807            },
12808            "/x",
12809        );
12810        assert_eq!(reply.status, 415);
12811        let json_ct = reply
12812            .headers
12813            .iter()
12814            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12815            .count();
12816        assert_eq!(json_ct, 1);
12817        let body = match &reply.body {
12818            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12819            _ => panic!("expected bytes body"),
12820        };
12821        let parsed: serde_json::Value =
12822            serde_json::from_str(&body).expect("body must be valid JSON");
12823        assert_eq!(parsed["error"], "unsupported_media_type");
12824        assert_eq!(
12825            parsed["message"],
12826            "consumed text/plain, declared application/json"
12827        );
12828    }
12829
12830    #[test]
12831    fn finalizer_maps_not_acceptable() {
12832        let reply = pipeline_error_to_reply(
12833            CamelError::NotAcceptable {
12834                accept: "application/xml".to_string(),
12835                produced: "application/json".to_string(),
12836            },
12837            "/x",
12838        );
12839        assert_eq!(reply.status, 406);
12840        let json_ct = reply
12841            .headers
12842            .iter()
12843            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12844            .count();
12845        assert_eq!(json_ct, 1);
12846        let body = match &reply.body {
12847            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12848            _ => panic!("expected bytes body"),
12849        };
12850        let parsed: serde_json::Value =
12851            serde_json::from_str(&body).expect("body must be valid JSON");
12852        assert_eq!(parsed["error"], "not_acceptable");
12853        assert_eq!(
12854            parsed["message"],
12855            "accept application/xml, produced application/json"
12856        );
12857    }
12858
12859    #[test]
12860    fn json_error_reply_preserves_empty_message() {
12861        let reply = json_error_reply(400, "bad_request", "".to_string());
12862        assert_eq!(reply.status, 400);
12863        let json_ct = reply
12864            .headers
12865            .iter()
12866            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12867            .count();
12868        assert_eq!(json_ct, 1);
12869        let body = match &reply.body {
12870            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12871            _ => panic!("expected bytes body"),
12872        };
12873        let parsed: serde_json::Value =
12874            serde_json::from_str(&body).expect("body must be valid JSON");
12875        assert_eq!(parsed["error"], "bad_request");
12876        assert_eq!(parsed["message"], "");
12877    }
12878
12879    #[test]
12880    fn https_consumer_without_tls_cert_errors() {
12881        let endpoint = HttpEndpoint {
12882            uri: "https://0.0.0.0:8443/api".to_string(),
12883            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12884            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12885            client: plain_http_test_client(),
12886            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12887                PINNED_CLIENT_TTL,
12888                PINNED_CLIENT_MAX_ENTRIES,
12889            )),
12890            http_config: HttpConfig::default(),
12891        };
12892        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12893        let result = endpoint.create_consumer(rt);
12894        assert!(result.is_err(), "expected error for https without tls cert");
12895        if let Err(e) = result {
12896            let msg = e.to_string();
12897            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
12898        }
12899    }
12900
12901    #[test]
12902    fn http_consumer_with_tls_config_errors() {
12903        let endpoint = HttpEndpoint {
12904            uri: "http://0.0.0.0:8080/api".to_string(),
12905            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
12906            server_config: HttpServerConfig::from_uri(
12907                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
12908            )
12909            .unwrap(),
12910            client: plain_http_test_client(),
12911            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12912                PINNED_CLIENT_TTL,
12913                PINNED_CLIENT_MAX_ENTRIES,
12914            )),
12915            http_config: HttpConfig::default(),
12916        };
12917        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12918        let result = endpoint.create_consumer(rt);
12919        assert!(result.is_err(), "expected error for http with tls config");
12920        if let Err(e) = result {
12921            let msg = e.to_string();
12922            assert!(msg.contains("https"), "error must mention https: {msg}");
12923        }
12924    }
12925
12926    #[test]
12927    fn https_consumer_with_partial_tls_cert_only_errors() {
12928        // tlsCert without tlsKey → tls_config is None at parse time
12929        // → create_consumer sees https:// + no TLS → must error
12930        let server_config =
12931            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12932        assert!(
12933            server_config.tls_config.is_none(),
12934            "partial tlsCert must not create ServerTlsConfig"
12935        );
12936        let endpoint = HttpEndpoint {
12937            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
12938            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
12939                .unwrap(),
12940            server_config,
12941            client: plain_http_test_client(),
12942            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12943                PINNED_CLIENT_TTL,
12944                PINNED_CLIENT_MAX_ENTRIES,
12945            )),
12946            http_config: HttpConfig::default(),
12947        };
12948        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12949        let result = endpoint.create_consumer(rt);
12950        assert!(
12951            result.is_err(),
12952            "must error: https:// requires both tlsCert and tlsKey"
12953        );
12954    }
12955
12956    #[test]
12957    fn load_tls_config_parses_valid_pem() {
12958        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
12959        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12960        use camel_component_api::test_support::tls;
12961        let (_, cert_pem, key_pem) = tls::gen_server_cert();
12962        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
12963        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
12964
12965        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
12966        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
12967    }
12968
12969    #[tokio::test(flavor = "multi_thread")]
12970    #[allow(clippy::await_holding_lock)]
12971    async fn consumer_tls_handshake_roundtrip() {
12972        use camel_component_api::test_support::tls;
12973        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12974
12975        // Install rustls crypto provider (aws-lc-rs)
12976        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12977
12978        // Serialize against global ServerRegistry singleton
12979        let _guard = lock_registry_test_mutex();
12980
12981        // Generate CA + server cert
12982        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
12983        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
12984        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
12985        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
12986
12987        // Get ephemeral port
12988        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12989        let port = probe.local_addr().unwrap().port();
12990        drop(probe);
12991
12992        ServerRegistry::reset();
12993
12994        // Create real HttpComponent + endpoint with TLS URI
12995        let component = HttpComponent::new();
12996        let endpoint_ctx = NoOpComponentContext;
12997        let uri = format!(
12998            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12999            cert_path.to_string_lossy(),
13000            key_path.to_string_lossy(),
13001        );
13002        let endpoint = component
13003            .create_endpoint(&uri, &endpoint_ctx)
13004            .expect("create TLS endpoint");
13005        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
13006
13007        // Start consumer — this calls get_or_spawn with tls_config
13008        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13009        let token = tokio_util::sync::CancellationToken::new();
13010        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
13011        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13012
13013        // Give server time to start
13014        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
13015
13016        // Client with CA cert — REAL verification (no danger_accept_invalid)
13017        let ca_bytes = std::fs::read(&ca_path).unwrap();
13018        let client = reqwest::Client::builder()
13019            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
13020            .build()
13021            .unwrap();
13022
13023        let send_fut = client
13024            .post(format!("https://localhost:{port}/test"))
13025            .body("ping")
13026            .send();
13027
13028        // Handler: receive envelope, reply 200 with "pong" body
13029        let (http_result, _) = tokio::join!(send_fut, async {
13030            if let Some(mut envelope) = rx.recv().await {
13031                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
13032                if let Some(reply_tx) = envelope.reply_tx {
13033                    let _ = reply_tx.send(Ok(envelope.exchange));
13034                }
13035            }
13036        });
13037
13038        let resp = http_result.expect("TLS handshake + request must succeed");
13039
13040        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
13041        let body = resp.text().await.unwrap();
13042        assert_eq!(body, "pong");
13043
13044        token.cancel();
13045    }
13046
13047    #[tokio::test(flavor = "multi_thread")]
13048    #[allow(clippy::await_holding_lock)]
13049    async fn consumer_tls_rejects_client_without_ca() {
13050        use camel_component_api::test_support::tls;
13051        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
13052
13053        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
13054
13055        // Serialize against global ServerRegistry singleton
13056        let _guard = lock_registry_test_mutex();
13057
13058        let (_, cert_pem, key_pem) = tls::gen_server_cert();
13059        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
13060        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
13061
13062        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13063        let port = probe.local_addr().unwrap().port();
13064        drop(probe);
13065
13066        ServerRegistry::reset();
13067
13068        // Spawn TLS server via real HttpComponent path
13069        let component = HttpComponent::new();
13070        let endpoint_ctx = NoOpComponentContext;
13071        let uri = format!(
13072            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
13073            cert_path.to_string_lossy(),
13074            key_path.to_string_lossy(),
13075        );
13076        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
13077        let mut consumer = endpoint.create_consumer(rt()).unwrap();
13078        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13079        let token = tokio_util::sync::CancellationToken::new();
13080        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
13081        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13082
13083        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
13084
13085        // Client WITHOUT CA cert — must fail TLS verification
13086        let client = plain_http_test_client();
13087
13088        let result = client
13089            .get(format!("https://localhost:{port}/test"))
13090            .send()
13091            .await;
13092
13093        assert!(
13094            result.is_err(),
13095            "must reject without CA — proves real verification"
13096        );
13097
13098        token.cancel();
13099    }
13100
13101    #[test]
13102    fn server_config_partial_tls_cert_without_key() {
13103        // Parse URI with only tlsCert (no tlsKey)
13104        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
13105        // Partial params → tls_config must be None
13106        assert!(cfg.tls_config.is_none());
13107    }
13108
13109    #[test]
13110    fn endpoint_uri_options_count_parity() {
13111        // Mirror struct must stay in sync with bespoke from_components parser.
13112        assert_eq!(
13113            HttpEndpointConfig::uri_options().len(),
13114            23,
13115            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
13116        );
13117    }
13118
13119    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
13120        pairs
13121            .iter()
13122            .map(|(k, v)| {
13123                (
13124                    (*k).to_string(),
13125                    serde_json::Value::String((*v).to_string()),
13126                )
13127            })
13128            .collect()
13129    }
13130
13131    #[test]
13132    fn response_emits_cache_control_via_pragma_warning() {
13133        let headers = make_headers(&[
13134            ("Cache-Control", "public, max-age=3600"),
13135            ("Via", "1.1 myproxy"),
13136            ("Pragma", "no-cache"),
13137            ("Warning", "199 misc"),
13138        ]);
13139        let selected = select_response_headers(&headers, None, None);
13140        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13141        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
13142            assert!(
13143                names.contains(&expected),
13144                "{expected} should pass through to the response"
13145            );
13146        }
13147    }
13148
13149    #[test]
13150    fn response_excludes_request_only_and_server_owned() {
13151        let headers = make_headers(&[
13152            ("User-Agent", "x"),
13153            ("Accept", "*/*"),
13154            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
13155        ]);
13156        let selected = select_response_headers(&headers, None, None);
13157        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13158        for excluded in ["User-Agent", "Accept", "Date"] {
13159            assert!(
13160                !names.contains(&excluded),
13161                "{excluded} should NOT appear in the response"
13162            );
13163        }
13164    }
13165
13166    #[test]
13167    fn response_re_derives_content_type() {
13168        let headers = make_headers(&[("Content-Type", "text/plain")]);
13169        let selected = select_response_headers(&headers, Some("application/json".into()), None);
13170        let ct_entries: Vec<&str> = selected
13171            .iter()
13172            .filter(|(k, _)| k == "Content-Type")
13173            .map(|(_, v)| v.as_str())
13174            .collect();
13175        assert_eq!(
13176            ct_entries,
13177            ["application/json"],
13178            "exactly one Content-Type entry, re-derived from user_content_type"
13179        );
13180    }
13181
13182    #[test]
13183    fn response_excludes_camel_headers() {
13184        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
13185        let selected = select_response_headers(&headers, None, None);
13186        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13187        assert!(
13188            !names.contains(&"CamelHttpPath"),
13189            "Camel-namespace headers must be excluded"
13190        );
13191        assert!(
13192            names.contains(&"Cache-Control"),
13193            "Cache-Control must pass through"
13194        );
13195    }
13196
13197    #[test]
13198    fn response_stringifies_scalar_header_values() {
13199        let mut headers = make_headers(&[("X-Label", "keep")]);
13200        headers.insert("X-Retries".to_string(), serde_json::json!(3));
13201        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
13202        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
13203        let selected = select_response_headers(&headers, None, None);
13204        let get = |name: &str| -> Option<&str> {
13205            selected
13206                .iter()
13207                .find(|(k, _)| k == name)
13208                .map(|(_, v)| v.as_str())
13209        };
13210        assert_eq!(
13211            get("X-Retries"),
13212            Some("3"),
13213            "integer header must be stringified"
13214        );
13215        assert_eq!(
13216            get("X-Ratio"),
13217            Some("3.5"),
13218            "float header must be stringified"
13219        );
13220        assert_eq!(
13221            get("X-Enabled"),
13222            Some("true"),
13223            "bool header must be stringified"
13224        );
13225        assert_eq!(
13226            get("X-Label"),
13227            Some("keep"),
13228            "string header must pass through"
13229        );
13230    }
13231
13232    #[test]
13233    fn response_drops_null_and_structured_header_values() {
13234        let mut headers = make_headers(&[("X-Keep", "yes")]);
13235        headers.insert("X-Null".to_string(), serde_json::Value::Null);
13236        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
13237        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
13238        let selected = select_response_headers(&headers, None, None);
13239        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13240        for dropped in ["X-Null", "X-Obj", "X-Arr"] {
13241            assert!(
13242                !names.contains(&dropped),
13243                "{dropped} must not be emitted: no single-value form"
13244            );
13245        }
13246        assert!(names.contains(&"X-Keep"), "scalar headers must survive");
13247    }
13248
13249    #[test]
13250    fn response_stringifies_scalars_despite_excluded_names() {
13251        // Excluded names stay excluded regardless of value type: the policy
13252        // filter runs before stringification, so numeric values cannot smuggle
13253        // content-length or server-owned headers into the reply.
13254        let mut headers = HashMap::new();
13255        headers.insert("Content-Length".to_string(), serde_json::json!(999));
13256        headers.insert("Date".to_string(), serde_json::json!(12345));
13257        let selected = select_response_headers(&headers, None, None);
13258        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13259        assert!(
13260            !names.contains(&"Content-Length"),
13261            "content-length is re-derived by the server"
13262        );
13263        assert!(!names.contains(&"Date"), "date is server-owned");
13264    }
13265
13266    #[test]
13267    fn outbound_stringifies_scalar_header_values() {
13268        let mut headers = make_headers(&[("X-Label", "keep")]);
13269        headers.insert("X-Retries".to_string(), serde_json::json!(3));
13270        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
13271        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
13272        let outbound = select_outbound_headers(&headers, &[], &[]);
13273        // HeaderName construction lowercases; lookups compare case-blind.
13274        let get = |name: &str| -> Option<String> {
13275            outbound
13276                .accepted
13277                .iter()
13278                .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13279                .map(|(_, v)| v.to_str().unwrap().to_string())
13280        };
13281        assert_eq!(
13282            get("X-Retries").as_deref(),
13283            Some("3"),
13284            "integer header must be stringified"
13285        );
13286        assert_eq!(
13287            get("X-Ratio").as_deref(),
13288            Some("3.5"),
13289            "float header must be stringified"
13290        );
13291        assert_eq!(
13292            get("X-Enabled").as_deref(),
13293            Some("true"),
13294            "bool header must be stringified"
13295        );
13296        assert_eq!(
13297            get("X-Label").as_deref(),
13298            Some("keep"),
13299            "string header must pass through"
13300        );
13301        assert!(outbound.drops.is_empty(), "scalar headers must not drop");
13302    }
13303
13304    #[test]
13305    fn outbound_drops_null_and_structured_header_values() {
13306        let mut headers = make_headers(&[("X-Keep", "yes")]);
13307        headers.insert("X-Null".to_string(), serde_json::Value::Null);
13308        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
13309        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
13310        let outbound = select_outbound_headers(&headers, &[], &[]);
13311        let has = |name: &str| {
13312            outbound
13313                .accepted
13314                .iter()
13315                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13316        };
13317        assert!(has("X-Keep"), "scalar headers must survive");
13318        for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
13319            let dropped = outbound
13320                .drops
13321                .iter()
13322                .find(|d| d.name == name)
13323                .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
13324            assert_eq!(
13325                dropped.reason, "no scalar string form",
13326                "{name} drop reason must name the value kind absence"
13327            );
13328            assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
13329        }
13330    }
13331
13332    #[test]
13333    fn outbound_stringifies_scalars_despite_excluded_names() {
13334        // Excluded names stay excluded regardless of value type: the policy
13335        // filter runs before stringification, so numeric values cannot smuggle
13336        // hop-by-hop or client-derived headers onto the wire.
13337        let mut headers = HashMap::new();
13338        headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
13339        headers.insert("Host".to_string(), serde_json::json!(12345));
13340        headers.insert("X-Ok".to_string(), serde_json::json!(7));
13341        let outbound = select_outbound_headers(&headers, &[], &[]);
13342        let has = |name: &str| {
13343            outbound
13344                .accepted
13345                .iter()
13346                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13347        };
13348        assert!(
13349            !has("Transfer-Encoding"),
13350            "hop-by-hop header must stay excluded"
13351        );
13352        assert!(!has("Host"), "host is destination-derived");
13353        assert!(has("X-Ok"), "non-excluded scalar must be stringified");
13354        assert!(
13355            outbound
13356                .drops
13357                .iter()
13358                .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
13359            "policy drop must be recorded before coercion"
13360        );
13361    }
13362
13363    #[test]
13364    fn outbound_drops_invalid_names_values_and_skip_config() {
13365        let mut headers = make_headers(&[("X-Good", "fine")]);
13366        headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
13367        headers.insert(
13368            "X-Control-Value".to_string(),
13369            serde_json::json!("line1\nline2"),
13370        );
13371        headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
13372        headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
13373        let skip = vec!["x-secret".to_string()];
13374        let outbound = select_outbound_headers(&headers, &skip, &[]);
13375        let has = |name: &str| {
13376            outbound
13377                .accepted
13378                .iter()
13379                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13380        };
13381        assert!(has("X-Good"), "valid header must survive");
13382        assert!(!has("X Bad Name"), "invalid header name must drop");
13383        assert!(!has("X-Control-Value"), "control-char value must drop");
13384        assert!(!has("X-Secret"), "skipped header must drop");
13385        assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
13386        let reason = |n: &str| {
13387            outbound
13388                .drops
13389                .iter()
13390                .find(|d| d.name == n)
13391                .map(|d| d.reason)
13392        };
13393        assert_eq!(reason("X Bad Name"), Some("invalid header name"));
13394        assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
13395        assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
13396        assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
13397    }
13398
13399    #[test]
13400    fn constructed_header_invalid_value_returns_drop_record() {
13401        let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
13402        let Err(record) = result else {
13403            panic!("invalid value must produce a drop record");
13404        };
13405        assert_eq!(record.reason, "invalid header value");
13406        assert_eq!(record.name, "user-agent");
13407        assert!(record.value_kind.is_none());
13408        let debug = format!("{record:?}");
13409        assert!(
13410            !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
13411            "drop record debug must not leak the value"
13412        );
13413    }
13414
13415    #[test]
13416    fn constructed_header_invalid_name_returns_drop_record() {
13417        let result = constructed_header("bad name", "ok");
13418        let Err(record) = result else {
13419            panic!("invalid name must produce a drop record");
13420        };
13421        assert_eq!(record.reason, "invalid header name");
13422        assert_eq!(record.name, "bad name");
13423        let debug = format!("{record:?}");
13424        assert!(
13425            !debug.contains("ok"),
13426            "drop record debug must not leak the value"
13427        );
13428    }
13429
13430    #[test]
13431    fn constructed_header_valid_pair_roundtrip() {
13432        let result = constructed_header("authorization", "Bearer abc123");
13433        let Ok((name, val)) = result else {
13434            panic!("valid pair must construct");
13435        };
13436        assert_eq!(name.as_str(), "authorization");
13437        let Ok(roundtrip) = val.to_str() else {
13438            panic!("valid value must roundtrip to str");
13439        };
13440        assert_eq!(roundtrip, "Bearer abc123");
13441    }
13442
13443    // -----------------------------------------------------------------------
13444    // Bridge proxy end-to-end integration tests (Task 4.1)
13445    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
13446    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
13447    // -----------------------------------------------------------------------
13448
13449    /// Destination server that captures the outbound request line and the
13450    /// `Host:` header the producer actually sent on the wire. Returns
13451    /// `(host_value, request_line)` so a bridge-proxy test can assert that
13452    /// the producer derived `Host` from the destination (not the exchange)
13453    /// and honoured bridging semantics for the path.
13454    async fn start_host_capturing_destination() -> (
13455        String,
13456        Arc<std::sync::Mutex<Option<(String, String)>>>,
13457        tokio::task::JoinHandle<()>,
13458    ) {
13459        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13460        let port = listener.local_addr().unwrap().port();
13461        let url = format!("http://127.0.0.1:{port}");
13462        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
13463            Arc::new(std::sync::Mutex::new(None));
13464        let captured_clone = Arc::clone(&captured);
13465        let handle = tokio::spawn(async move {
13466            use tokio::io::{AsyncReadExt, AsyncWriteExt};
13467            if let Ok((mut stream, _)) = listener.accept().await {
13468                let mut buf = vec![0u8; 16384];
13469                let n = stream.read(&mut buf).await.unwrap_or(0);
13470                let request = String::from_utf8_lossy(&buf[..n]).to_string();
13471                if request.contains("\r\n\r\n") {
13472                    let request_line = request.lines().next().unwrap_or("").to_string();
13473                    let host_value = request
13474                        .lines()
13475                        .find(|l| l.to_lowercase().starts_with("host:"))
13476                        .and_then(|l| l.split_once(':'))
13477                        .map(|(_, v)| v.trim().to_string())
13478                        .unwrap_or_default();
13479                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
13480                }
13481                let body = r#"{"echo":"ok"}"#;
13482                let resp = format!(
13483                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
13484                    body.len(),
13485                    body
13486                );
13487                let _ = stream.write_all(resp.as_bytes()).await;
13488            }
13489        });
13490        (url, captured, handle)
13491    }
13492
13493    /// A bridging producer must derive `Host` from the destination URL and
13494    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
13495    /// semantics. The wire-level proof is the raw `Host:` header and request
13496    /// line captured at the destination TCP socket.
13497    #[tokio::test]
13498    async fn bridge_proxy_outbound_host_matches_destination() {
13499        use tower::ServiceExt;
13500
13501        let (url, captured, _handle) = start_host_capturing_destination().await;
13502        // The Host header reqwest derives for http://127.0.0.1:{port} is the
13503        // authority, scheme-stripped: "127.0.0.1:{port}".
13504        let expected_host = url.strip_prefix("http://").unwrap();
13505
13506        let ctx = test_producer_ctx();
13507        let component = HttpComponent::new();
13508        let endpoint_ctx = NoOpComponentContext;
13509        let endpoint = component
13510            .create_endpoint(
13511                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
13512                &endpoint_ctx,
13513            )
13514            .unwrap();
13515        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
13516
13517        // Exchange carries a stale Host and a CamelHttpPath that bridging
13518        // must drop.
13519        let mut exchange = Exchange::new(Message::default());
13520        exchange.input.set_header("Host", "localhost");
13521        exchange.input.set_header("CamelHttpPath", "/foo");
13522
13523        let result = producer.oneshot(exchange).await;
13524        assert!(result.is_ok(), "producer call failed: {:?}", result);
13525
13526        tokio::time::sleep(Duration::from_millis(100)).await;
13527        let (host_value, request_line) = captured
13528            .lock()
13529            .unwrap()
13530            .take()
13531            .expect("destination capture mutex empty — producer did not reach the destination");
13532
13533        assert_ne!(
13534            host_value, "localhost",
13535            "bridge producer must not forward the exchange Host: localhost"
13536        );
13537        assert_eq!(
13538            host_value, expected_host,
13539            "Host must be derived from the destination authority (no scheme)"
13540        );
13541        assert!(
13542            !request_line.contains("/foo"),
13543            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
13544        );
13545    }
13546
13547    /// A response header set by the route (`Cache-Control`) must survive to
13548    /// the wire. The assertion is on the reqwest HTTP response — not an
13549    /// in-process HttpReply struct — so it proves the consumer's reply
13550    /// finaliser emitted the header over the socket.
13551    #[tokio::test]
13552    async fn bridge_proxy_route_set_response_header_survives() {
13553        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
13554
13555        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13556        let port = listener.local_addr().unwrap().port();
13557        drop(listener);
13558
13559        let component = HttpComponent::new();
13560        let endpoint_ctx = NoOpComponentContext;
13561        let endpoint = component
13562            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
13563            .unwrap();
13564        let mut consumer = endpoint.create_consumer(rt()).unwrap();
13565
13566        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13567        let token = tokio_util::sync::CancellationToken::new();
13568        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
13569
13570        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13571        tokio::time::sleep(Duration::from_millis(50)).await;
13572
13573        let client = plain_http_test_client();
13574        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
13575
13576        // Route sets Cache-Control on the outbound reply (exchange.input is
13577        // the message the reply finaliser reads — see select_response_headers
13578        // at the dispatch site).
13579        let (http_result, _) = tokio::join!(send_fut, async {
13580            if let Some(mut envelope) = rx.recv().await {
13581                envelope
13582                    .exchange
13583                    .input
13584                    .set_header("Cache-Control", "public, max-age=3600");
13585                if let Some(reply_tx) = envelope.reply_tx {
13586                    let _ = reply_tx.send(Ok(envelope.exchange));
13587                }
13588            }
13589        });
13590
13591        let resp = http_result.unwrap();
13592        assert_eq!(resp.status().as_u16(), 200);
13593
13594        let cache_control = resp.headers().get("cache-control");
13595        assert!(
13596            cache_control.is_some(),
13597            "Cache-Control header must survive to the wire response"
13598        );
13599        assert_eq!(
13600            cache_control.unwrap().to_str().unwrap(),
13601            "public, max-age=3600"
13602        );
13603
13604        token.cancel();
13605    }
13606
13607    // -----------------------------------------------------------------------
13608    // credential-sources task 2.3: credential values stay out of diagnostics
13609    // -----------------------------------------------------------------------
13610    //
13611    // camel-http has no request access log (design.md "Redaction sinks",
13612    // ADR-0051). The only diagnostic sink on the failed-auth path is
13613    // `pipeline_error_to_reply`, which renders the (generic) error message and
13614    // the *configured* route path — never the request URI, query string, or
13615    // extracted credential. These tests pin that redact-by-construction
13616    // contract: a sentinel credential presented in a declared source must not
13617    // appear in the reply body nor in any tracing record emitted while the
13618    // request is handled.
13619    //
13620    // Capture scope: `#[traced_test]` installs a per-crate env filter
13621    // (`camel_component_http=trace`), so records from OTHER targets
13622    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
13623    // redaction contract for those crates is guarded by their own tests.
13624    // Revisit this capture scope if camel-auth ever logs on the auth path.
13625    use camel_api::security_policy::CredentialSource;
13626    use camel_auth::credential_source::extract_token_from_exchange;
13627    use camel_auth::native_auth::NativeCredentialStore;
13628    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
13629
13630    // Sentinel credential values — test fixtures only, not real secrets.
13631    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
13632    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
13633    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
13634
13635    /// Build the exchange the consumer would build for a request envelope:
13636    /// standard Camel HTTP headers plus title-cased forwarded request headers.
13637    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
13638        let mut msg = Message::default();
13639        msg.set_header(
13640            "CamelHttpMethod",
13641            serde_json::Value::String(envelope.method.clone()),
13642        );
13643        msg.set_header(
13644            "CamelHttpPath",
13645            serde_json::Value::String(envelope.path.clone()),
13646        );
13647        msg.set_header(
13648            "CamelHttpQuery",
13649            serde_json::Value::String(envelope.query.clone()),
13650        );
13651        for (k, v) in &envelope.headers {
13652            if let Ok(val_str) = v.to_str() {
13653                msg.set_header(
13654                    title_case_header(k.as_str()),
13655                    serde_json::Value::String(val_str.to_string()),
13656                );
13657            }
13658        }
13659        Exchange::new(msg)
13660    }
13661
13662    /// Register a route whose responder authenticates each request against an
13663    /// empty native store, so every presented credential fails lookup with
13664    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
13665    /// authentication step (extract per `sources` → authenticate → deny) so the
13666    /// credential-extraction redaction contract is exercised on a real
13667    /// authentication failure.
13668    async fn spawn_failing_auth_route(
13669        registry: &HttpRouteRegistry,
13670        path: &str,
13671        sources: Vec<CredentialSource>,
13672    ) {
13673        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
13674            NativeCredentialStore::try_new(vec![]).unwrap(),
13675        ));
13676        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
13677        registry.register_api_route(path.to_string(), tx).await;
13678        let path_owned = path.to_string();
13679        tokio::spawn(async move {
13680            while let Some(envelope) = rx.recv().await {
13681                let exchange = envelope_to_exchange(&envelope);
13682                let reply_tx = envelope.reply_tx;
13683                let result: Result<(), CamelError> = async {
13684                    let token = extract_token_from_exchange(&exchange, &sources)
13685                        .map(|extracted| extracted.token)
13686                        .ok_or_else(|| {
13687                            CamelError::Unauthenticated("no credential in any source".into())
13688                        })?;
13689                    authenticator.authenticate_bearer(&token).await?;
13690                    Ok(())
13691                }
13692                .await;
13693                let reply = match result {
13694                    Ok(()) => HttpReply {
13695                        status: 200,
13696                        headers: vec![],
13697                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
13698                    },
13699                    Err(e) => pipeline_error_to_reply(e, &path_owned),
13700                };
13701                let _ = reply_tx.send(reply);
13702            }
13703        });
13704    }
13705
13706    /// Whether any tracing record captured so far (process-wide) contains
13707    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
13708    /// shared buffer, so logs from spawned request-handling tasks are included.
13709    fn captured_logs_contain(needle: &str) -> bool {
13710        let buf = tracing_test::internal::global_buf().lock().unwrap();
13711        String::from_utf8_lossy(&buf).contains(needle)
13712    }
13713
13714    #[tracing_test::traced_test]
13715    #[tokio::test]
13716    async fn error_context_redacts_query_sentinel() {
13717        let (port, registry) = spawn_test_server().await;
13718        spawn_failing_auth_route(
13719            &registry,
13720            "/secure-query",
13721            vec![CredentialSource::QueryParam {
13722                param: "token".to_string(),
13723            }],
13724        )
13725        .await;
13726
13727        let client = plain_http_test_client();
13728        let resp = client
13729            // allow-secret: `token` is the declared query-source param name, not a credential
13730            .get(format!(
13731                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
13732            ))
13733            .send()
13734            .await
13735            .unwrap();
13736
13737        assert_eq!(resp.status().as_u16(), 401);
13738        let body = resp.text().await.unwrap();
13739        assert_eq!(body, "Unauthorized");
13740        assert!(
13741            !body.contains(SENTINEL_QRY_42),
13742            "reply body must not contain the query credential"
13743        );
13744        assert!(
13745            !captured_logs_contain(SENTINEL_QRY_42),
13746            "no tracing record during request handling may render the query credential"
13747        );
13748        // Permanent positive control: the failed-auth warn! must be captured.
13749        // If the per-crate env filter ever stops matching, this fails loudly
13750        // instead of letting the sentinel assertions pass vacuously.
13751        assert!(
13752            captured_logs_contain("Authentication failed"),
13753            "positive control: the failed-auth warn! must be captured by the test subscriber"
13754        );
13755    }
13756
13757    #[tracing_test::traced_test]
13758    #[tokio::test]
13759    async fn error_context_redacts_cookie_sentinel() {
13760        let (port, registry) = spawn_test_server().await;
13761        spawn_failing_auth_route(
13762            &registry,
13763            "/secure-cookie",
13764            vec![CredentialSource::Cookie {
13765                name: "session".to_string(),
13766            }],
13767        )
13768        .await;
13769
13770        let client = plain_http_test_client();
13771        let resp = client
13772            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
13773            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
13774            .send()
13775            .await
13776            .unwrap();
13777
13778        assert_eq!(resp.status().as_u16(), 401);
13779        let body = resp.text().await.unwrap();
13780        assert_eq!(body, "Unauthorized");
13781        assert!(
13782            !body.contains(SENTINEL_CKY_7),
13783            "reply body must not contain the cookie credential"
13784        );
13785        assert!(
13786            !captured_logs_contain(SENTINEL_CKY_7),
13787            "no tracing record during request handling may render the cookie credential"
13788        );
13789    }
13790
13791    #[tracing_test::traced_test]
13792    #[tokio::test]
13793    async fn error_reply_no_credential_value() {
13794        let (port, registry) = spawn_test_server().await;
13795        spawn_failing_auth_route(
13796            &registry,
13797            "/secure-bad",
13798            vec![CredentialSource::Cookie {
13799                name: "session".to_string(),
13800            }],
13801        )
13802        .await;
13803
13804        let client = plain_http_test_client();
13805        let resp = client
13806            .get(format!("http://127.0.0.1:{port}/secure-bad"))
13807            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
13808            .send()
13809            .await
13810            .unwrap();
13811
13812        assert_eq!(resp.status().as_u16(), 401);
13813        let body = resp.text().await.unwrap();
13814        assert_eq!(body, "Unauthorized");
13815        assert!(
13816            !body.contains(SENTINEL_BAD_1),
13817            "reply body must not contain the credential value"
13818        );
13819        assert!(
13820            !captured_logs_contain(SENTINEL_BAD_1),
13821            "error logs must not render the credential value"
13822        );
13823    }
13824
13825    // -----------------------------------------------------------------------
13826    // Pinned-client-cache producer-path behavioral tests
13827    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
13828    // the endpoint cache, hostname requests build one client while the entry
13829    // stays retrievable, IP-literal requests bypass the cache)
13830    // -----------------------------------------------------------------------
13831
13832    /// Local responder that accepts any number of HTTP/1.1 connections on an
13833    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
13834    /// Unlike [`start_host_capturing_destination`], which serves exactly one
13835    /// connection, this loop keeps accepting so cache-reuse tests can drive
13836    /// several requests through one destination. Returns
13837    /// `(base_url, JoinHandle)`.
13838    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13839        use tokio::io::AsyncWriteExt;
13840
13841        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13842            .await
13843            .expect("bind ephemeral 127.0.0.1 listener");
13844        let port = listener.local_addr().expect("local addr").port();
13845        let base_url = format!("http://localhost:{port}");
13846        let handle = tokio::spawn(async move {
13847            while let Ok((mut conn, _)) = listener.accept().await {
13848                let _ = conn
13849                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13850                    .await;
13851                let _ = conn.shutdown().await;
13852            }
13853        });
13854        (base_url, handle)
13855    }
13856
13857    /// rc-0li3: local HTTPS responder — the TLS twin of
13858    /// [`spawn_multi_accept_200`]. Accepts any number of TLS connections on
13859    /// an ephemeral 127.0.0.1 port and answers each with a fixed 200. The
13860    /// certificate comes from `camel_component_api::test_support`
13861    /// (SANs: localhost, 127.0.0.1, ::1); clients run with
13862    /// `tls.insecure = true`.
13863    async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13864        use tokio::io::AsyncWriteExt;
13865
13866        let (_ca_pem, cert_pem, key_pem) =
13867            camel_component_api::test_support::tls::gen_server_cert();
13868        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
13869            .collect::<Result<_, _>>()
13870            .expect("parse server cert pem");
13871        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
13872            .expect("parse server key pem")
13873            .expect("server key present");
13874        // Explicit provider: the process default is ambiguous when multiple
13875        // crates pull rustls feature sets; the graph enables aws-lc-rs.
13876        let provider =
13877            std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
13878        let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
13879            .with_safe_default_protocol_versions()
13880            .expect("safe default protocol versions")
13881            .with_no_client_auth()
13882            .with_single_cert(certs, key)
13883            .expect("build rustls server config");
13884        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
13885
13886        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13887            .await
13888            .expect("bind ephemeral 127.0.0.1 listener");
13889        let port = listener.local_addr().expect("local addr").port();
13890        let base_url = format!("https://localhost:{port}");
13891        let handle = tokio::spawn(async move {
13892            while let Ok((conn, _)) = listener.accept().await {
13893                let acceptor = acceptor.clone();
13894                tokio::spawn(async move {
13895                    if let Ok(mut tls) = acceptor.accept(conn).await {
13896                        let _ = tls
13897                            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13898                            .await;
13899                        let _ = tls.shutdown().await;
13900                    }
13901                });
13902            }
13903        });
13904        (base_url, handle)
13905    }
13906
13907    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
13908    /// target a different authority (the 127.0.0.1 literal) on the same
13909    /// listener.
13910    fn responder_port(base_url: &str) -> u16 {
13911        url::Url::parse(base_url)
13912            .expect("responder base URL parses")
13913            .port()
13914            .expect("responder base URL carries an explicit port")
13915    }
13916
13917    /// Build an endpoint literal whose outbound config points at
13918    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
13919    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
13920    /// build counts stay observable across producers.
13921    fn endpoint_with_shared_cache(
13922        base_url: &str,
13923        pinned_cache: &Arc<PinnedClientCache>,
13924    ) -> HttpEndpoint {
13925        let uri = format!("{base_url}?allowInternal=true");
13926        HttpEndpoint {
13927            uri: uri.clone(),
13928            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
13929            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
13930            client: plain_http_test_client(),
13931            pinned_cache: Arc::clone(pinned_cache),
13932            http_config: HttpConfig::default(),
13933        }
13934    }
13935
13936    #[tokio::test]
13937    async fn producers_share_endpoint_cache() {
13938        use tower::ServiceExt;
13939
13940        let (base_url, _handle) = spawn_multi_accept_200().await;
13941        let pinned_cache = Arc::new(PinnedClientCache::new(
13942            PINNED_CLIENT_TTL,
13943            PINNED_CLIENT_MAX_ENTRIES,
13944        ));
13945
13946        let ctx = test_producer_ctx();
13947        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13948        let producer_a = endpoint.create_producer(rt(), &ctx);
13949        let producer_b = endpoint.create_producer(rt(), &ctx);
13950
13951        // Each producer sends one exchange whose resolved URL is the
13952        // endpoint's localhost base URL (a domain name → pinned-client path).
13953        for producer in [producer_a, producer_b] {
13954            let producer = producer.expect("create producer");
13955            let exchange = Exchange::new(Message::default());
13956            let reply = producer.oneshot(exchange).await;
13957            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13958        }
13959
13960        assert_eq!(
13961            pinned_cache.build_count(),
13962            1,
13963            "both producers must hit the same shared cache entry; a second \
13964             build means sharing is broken"
13965        );
13966    }
13967
13968    #[tokio::test]
13969    async fn producer_repeated_hostname_requests_build_one_client() {
13970        use tower::ServiceExt;
13971
13972        let (base_url, _handle) = spawn_multi_accept_200().await;
13973        let pinned_cache = Arc::new(PinnedClientCache::new(
13974            PINNED_CLIENT_TTL,
13975            PINNED_CLIENT_MAX_ENTRIES,
13976        ));
13977        let ctx = test_producer_ctx();
13978        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13979        let producer = endpoint
13980            .create_producer(rt(), &ctx)
13981            .expect("create producer");
13982
13983        // Two sequential hostname requests — the cached pinned client stays
13984        // retrievable between them, so no second build may happen.
13985        for i in 0..2 {
13986            let exchange = Exchange::new(Message::default());
13987            let reply = producer.clone().oneshot(exchange).await;
13988            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13989        }
13990
13991        assert_eq!(
13992            pinned_cache.build_count(),
13993            1,
13994            "repeated hostname requests must reuse the one pinned client; \
13995             0 builds means the producer bypassed the cache, more than 1 \
13996             means the entry was dropped"
13997        );
13998    }
13999
14000    #[tokio::test]
14001    async fn ip_literal_request_never_enters_cache() {
14002        use tower::ServiceExt;
14003
14004        let (base_url, _handle) = spawn_multi_accept_200().await;
14005        let pinned_cache = Arc::new(PinnedClientCache::new(
14006            PINNED_CLIENT_TTL,
14007            PINNED_CLIENT_MAX_ENTRIES,
14008        ));
14009
14010        let ctx = test_producer_ctx();
14011        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
14012        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
14013        let producer = endpoint
14014            .create_producer(rt(), &ctx)
14015            .expect("create producer");
14016
14017        let exchange = Exchange::new(Message::default());
14018        let reply = producer.oneshot(exchange).await;
14019        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
14020
14021        assert_eq!(
14022            pinned_cache.build_count(),
14023            0,
14024            "an IP-literal URL must use the shared unpinned client and \
14025             never enter the pinned cache"
14026        );
14027    }
14028
14029    #[tokio::test]
14030    async fn test_component_endpoints_share_pinned_cache() {
14031        use tower::ServiceExt;
14032
14033        let component = HttpComponent::new();
14034        let (base_url, _handle) = spawn_multi_accept_200().await;
14035        let baseline = component.pinned_cache.build_count();
14036
14037        let ctx = test_producer_ctx();
14038        let endpoint_ctx = NoOpComponentContext;
14039        for uri in [
14040            format!("{base_url}/a?allowInternal=true&k=a"),
14041            format!("{base_url}/b?allowInternal=true&k=b"),
14042        ] {
14043            let endpoint = component
14044                .create_endpoint(&uri, &endpoint_ctx)
14045                .expect("create endpoint");
14046            let producer = endpoint
14047                .create_producer(rt(), &ctx)
14048                .expect("create producer");
14049            let exchange = Exchange::new(Message::default());
14050            let reply = producer.oneshot(exchange).await;
14051            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
14052        }
14053
14054        assert_eq!(
14055            component.pinned_cache.build_count() - baseline,
14056            1,
14057            "endpoints created by one component must share its pinned cache; \
14058             0 builds means the endpoints bypassed it, more than 1 means \
14059             per-endpoint caches came back"
14060        );
14061    }
14062
14063    #[tokio::test]
14064    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
14065        use tower::ServiceExt;
14066
14067        let component = HttpComponent::new();
14068        let (base_url, _handle) = spawn_multi_accept_200().await;
14069        let baseline = component.pinned_cache.build_count();
14070
14071        let ctx = test_producer_ctx();
14072        let endpoint_ctx = NoOpComponentContext;
14073        for i in 0..3 {
14074            let endpoint = component
14075                .create_endpoint(
14076                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
14077                    &endpoint_ctx,
14078                )
14079                .expect("create endpoint");
14080            let producer = endpoint
14081                .create_producer(rt(), &ctx)
14082                .expect("create producer");
14083            let exchange = Exchange::new(Message::default());
14084            let reply = producer.oneshot(exchange).await;
14085            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
14086        }
14087
14088        assert_eq!(
14089            component.pinned_cache.build_count() - baseline,
14090            1,
14091            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
14092             must reuse the component's one pinned cache entry; 0 builds \
14093             means the endpoints bypassed it, more than 1 means \
14094             per-endpoint caches came back"
14095        );
14096    }
14097
14098    /// rc-0li3: BEHAVIORAL https sharing pin — two https endpoints created
14099    /// through one `HttpsComponent` drive real TLS requests through the
14100    /// component's single pinned cache. A regression that reintroduces
14101    /// per-endpoint `PinnedClientCache::new` inside
14102    /// `HttpsComponent::create_endpoint` leaves the component cache at
14103    /// delta 0 and fails this test (the structural ptr_eq test cannot see
14104    /// that).
14105    #[tokio::test]
14106    async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
14107        use tower::ServiceExt;
14108
14109        let http_config = HttpConfig {
14110            tls: Some(crate::config::TlsConfig {
14111                enabled: true,
14112                insecure: true,
14113                ..Default::default()
14114            }),
14115            ..Default::default()
14116        };
14117        let component = HttpsComponent::with_config(http_config);
14118        let (base_url, _handle) = spawn_tls_multi_accept_200().await;
14119        let baseline = component.pinned_cache.build_count();
14120
14121        let ctx = test_producer_ctx();
14122        let endpoint_ctx = NoOpComponentContext;
14123        for uri in [
14124            format!("{base_url}/a?allowInternal=true&k=a"),
14125            format!("{base_url}/b?allowInternal=true&k=b"),
14126        ] {
14127            let endpoint = component
14128                .create_endpoint(&uri, &endpoint_ctx)
14129                .expect("create https endpoint");
14130            let producer = endpoint
14131                .create_producer(rt(), &ctx)
14132                .expect("create producer");
14133            let exchange = Exchange::new(Message::default());
14134            let reply = producer.oneshot(exchange).await;
14135            assert!(reply.is_ok(), "https request failed: {reply:?}");
14136        }
14137
14138        assert_eq!(
14139            component.pinned_cache.build_count() - baseline,
14140            1,
14141            "endpoints of one HttpsComponent must share its pinned cache over \
14142             real https requests; 0 builds means the endpoints bypassed it \
14143             (per-endpoint cache regression), more than 1 means \
14144             per-endpoint caches came back"
14145        );
14146    }
14147
14148    #[test]
14149    fn test_https_component_owns_distinct_cache() {
14150        let http = HttpComponent::new();
14151        let https = HttpsComponent::new();
14152
14153        assert!(
14154            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
14155            "http and https components must each own their own pinned cache"
14156        );
14157
14158        let endpoint_ctx = NoOpComponentContext;
14159        let _ = http
14160            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
14161            .expect("http endpoint");
14162        let _ = https
14163            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
14164            .expect("https endpoint");
14165
14166        assert_eq!(
14167            http.pinned_cache.build_count(),
14168            0,
14169            "endpoint creation must not build a pinned client"
14170        );
14171        assert_eq!(
14172            https.pinned_cache.build_count(),
14173            0,
14174            "endpoint creation must not build a pinned client"
14175        );
14176    }
14177
14178    #[test]
14179    fn test_component_constructor_builds_one_unpinned_client() {
14180        let baseline = build_client_call_count();
14181
14182        let _http = HttpComponent::new();
14183        assert_eq!(
14184            build_client_call_count() - baseline,
14185            1,
14186            "HttpComponent::new() must build exactly one shared unpinned client"
14187        );
14188
14189        let _https = HttpsComponent::new();
14190        assert_eq!(
14191            build_client_call_count() - baseline,
14192            2,
14193            "HttpsComponent::new() must build exactly one more shared unpinned client"
14194        );
14195    }
14196
14197    #[test]
14198    fn test_component_endpoints_share_unpinned_client() {
14199        let component = HttpComponent::new();
14200        let baseline = build_client_call_count();
14201
14202        let endpoint_ctx = NoOpComponentContext;
14203        for uri in [
14204            "http://localhost:1/a?allowInternal=true",
14205            "http://localhost:1/b?allowInternal=true",
14206        ] {
14207            let _endpoint = component
14208                .create_endpoint(uri, &endpoint_ctx)
14209                .expect("create endpoint");
14210        }
14211
14212        assert_eq!(
14213            build_client_call_count() - baseline,
14214            0,
14215            "create_endpoint must clone the component's shared unpinned client, \
14216             never build a fresh one"
14217        );
14218    }
14219
14220    #[test]
14221    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
14222        let component = HttpComponent::new();
14223        let baseline = build_client_call_count();
14224
14225        let ctx = test_producer_ctx();
14226        let endpoint_ctx = NoOpComponentContext;
14227        for i in 0..3 {
14228            let endpoint = component
14229                .create_endpoint(
14230                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
14231                    &endpoint_ctx,
14232                )
14233                .expect("create endpoint");
14234            let _producer = endpoint
14235                .create_producer(rt(), &ctx)
14236                .expect("create producer");
14237        }
14238
14239        assert_eq!(
14240            build_client_call_count() - baseline,
14241            0,
14242            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
14243             must reuse the component's shared unpinned client and build \
14244             no additional clients"
14245        );
14246    }
14247
14248    #[test]
14249    fn test_webpki_fallback_config_builds_client() {
14250        // The fallback backend must construct a client on ANY platform —
14251        // it reads no platform state. This is the exact TLS configuration
14252        // Termux executes when the primary (platform-verifier) build
14253        // fails (rc-3j4mq).
14254        let client = reqwest::Client::builder()
14255            .tls_backend_preconfigured(webpki_root_client_config())
14256            .build()
14257            .expect("webpki-rooted client must build"); // allow-unwrap(test)
14258        // A built client is usable (internal state initialized); assert
14259        // via the debug render rather than a network round-trip.
14260        let rendered = format!("{client:?}");
14261        assert!(
14262            !rendered.is_empty(),
14263            "client must render a debug representation"
14264        );
14265    }
14266
14267    /// rcgen self-signed one-root CA, written to `ca.pem` inside a fresh
14268    /// tempdir (rcgen 0.14 shapes per camel-component-api test_support).
14269    fn fallback_test_ca() -> (tempfile::TempDir, String) {
14270        let ca_key = rcgen::KeyPair::generate().expect("ca keygen"); // allow-unwrap(test)
14271        let mut ca_params = rcgen::CertificateParams::default();
14272        ca_params
14273            .distinguished_name
14274            .push(rcgen::DnType::CommonName, "Fallback test CA");
14275        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
14276        let ca_cert = ca_params.self_signed(&ca_key).expect("ca self-sign"); // allow-unwrap(test)
14277        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14278        let ca_path = dir.path().join("ca.pem");
14279        std::fs::write(&ca_path, ca_cert.pem()).expect("write ca pem"); // allow-unwrap(test)
14280        (dir, ca_path.display().to_string())
14281    }
14282
14283    /// Valid strict TLS material fixtures (rc-3x5qj): a rcgen one-root
14284    /// CA written to `ca.pem` plus a CA-signed client identity written
14285    /// to `client-cert.pem`/`client-key.pem`, all inside a fresh
14286    /// tempdir. The CA itself is returned too, so handshake tests can
14287    /// sign a server leaf from the SAME CA the config carries — the
14288    /// rebuild-failure tests' precondition (VALID material throughout,
14289    /// so no material error can precede the terminal under test).
14290    /// Named fields for the strict TLS material fixtures (rc-3x5qj):
14291    /// position-only PathBufs in a tuple invited silent cert/key swaps.
14292    struct StrictMaterial {
14293        dir: tempfile::TempDir,
14294        ca: TestCa,
14295        ca_path: std::path::PathBuf,
14296        cert_path: std::path::PathBuf,
14297        key_path: std::path::PathBuf,
14298    }
14299
14300    fn strict_material_fixtures() -> StrictMaterial {
14301        let ca = gen_test_ca();
14302        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14303        let ca_path = dir.path().join("ca.pem");
14304        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
14305        let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
14306        let cert_path = dir.path().join("client-cert.pem");
14307        std::fs::write(&cert_path, client_cert_pem.as_bytes()).expect("write client cert"); // allow-unwrap(test)
14308        let key_path = dir.path().join("client-key.pem");
14309        std::fs::write(&key_path, client_key_pem.as_bytes()).expect("write client key"); // allow-unwrap(test)
14310        StrictMaterial {
14311            dir,
14312            ca,
14313            ca_path,
14314            cert_path,
14315            key_path,
14316        }
14317    }
14318
14319    /// Assert `err` is `EndpointCreationFailed` whose message contains
14320    /// every `fragment`.
14321    fn assert_strict_fallback_error(err: CamelError, fragments: &[&str]) {
14322        match err {
14323            CamelError::EndpointCreationFailed(msg) => {
14324                for fragment in fragments {
14325                    assert!(msg.contains(fragment), "message: {msg}");
14326                }
14327            }
14328            other => panic!("expected EndpointCreationFailed, got {other:?}"),
14329        }
14330    }
14331
14332    /// Order is hygiene-only for verification, not load-bearing:
14333    /// rustls 0.23.45 `src/webpki/verify.rs:245-263` passes
14334    /// `&roots.roots` to webpki in store order, and rustls-webpki
14335    /// 0.103.15 `src/verify_cert.rs:66-73` +
14336    /// `loop_while_non_fatal_error` (`:757-774`) returns on the FIRST
14337    /// anchor that validates and continues past non-fatal misses, so
14338    /// accept/reject cannot depend on order. Custom-first is pinned
14339    /// anyway for parity with the primary path
14340    /// (rustls-platform-verifier 0.7.0
14341    /// `src/verification/others.rs:81-93` adds extra roots before
14342    /// native certs).
14343    #[test]
14344    fn test_fallback_root_store_union_custom_and_mozilla() {
14345        let (_dir, ca_path) = fallback_test_ca();
14346        let store = fallback_root_store(Some(&ca_path), true).expect("union root store must build"); // allow-unwrap(test)
14347        assert_eq!(
14348            store.roots.len(),
14349            webpki_roots::TLS_SERVER_ROOTS.len() + 1,
14350            "custom CA must UNION with the Mozilla anchors, not replace them"
14351        );
14352        let ca_pem = std::fs::read_to_string(&ca_path).expect("read ca pem back"); // allow-unwrap(test)
14353        let der = rustls_pemfile::certs(&mut std::io::Cursor::new(ca_pem.as_bytes()))
14354            .next()
14355            .expect("exactly one CERTIFICATE section in test CA") // allow-unwrap(test)
14356            .expect("parse test CA pem"); // allow-unwrap(test)
14357        // Rebuild the expected anchor the same way the production store
14358        // accepts it, so the equality below is construction-identical.
14359        let mut scratch = rustls::RootCertStore::empty();
14360        let (added, _ignored) = scratch.add_parsable_certificates(vec![der]);
14361        assert_eq!(added, 1, "scratch root store must accept the test CA");
14362        let expected_anchor = scratch.roots[0].clone();
14363        assert_eq!(
14364            store.roots.first(),
14365            Some(&expected_anchor),
14366            "custom anchor must sit at index 0 — parity with the primary path's \
14367             extra-roots-before-platform-roots order"
14368        );
14369        assert_eq!(
14370            store.roots[1..],
14371            *webpki_roots::TLS_SERVER_ROOTS,
14372            "everything after the custom anchor must be exactly the bundled Mozilla anchors"
14373        );
14374        let first_custom = store
14375            .roots
14376            .iter()
14377            .position(|anchor| anchor == &expected_anchor)
14378            .expect("custom anchor present in store"); // allow-unwrap(test)
14379        let first_mozilla = store
14380            .roots
14381            .iter()
14382            .position(|anchor| webpki_roots::TLS_SERVER_ROOTS.contains(anchor))
14383            .expect("Mozilla anchors present in store"); // allow-unwrap(test)
14384        assert!(
14385            first_custom < first_mozilla,
14386            "first custom anchor (index {first_custom}) must precede the first \
14387             Mozilla anchor (index {first_mozilla})"
14388        );
14389    }
14390
14391    #[test]
14392    fn test_fallback_root_store_no_ca_is_mozilla_only() {
14393        let store = fallback_root_store(None, true).expect("mozilla-only store must build"); // allow-unwrap(test)
14394        assert_eq!(
14395            store.roots.len(),
14396            webpki_roots::TLS_SERVER_ROOTS.len(),
14397            "no configured CA must leave the bundled Mozilla anchors untouched"
14398        );
14399        assert_eq!(
14400            store.roots,
14401            *webpki_roots::TLS_SERVER_ROOTS,
14402            "with no configured CA the whole store IS the bundled Mozilla anchors, \
14403             byte-for-byte"
14404        );
14405    }
14406
14407    #[test]
14408    fn test_fallback_root_store_strict_unreadable_ca_fails_closed() {
14409        let err = match fallback_root_store(Some("/nonexistent/ca-bundle.pem"), true) {
14410            Err(e) => e,
14411            Ok(_) => panic!("strict mode must fail closed on an unreadable CA file"),
14412        };
14413        assert_strict_fallback_error(err, &["tls.strict/webpki-fallback", "unreadable"]);
14414    }
14415
14416    #[test]
14417    fn test_fallback_root_store_strict_zero_pem_sections_fails_closed() {
14418        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14419        let ca_path = dir.path().join("key-only.pem");
14420        std::fs::write(
14421            &ca_path,
14422            "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14423        )
14424        .expect("write key-only pem"); // allow-unwrap(test)
14425        let path = ca_path.display().to_string();
14426        let err = match fallback_root_store(Some(&path), true) {
14427            Err(e) => e,
14428            Ok(_) => panic!("strict mode must fail closed on a CA with no CERTIFICATE section"),
14429        };
14430        assert_strict_fallback_error(err, &["no parseable PEM CERTIFICATE"]);
14431    }
14432
14433    #[test]
14434    fn test_fallback_root_store_strict_zero_roots_accepted_fails_closed() {
14435        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14436        let ca_path = dir.path().join("garbage-cert.pem");
14437        std::fs::write(
14438            &ca_path,
14439            "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14440        )
14441        .expect("write garbage cert pem"); // allow-unwrap(test)
14442        let path = ca_path.display().to_string();
14443        let err = match fallback_root_store(Some(&path), true) {
14444            Err(e) => e,
14445            Ok(_) => panic!("strict mode must fail closed when the root store rejects the CA"),
14446        };
14447        assert_strict_fallback_error(err, &["rejected by the TLS root store"]);
14448    }
14449
14450    #[test]
14451    fn test_fallback_root_store_nonstrict_bad_ca_degrades_to_mozilla() {
14452        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14453        let ca_path = dir.path().join("garbage.pem");
14454        std::fs::write(&ca_path, "this is not a certificate\n").expect("write garbage pem"); // allow-unwrap(test)
14455        let path = ca_path.display().to_string();
14456        let store = fallback_root_store(Some(&path), false)
14457            .expect("non-strict garbage CA must degrade to Mozilla-only"); // allow-unwrap(test)
14458        assert_eq!(
14459            store.roots.len(),
14460            webpki_roots::TLS_SERVER_ROOTS.len(),
14461            "non-strict bad CA must degrade to the bundled Mozilla anchors"
14462        );
14463    }
14464
14465    #[test]
14466    fn test_fallback_client_config_disabled_tls_returns_none() {
14467        let tls = TlsConfig {
14468            enabled: false,
14469            ..Default::default()
14470        };
14471        assert!(
14472            fallback_client_config(&tls)
14473                .expect("disabled TLS is infallible") // allow-unwrap(test)
14474                .is_none()
14475        );
14476    }
14477
14478    #[test]
14479    fn test_fallback_client_config_no_material_verifying_returns_none() {
14480        let tls = TlsConfig {
14481            enabled: true,
14482            ..Default::default()
14483        };
14484        assert!(
14485            fallback_client_config(&tls)
14486                .expect("plain verifying TLS is infallible") // allow-unwrap(test)
14487                .is_none()
14488        );
14489    }
14490
14491    #[test]
14492    fn test_fallback_client_config_strict_identity_garbage_key_fails_closed() {
14493        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14494        let client_key = rcgen::KeyPair::generate().expect("client keygen"); // allow-unwrap(test)
14495        let client_params = rcgen::CertificateParams::default();
14496        let client_cert = client_params
14497            .self_signed(&client_key)
14498            .expect("client cert self-sign"); // allow-unwrap(test)
14499        let cert_path = dir.path().join("client.pem");
14500        std::fs::write(&cert_path, client_cert.pem()).expect("write client pem"); // allow-unwrap(test)
14501        let key_path = dir.path().join("key.pem");
14502        std::fs::write(&key_path, "garbage not a key at all\n").expect("write garbage key"); // allow-unwrap(test)
14503        let tls = TlsConfig {
14504            enabled: true,
14505            strict: true,
14506            client_cert_path: Some(cert_path.display().to_string()),
14507            client_key_path: Some(key_path.display().to_string()),
14508            ..Default::default()
14509        };
14510        let err = match fallback_client_config(&tls) {
14511            Err(e) => e,
14512            Ok(_) => panic!("strict mode must fail closed on a garbage client key"),
14513        };
14514        assert_strict_fallback_error(err, &["no parseable private key"]);
14515    }
14516
14517    #[test]
14518    fn test_fallback_client_config_strict_rustls_rejected_key_fails_closed() {
14519        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14520        let client_key = rcgen::KeyPair::generate().expect("client keygen"); // allow-unwrap(test)
14521        let client_params = rcgen::CertificateParams::default();
14522        let client_cert = client_params
14523            .self_signed(&client_key)
14524            .expect("client cert self-sign"); // allow-unwrap(test)
14525        let cert_path = dir.path().join("client.pem");
14526        std::fs::write(&cert_path, client_cert.pem()).expect("write client pem"); // allow-unwrap(test)
14527        let key_path = dir.path().join("key.pem");
14528        std::fs::write(
14529            &key_path,
14530            "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14531        )
14532        .expect("write pkcs8-framed garbage key"); // allow-unwrap(test)
14533        let tls = TlsConfig {
14534            enabled: true,
14535            strict: true,
14536            client_cert_path: Some(cert_path.display().to_string()),
14537            client_key_path: Some(key_path.display().to_string()),
14538            ..Default::default()
14539        };
14540        let err = match fallback_client_config(&tls) {
14541            Err(e) => e,
14542            Ok(_) => panic!("strict mode must fail closed when rustls rejects the identity"),
14543        };
14544        assert_strict_fallback_error(err, &["rejected by the TLS backend"]);
14545    }
14546
14547    #[test]
14548    fn test_fallback_client_config_half_mtls_pair_strict_fails_nonstrict_no_auth() {
14549        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14550        let cert_path = dir.path().join("client.pem");
14551        std::fs::write(&cert_path, "cert-only, key missing\n").expect("write placeholder pem"); // allow-unwrap(test)
14552        let tls_strict = TlsConfig {
14553            enabled: true,
14554            strict: true,
14555            client_cert_path: Some(cert_path.display().to_string()),
14556            ..Default::default()
14557        };
14558        let err = match fallback_client_config(&tls_strict) {
14559            Err(e) => e,
14560            Ok(_) => panic!("strict mode must fail closed on a half-configured mTLS pair"),
14561        };
14562        assert_strict_fallback_error(err, &["BOTH client_cert_path"]);
14563        let tls_permissive = TlsConfig {
14564            enabled: true,
14565            client_cert_path: Some(cert_path.display().to_string()),
14566            ..Default::default()
14567        };
14568        assert!(
14569            fallback_client_config(&tls_permissive)
14570                .expect("non-strict half pair degrades to no-auth") // allow-unwrap(test)
14571                .is_some()
14572        );
14573    }
14574
14575    #[test]
14576    fn test_no_verify_verifier_accepts_any_certificate() {
14577        use rustls::client::danger::ServerCertVerifier;
14578
14579        let verifier = NoVerifyServerCertVerifier;
14580        let garbage = rustls::pki_types::CertificateDer::from(vec![0xDE, 0xAD, 0xBE, 0xEF]);
14581        let server_name =
14582            rustls::pki_types::ServerName::try_from("localhost").expect("localhost dns name"); // allow-unwrap(test)
14583        assert!(
14584            verifier
14585                .verify_server_cert(
14586                    &garbage,
14587                    &[],
14588                    &server_name,
14589                    &[],
14590                    rustls::pki_types::UnixTime::now()
14591                )
14592                .is_ok(),
14593            "no-verify verifier must accept any certificate without validation"
14594        );
14595    }
14596
14597    #[test]
14598    fn test_build_client_falls_back_on_empty_platform_ca_store() {
14599        // Serialize against the primary-path test: the env window below
14600        // is process-visible and would force its build through the
14601        // fallback too.
14602        let _ca_guard = lock_ca_store_test_mutex();
14603        // Simulate a CA-less platform (Android/Termux, rc-3j4mq):
14604        // openssl-probe honors SSL_CERT_FILE/SSL_CERT_DIR only when the
14605        // paths EXIST, so an existing-but-empty file plus an
14606        // existing-but-empty directory make rustls-native-certs load zero
14607        // roots — the exact condition that made the platform verifier
14608        // (and pre-fix startup) fail on Termux. The env window is
14609        // process-visible: concurrent client builds in other tests also
14610        // take the fallback, which still builds a working client — no
14611        // assertion outside this test can distinguish the two paths
14612        // except via this thread's BUILD_CLIENT_FALLBACKS counter.
14613        let empty_ca_dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14614        let empty_ca_file = empty_ca_dir.path().join("empty-ca-bundle.pem");
14615        std::fs::write(&empty_ca_file, b"").expect("write empty CA file"); // allow-unwrap(test)
14616
14617        // Safety: test-only mutation of process env vars. This test does
14618        // not spawn threads that read these vars outside the guarded
14619        // window below; the guard restores previous values before return.
14620        let (prev_file, prev_dir) = unsafe {
14621            let prev = (
14622                std::env::var_os("SSL_CERT_FILE"),
14623                std::env::var_os("SSL_CERT_DIR"),
14624            );
14625            std::env::set_var("SSL_CERT_FILE", &empty_ca_file);
14626            std::env::set_var("SSL_CERT_DIR", empty_ca_dir.path());
14627            prev
14628        };
14629
14630        let result = std::panic::catch_unwind(|| {
14631            let fallbacks_before = build_client_fallback_count();
14632            let _client = build_client(&HttpConfig::default(), None).expect("client must build"); // allow-unwrap(test)
14633            build_client_fallback_count() - fallbacks_before
14634        });
14635
14636        // Restore FIRST so the guard holds even if assertions fail.
14637        // Safety: restoring the previously-captured values.
14638        unsafe {
14639            match prev_file {
14640                Some(v) => std::env::set_var("SSL_CERT_FILE", v),
14641                None => std::env::remove_var("SSL_CERT_FILE"),
14642            }
14643            match prev_dir {
14644                Some(v) => std::env::set_var("SSL_CERT_DIR", v),
14645                None => std::env::remove_var("SSL_CERT_DIR"),
14646            }
14647        }
14648
14649        let fallbacks_taken = result.expect("build_client must not panic on an empty CA store"); // allow-unwrap(test)
14650        assert_eq!(
14651            fallbacks_taken, 1,
14652            "an empty platform CA store must route exactly one build through \
14653             the webpki fallback (primary build failed, fallback succeeded)"
14654        );
14655    }
14656
14657    /// Throwaway self-signed root used ONLY to make the native-root
14658    /// store non-empty hermetically (tests need a parseable PEM
14659    /// CERTIFICATE section, nothing more — the private key was discarded
14660    /// at generation and the root signs/trusts nothing). Generated
14661    /// 2026-09-21, self-expiring 2126.
14662    const HERMETIC_TEST_ROOT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
14663        MIIDVzCCAj+gAwIBAgIUQ+hB0JPFtHpUKaNwcTB8ijGqEW0wDQYJKoZIhvcNAQEL\n\
14664        BQAwOjEdMBsGA1UEAwwUY2FtZWwtaHR0cCB0ZXN0IHJvb3QxGTAXBgNVBAoMEGNh\n\
14665        bWVsLWh0dHAgdGVzdHMwIBcNMjYwOTIxMTEyMzUwWhgPMjEyNjA4MjgxMTIzNTBa\n\
14666        MDoxHTAbBgNVBAMMFGNhbWVsLWh0dHAgdGVzdCByb290MRkwFwYDVQQKDBBjYW1l\n\
14667        bC1odHRwIHRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAolnF\n\
14668        SiT/bK8pMl9n50HTQj4oxPZGXK34Q/OSq8TMUEcmhUFzBjzwZKoFqvDIt/e0mSi/\n\
14669        h8WQlnrHAvYOnsRlpDsfs4yder+lpEYE84OooJjQvj/kREj7ncK6WQKDY1NTGikx\n\
14670        lF8kIaCrNHNIlUgoSWMR4E6UshLFwSu5lKtcWCeN6FNzGVQ9jxYJSFsmVk+wFyHI\n\
14671        edvpPbnFkD3M1/GKNVuxCCR50sO+cQeB7w9FCyFdHvoTWWuwPV4Qq2LBM2n4eedG\n\
14672        pCXkqmuARtFuOKjpYIRryGybic9u9ZW59FbTbgHSj6rcOXNtBUVV/KnWRDRWo7ZC\n\
14673        cR5SmO2YwtfpISjbmwIDAQABo1MwUTAdBgNVHQ4EFgQUkhwU0Q5JYNLrs7yOqbXE\n\
14674        szzn2/cwHwYDVR0jBBgwFoAUkhwU0Q5JYNLrs7yOqbXEszzn2/cwDwYDVR0TAQH/\n\
14675        BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAXLqN7poLyxlpxN9vY1jISs403P2I\n\
14676        edwgX3eWyJgXQfc/+dhnig8Pi7SkJKNM9PM27qain3+e2/HeetNYGBD8xnzQo5ga\n\
14677        1ScPvm7u2DWIWc5y0bpm5JuCPqXoDl/7kBcaelA2lZRrPJzQuu6uPnH1ubjUPFJj\n\
14678        9SxWhR+QgJW3cFc17C179WSVSJSPLozxX1ODABSyxNXnz14Rq+MOJi2tEsDggyMm\n\
14679        txh6GwgVbiSey9c9bvu3B28iPfNpULuxBzxSLHcRrSSoII+46foY0G63V/jx7huY\n\
14680        Fc2DPwOcO2Ni8fLssMaAnEdWPZaIN/DdjmUFw9XgHE9Eld6aKEXBcRFPzA==\n\
14681        -----END CERTIFICATE-----";
14682
14683    #[test]
14684    fn test_build_client_primary_path_when_platform_store_non_empty() {
14685        // The webpki set is a FALLBACK, never a replacement for platform
14686        // roots (managed fleets keep OS root-program control). Hermetic:
14687        // SSL_CERT_FILE pinned to a parseable fixture root makes the
14688        // native store non-empty on ANY host — including genuinely
14689        // CA-less ones — so the no-fallback assertion cannot depend on
14690        // the build machine. Serialized against the CA-less sibling test
14691        // by the CA-store mutex (its env window forces the fallback).
14692        let _ca_guard = lock_ca_store_test_mutex();
14693        let ca_dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14694        let ca_file = ca_dir.path().join("hermetic-root.pem");
14695        std::fs::write(&ca_file, HERMETIC_TEST_ROOT_PEM).expect("write fixture root"); // allow-unwrap(test)
14696
14697        // Safety: test-only mutation of process env vars, restored
14698        // before any assertion below.
14699        let (prev_file, prev_dir) = unsafe {
14700            let prev = (
14701                std::env::var_os("SSL_CERT_FILE"),
14702                std::env::var_os("SSL_CERT_DIR"),
14703            );
14704            std::env::set_var("SSL_CERT_FILE", &ca_file);
14705            std::env::set_var("SSL_CERT_DIR", ca_dir.path());
14706            prev
14707        };
14708
14709        let fallbacks_before = build_client_fallback_count();
14710        let _client = build_client(&HttpConfig::default(), None).expect("client must build"); // allow-unwrap(test)
14711        let fallbacks_taken = build_client_fallback_count() - fallbacks_before;
14712
14713        // Safety: restoring the previously-captured values.
14714        unsafe {
14715            match prev_file {
14716                Some(v) => std::env::set_var("SSL_CERT_FILE", v),
14717                None => std::env::remove_var("SSL_CERT_FILE"),
14718            }
14719            match prev_dir {
14720                Some(v) => std::env::set_var("SSL_CERT_DIR", v),
14721                None => std::env::remove_var("SSL_CERT_DIR"),
14722            }
14723        }
14724
14725        assert_eq!(
14726            fallbacks_taken, 0,
14727            "with at least one platform root loadable, build_client must \
14728             use the platform-verifier primary path, never the webpki \
14729             fallback"
14730        );
14731    }
14732
14733    #[test]
14734    fn test_build_client_forced_fallback_strict_store_rejected_ca_returns_typed_error() {
14735        // pemfile-ok / store-rejected CA: the PEM section base64-decodes
14736        // (rustls-pemfile does not validate DER), so only the root store
14737        // rejects it — the store-rejection class through the full
14738        // build_client fallback path.
14739        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14740        let ca_path = dir.path().join("garbage-cert.pem");
14741        std::fs::write(
14742            &ca_path,
14743            "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14744        )
14745        .expect("write garbage cert pem"); // allow-unwrap(test)
14746        let config = HttpConfig {
14747            tls: Some(TlsConfig {
14748                enabled: true,
14749                strict: true,
14750                ca_cert_path: Some(ca_path.display().to_string()),
14751                ..Default::default()
14752            }),
14753            ..Default::default()
14754        };
14755
14756        let (err, fallbacks_taken) = forced_fallback_env(|| {
14757            let fallbacks_before = build_client_fallback_count();
14758            let err = match build_client(&config, None) {
14759                Err(e) => e,
14760                Ok(_) => panic!("strict mode must fail closed when the root store rejects the CA"),
14761            };
14762            (err, build_client_fallback_count() - fallbacks_before)
14763        });
14764
14765        assert_strict_fallback_error(
14766            err,
14767            &[
14768                "tls.strict/webpki-fallback",
14769                "rejected by the TLS root store",
14770            ],
14771        );
14772        assert_eq!(
14773            fallbacks_taken, 1,
14774            "the typed error must come from exactly one webpki fallback entry"
14775        );
14776    }
14777
14778    #[test]
14779    fn test_build_client_forced_fallback_strict_material_rejection_classes() {
14780        // One fixture set, four strict rejection classes, each routed
14781        // through the full build_client fallback path.
14782        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14783
14784        // (a) CA material with zero PEM CERTIFICATE sections.
14785        let plain_text_ca = dir.path().join("plain-text.pem");
14786        std::fs::write(&plain_text_ca, "this is not a certificate\n").expect("write plain text ca"); // allow-unwrap(test)
14787
14788        // Valid rcgen client identity shared by the mTLS classes.
14789        let client_key = rcgen::KeyPair::generate().expect("client keygen"); // allow-unwrap(test)
14790        let client_cert = rcgen::CertificateParams::default()
14791            .self_signed(&client_key)
14792            .expect("client self-sign"); // allow-unwrap(test)
14793        let cert_path = dir.path().join("client-cert.pem");
14794        std::fs::write(&cert_path, client_cert.pem()).expect("write client cert"); // allow-unwrap(test)
14795        let valid_key_path = dir.path().join("client-key.pem");
14796        std::fs::write(&valid_key_path, client_key.serialize_pem()).expect("write client key"); // allow-unwrap(test)
14797        let garbage_key_path = dir.path().join("garbage-key.pem");
14798        std::fs::write(&garbage_key_path, "not a key\n").expect("write garbage key"); // allow-unwrap(test)
14799        // PEM section parses; rustls rejects the bytes at client-auth
14800        // configuration.
14801        let rejected_key_path = dir.path().join("rejected-key.pem");
14802        std::fs::write(
14803            &rejected_key_path,
14804            "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14805        )
14806        .expect("write rejected key"); // allow-unwrap(test)
14807
14808        struct RejectionCase {
14809            name: &'static str,
14810            tls: TlsConfig,
14811            fragment: &'static str,
14812        }
14813        let cases = [
14814            RejectionCase {
14815                name: "CA with zero PEM CERTIFICATE sections",
14816                tls: TlsConfig {
14817                    enabled: true,
14818                    strict: true,
14819                    ca_cert_path: Some(plain_text_ca.display().to_string()),
14820                    ..Default::default()
14821                },
14822                fragment: "no parseable PEM CERTIFICATE section",
14823            },
14824            RejectionCase {
14825                name: "client key with no parseable section",
14826                tls: TlsConfig {
14827                    enabled: true,
14828                    strict: true,
14829                    client_cert_path: Some(cert_path.display().to_string()),
14830                    client_key_path: Some(garbage_key_path.display().to_string()),
14831                    ..Default::default()
14832                },
14833                fragment: "no parseable private key section",
14834            },
14835            RejectionCase {
14836                name: "client key rejected by the TLS backend",
14837                tls: TlsConfig {
14838                    enabled: true,
14839                    strict: true,
14840                    client_cert_path: Some(cert_path.display().to_string()),
14841                    client_key_path: Some(rejected_key_path.display().to_string()),
14842                    ..Default::default()
14843                },
14844                fragment: "rejected by the TLS backend",
14845            },
14846            RejectionCase {
14847                name: "unreadable client cert",
14848                tls: TlsConfig {
14849                    enabled: true,
14850                    strict: true,
14851                    client_cert_path: Some("/nonexistent/client-cert.pem".to_string()),
14852                    client_key_path: Some(valid_key_path.display().to_string()),
14853                    ..Default::default()
14854                },
14855                fragment: "unreadable",
14856            },
14857        ];
14858
14859        for case in cases {
14860            let config = HttpConfig {
14861                tls: Some(case.tls),
14862                ..Default::default()
14863            };
14864            let err = forced_fallback_env(|| match build_client(&config, None) {
14865                Err(e) => e,
14866                Ok(_) => panic!("strict case '{}' must fail closed", case.name),
14867            });
14868            assert_strict_fallback_error(err, &["tls.strict/webpki-fallback", case.fragment]);
14869        }
14870    }
14871
14872    #[test]
14873    fn test_build_client_forced_fallback_nonstrict_bad_ca_still_builds() {
14874        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14875        let ca_path = dir.path().join("garbage.pem");
14876        std::fs::write(&ca_path, "this is not a certificate\n").expect("write garbage pem"); // allow-unwrap(test)
14877        let config = HttpConfig {
14878            tls: Some(TlsConfig {
14879                enabled: true,
14880                strict: false,
14881                ca_cert_path: Some(ca_path.display().to_string()),
14882                ..Default::default()
14883            }),
14884            ..Default::default()
14885        };
14886
14887        let (_client, fallbacks_taken) = forced_fallback_env(|| {
14888            let fallbacks_before = build_client_fallback_count();
14889            let client = build_client(&config, None)
14890                .expect("non-strict bad CA must degrade per-item and still build"); // allow-unwrap(test)
14891            (client, build_client_fallback_count() - fallbacks_before)
14892        });
14893
14894        assert_eq!(
14895            fallbacks_taken, 1,
14896            "permissive item downgrade still routes exactly one build \
14897             through the webpki fallback"
14898        );
14899    }
14900
14901    #[test]
14902    fn test_http_component_with_config_folds_fallback_error_without_panic() {
14903        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14904        let ca_path = dir.path().join("garbage-cert.pem");
14905        std::fs::write(
14906            &ca_path,
14907            "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14908        )
14909        .expect("write garbage cert pem"); // allow-unwrap(test)
14910        let config = HttpConfig {
14911            tls: Some(TlsConfig {
14912                enabled: true,
14913                strict: true,
14914                ca_cert_path: Some(ca_path.display().to_string()),
14915                ..Default::default()
14916            }),
14917            ..Default::default()
14918        };
14919
14920        let endpoint_err = forced_fallback_env(|| {
14921            // Construction must complete without panic; the build error
14922            // folds into strict_tls_error and surfaces at endpoint
14923            // creation.
14924            let component = HttpComponent::with_config(config);
14925            component
14926                .create_endpoint("http://h/p", &NoOpComponentContext)
14927                .err()
14928        });
14929
14930        let err = endpoint_err.expect("folded fallback error must fail endpoint creation"); // allow-unwrap(test)
14931        assert!(
14932            err.to_string().contains("rejected by the TLS root store"),
14933            "folded error must carry the fallback diagnosis: {err}"
14934        );
14935    }
14936
14937    #[test]
14938    fn test_http_component_new_no_panic_on_empty_ca_store() {
14939        let endpoint = forced_fallback_env(|| {
14940            let component = HttpComponent::new();
14941            component
14942                .create_endpoint("http://h/p", &NoOpComponentContext)
14943                .ok()
14944        });
14945        let _endpoint = endpoint.expect("material-free webpki fallback must serve endpoints"); // allow-unwrap(test)
14946    }
14947
14948    // ---- Forced-fallback loopback TLS handshake scenarios (castrict 1.3)
14949    //
14950    // Real TLS handshakes against a loopback rustls server prove that
14951    // the configured TLS material is CARRIED into the webpki fallback
14952    // backend: the strict material-free build demonstrably routes
14953    // through the forced webpki fallback (its Mozilla roots reject the
14954    // rcgen CA), while every material-carrying build completes a real
14955    // handshake against a server certified ONLY by the configured CA.
14956    //
14957    // Path forcing (e_glm adjudication, rc-hl9cn — Task 1.3 deviation
14958    // note in openspec/changes/castrict/tasks.md): rustls-platform-
14959    // verifier 0.7.0 merges configured extra roots into the platform
14960    // store FIRST and hard-errors only when the merged store is EMPTY,
14961    // so on Linux an empty `SSL_CERT_FILE`/`SSL_CERT_DIR` window plus a
14962    // valid configured CA keeps the PRIMARY build succeeding (extra-
14963    // roots rescue); the fallback-with-valid-material path is real only
14964    // on verifier-hard-error platforms (android/apple — the Termux
14965    // case). The material-carrying tests below therefore run
14966    // `build_client` under the `force_webpki_fallback` seam (primary
14967    // skipped, fallback entered directly; the trigger warn's error
14968    // field reads "forced webpki fallback entry (test)") and prove path
14969    // control with a `build_client_fallback_count()` delta of exactly
14970    // one. The material-free test keeps the REAL env window — no extra
14971    // roots exist to rescue the verifier, so its fallback entry is
14972    // genuine. Every send is timeout-bounded, so a broken harness
14973    // fails fast instead of hanging.
14974
14975    #[tracing_test::traced_test]
14976    #[tokio::test]
14977    async fn tls_handshake_strict_custom_ca_carried() {
14978        let ca = gen_test_ca();
14979        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
14980        let ca_path = dir.path().join("ca.pem");
14981        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
14982        let (server_cert, server_key) =
14983            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
14984        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
14985
14986        let config = HttpConfig {
14987            tls: Some(TlsConfig {
14988                enabled: true,
14989                strict: true,
14990                ca_cert_path: Some(ca_path.display().to_string()),
14991                ..Default::default()
14992            }),
14993            ..Default::default()
14994        };
14995        let client = build_under_seam(&config);
14996
14997        let resp = tokio::time::timeout(
14998            Duration::from_secs(15),
14999            client.get(format!("https://{addr}/")).send(),
15000        )
15001        .await
15002        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15003        .expect("strict webpki fallback must carry the configured CA into the handshake"); // allow-unwrap(test)
15004        assert_eq!(resp.status().as_u16(), 200);
15005        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15006        assert_eq!(body, "ok");
15007
15008        // Path control beyond the counter: the strict material-carried
15009        // info log fires inside the fallback (scope-filtered
15010        // `logs_contain`, injected by #[traced_test], so sibling tests'
15011        // events cannot pollute this assertion).
15012        assert!(
15013            logs_contain("carries configured TLS material"),
15014            "strict webpki fallback with configured material must log the carry"
15015        );
15016
15017        server.abort();
15018    }
15019
15020    #[tracing_test::traced_test]
15021    #[tokio::test]
15022    async fn tls_handshake_strict_material_free_fails_same_server() {
15023        let ca = gen_test_ca();
15024        let (server_cert, server_key) =
15025            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15026        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15027
15028        let config = HttpConfig {
15029            tls: Some(TlsConfig {
15030                enabled: true,
15031                strict: true,
15032                ..Default::default()
15033            }),
15034            ..Default::default()
15035        };
15036        let client = forced_fallback_env(|| {
15037            build_client(&config, None).expect("client must build") // allow-unwrap(test)
15038        });
15039
15040        let send_result = tokio::time::timeout(
15041            Duration::from_secs(15),
15042            client.get(format!("https://{addr}/")).send(),
15043        )
15044        .await
15045        .expect("request must complete within the harness timeout"); // allow-unwrap(test)
15046        assert!(
15047            send_result.is_err(),
15048            "material-free strict fallback trusts only Mozilla roots, which do not \
15049             trust the rcgen CA — the handshake must fail"
15050        );
15051
15052        // No TLS material configured: no per-item degradation warns
15053        // (spec scenario — material-free strict fallback). Scope-
15054        // filtered `logs_contain` (injected by #[traced_test]) so
15055        // sibling tests' mTLS-degrade warns cannot pollute the
15056        // negations; the positive control proves capture works.
15057        assert!(
15058            logs_contain("HTTP client build failed on platform TLS roots"),
15059            "positive control: this test's own fallback warn must be captured"
15060        );
15061        assert!(
15062            !logs_contain("client certificate NOT used"),
15063            "no identity was configured — the identity degrade warn must not fire"
15064        );
15065        assert!(
15066            !logs_contain("falling back to bundled Mozilla roots"),
15067            "no CA was configured — the CA degrade warn must not fire"
15068        );
15069
15070        server.abort();
15071    }
15072
15073    #[tracing_test::traced_test]
15074    #[tokio::test]
15075    async fn tls_handshake_strict_mtls_identity_carried() {
15076        let ca = gen_test_ca();
15077        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15078        let ca_path = dir.path().join("ca.pem");
15079        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
15080        let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
15081        let client_cert_path = dir.path().join("client-cert.pem");
15082        std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); // allow-unwrap(test)
15083        let client_key_path = dir.path().join("client-key.pem");
15084        std::fs::write(&client_key_path, client_key_pem.as_bytes()).expect("write client key"); // allow-unwrap(test)
15085        let (server_cert, server_key) =
15086            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15087        // The server REQUIRES client certificates verified against the
15088        // same CA that signed the identity.
15089        let (addr, server) = spawn_tls_server(&server_cert, &server_key, Some(&ca.ca_pem)).await;
15090
15091        let config = HttpConfig {
15092            tls: Some(TlsConfig {
15093                enabled: true,
15094                strict: true,
15095                ca_cert_path: Some(ca_path.display().to_string()),
15096                client_cert_path: Some(client_cert_path.display().to_string()),
15097                client_key_path: Some(client_key_path.display().to_string()),
15098                ..Default::default()
15099            }),
15100            ..Default::default()
15101        };
15102        let client = build_under_seam(&config);
15103
15104        let resp = tokio::time::timeout(
15105            Duration::from_secs(15),
15106            client.get(format!("https://{addr}/")).send(),
15107        )
15108        .await
15109        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15110        .expect("strict webpki fallback must carry the mTLS identity into the handshake"); // allow-unwrap(test)
15111        assert_eq!(resp.status().as_u16(), 200);
15112        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15113        assert_eq!(body, "ok");
15114
15115        // Path control beyond the counter: the strict material-carried
15116        // info log fires inside the fallback (scope-filtered
15117        // `logs_contain`, injected by #[traced_test]).
15118        assert!(
15119            logs_contain("carries configured TLS material"),
15120            "strict webpki fallback with configured material must log the carry"
15121        );
15122
15123        server.abort();
15124    }
15125
15126    #[tokio::test]
15127    async fn tls_handshake_strict_mtls_less_client_rejected() {
15128        let ca = gen_test_ca();
15129        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15130        let ca_path = dir.path().join("ca.pem");
15131        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
15132        let (server_cert, server_key) =
15133            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15134        let (addr, server) = spawn_tls_server(&server_cert, &server_key, Some(&ca.ca_pem)).await;
15135
15136        // Same CA-carried strict config but WITHOUT the client
15137        // identity: the handshake must fail server-side.
15138        let config = HttpConfig {
15139            tls: Some(TlsConfig {
15140                enabled: true,
15141                strict: true,
15142                ca_cert_path: Some(ca_path.display().to_string()),
15143                ..Default::default()
15144            }),
15145            ..Default::default()
15146        };
15147        let client = build_under_seam(&config);
15148
15149        let send_result = tokio::time::timeout(
15150            Duration::from_secs(15),
15151            client.get(format!("https://{addr}/")).send(),
15152        )
15153        .await
15154        .expect("request must complete within the harness timeout"); // allow-unwrap(test)
15155        assert!(
15156            send_result.is_err(),
15157            "the server requires client certificates — an identity-less client \
15158             must be rejected at the handshake"
15159        );
15160
15161        server.abort();
15162    }
15163
15164    #[tracing_test::traced_test]
15165    #[tokio::test]
15166    async fn tls_handshake_nonstrict_valid_ca_carried() {
15167        let ca = gen_test_ca();
15168        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15169        let ca_path = dir.path().join("ca.pem");
15170        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
15171        let (server_cert, server_key) =
15172            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15173        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15174
15175        let config = HttpConfig {
15176            tls: Some(TlsConfig {
15177                enabled: true,
15178                strict: false,
15179                ca_cert_path: Some(ca_path.display().to_string()),
15180                ..Default::default()
15181            }),
15182            ..Default::default()
15183        };
15184        let client = build_under_seam(&config);
15185
15186        let resp = tokio::time::timeout(
15187            Duration::from_secs(15),
15188            client.get(format!("https://{addr}/")).send(),
15189        )
15190        .await
15191        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15192        .expect("non-strict webpki fallback must carry the valid configured CA into the handshake"); // allow-unwrap(test)
15193        assert_eq!(resp.status().as_u16(), 200);
15194        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15195        assert_eq!(body, "ok");
15196
15197        // Loadable material never degrades: no per-item warns even
15198        // outside strict mode (spec scenario — valid material carried
15199        // regardless of strict). Scope-filtered `logs_contain`
15200        // (injected by #[traced_test]) so sibling tests' mTLS-degrade
15201        // warns cannot pollute the negations; the counter delta above
15202        // is the positive path control. The platform warn DOES fire
15203        // under the seam — that is the Forced-trigger notice, not a
15204        // material warn; the negations below target the per-item
15205        // material warns only.
15206        assert!(
15207            !logs_contain("client certificate NOT used"),
15208            "no identity was configured — the identity degrade warn must not fire"
15209        );
15210        assert!(
15211            !logs_contain("falling back to bundled Mozilla roots"),
15212            "the CA loaded cleanly — the CA degrade warn must not fire"
15213        );
15214
15215        server.abort();
15216    }
15217
15218    /// Genuine-trigger anchor for the force seam (castrict 1.3-deviation,
15219    /// Option C, weakened variant): strict mTLS identity WITHOUT
15220    /// `ca_cert_path`, verification ON, under the REAL env window — no
15221    /// force seam, and no extra roots exist to rescue the verifier, so
15222    /// the primary genuinely fails. The prescribed 200-ok variant is
15223    /// architecturally unreachable: reqwest's `!certs_verification`
15224    /// branch constructs the NoVerifier verifier and never builds the
15225    /// platform verifier, so a verify-off config cannot genuinely fail
15226    /// the primary on ANY platform; and every verify-on genuine fallback
15227    /// trusts only Mozilla ∪ custom roots, which cannot handshake with a
15228    /// hermetic rcgen CA. Handshake-level identity proof therefore lives
15229    /// in the seam tests, which share all downstream code from
15230    /// `fallback_client_config` onward.
15231    #[tracing_test::traced_test]
15232    #[tokio::test]
15233    async fn tls_handshake_mtls_identity_only_genuine_fallback_anchor() {
15234        let ca = gen_test_ca();
15235        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15236        let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
15237        let client_cert_path = dir.path().join("client-cert.pem");
15238        std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); // allow-unwrap(test)
15239        let client_key_path = dir.path().join("client-key.pem");
15240        std::fs::write(&client_key_path, client_key_pem.as_bytes()).expect("write client key"); // allow-unwrap(test)
15241
15242        let config = HttpConfig {
15243            tls: Some(TlsConfig {
15244                enabled: true,
15245                strict: true,
15246                client_cert_path: Some(client_cert_path.display().to_string()),
15247                client_key_path: Some(client_key_path.display().to_string()),
15248                ..Default::default()
15249            }),
15250            ..Default::default()
15251        };
15252        let (client, fallbacks_taken) = {
15253            let fallbacks_before = build_client_fallback_count();
15254            let client = forced_fallback_env(|| {
15255                build_client(&config, None).expect("client must build") // allow-unwrap(test)
15256            });
15257            (client, build_client_fallback_count() - fallbacks_before)
15258        };
15259        assert_eq!(
15260            fallbacks_taken, 1,
15261            "the empty-store env window must genuinely fail the platform-verifier primary"
15262        );
15263
15264        // The strict identity is carried into the fallback config
15265        // (`with_client_auth_cert` accepted it) — the build is Ok, not
15266        // fail-closed. No handshake is attempted: the fallback client
15267        // trusts only Mozilla roots client-side, which cannot verify the
15268        // hermetic rcgen CA, so a send would fail by design (see the
15269        // comment above).
15270        drop(client);
15271
15272        // Trigger authenticity: the REAL platform warn (not the Forced
15273        // seam notice) fires inside the fallback. Scope-filtered
15274        // `logs_contain` (injected by #[traced_test]) so sibling tests'
15275        // events cannot pollute the assertion.
15276        assert!(
15277            logs_contain("HTTP client build failed on platform TLS roots"),
15278            "the genuine env-window trigger must emit the real platform warn"
15279        );
15280    }
15281
15282    // Task 1.4 (castrict): verification-parity coverage. The two
15283    // danger-mode parity tests prove a verify-off fallback config
15284    // handshakes with a self-signed server the Mozilla/platform roots
15285    // cannot trust; the remaining three prove strict material failures
15286    // still fail closed, non-strict degradation stays item-wise, and a
15287    // verifying fallback rejects the same self-signed server — the
15288    // control showing the parity tests pass BECAUSE of the danger
15289    // verifier, not because the harness skips verification. All five
15290    // build under the FORCE_WEBPKI_FALLBACK seam: danger mode makes
15291    // reqwest construct the NoVerifier branch and never build the
15292    // platform verifier, so the primary cannot genuinely fail for
15293    // verify-off configs on ANY platform (1.3-deviation note in
15294    // openspec/changes/castrict/tasks.md).
15295
15296    /// Self-signed (NOT CA-signed) server leaf: signer unrelated to any
15297    /// configured CA — exactly what the parity scenarios need.
15298    #[tokio::test]
15299    async fn tls_parity_insecure_self_signed_succeeds() {
15300        let (server_cert, server_key) = gen_self_signed_server_cert();
15301        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15302
15303        let config = HttpConfig {
15304            tls: Some(TlsConfig {
15305                enabled: true,
15306                strict: false,
15307                insecure: true,
15308                ..Default::default()
15309            }),
15310            ..Default::default()
15311        };
15312        let client = build_under_seam(&config);
15313
15314        let resp = tokio::time::timeout(
15315            Duration::from_secs(15),
15316            client.get(format!("https://{addr}/")).send(),
15317        )
15318        .await
15319        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15320        .expect("verify-off (insecure) fallback must handshake with the self-signed server"); // allow-unwrap(test)
15321        assert_eq!(resp.status().as_u16(), 200);
15322        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15323        assert_eq!(body, "ok");
15324
15325        server.abort();
15326    }
15327
15328    #[tokio::test]
15329    async fn tls_parity_verify_peer_false_self_signed_succeeds() {
15330        let (server_cert, server_key) = gen_self_signed_server_cert();
15331        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15332
15333        let config = HttpConfig {
15334            tls: Some(TlsConfig {
15335                enabled: true,
15336                strict: false,
15337                verify_peer: false,
15338                ..Default::default()
15339            }),
15340            ..Default::default()
15341        };
15342        let client = build_under_seam(&config);
15343
15344        let resp = tokio::time::timeout(
15345            Duration::from_secs(15),
15346            client.get(format!("https://{addr}/")).send(),
15347        )
15348        .await
15349        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15350        .expect("verify_peer=false fallback must handshake with the self-signed server"); // allow-unwrap(test)
15351        assert_eq!(resp.status().as_u16(), 200);
15352        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15353        assert_eq!(body, "ok");
15354
15355        server.abort();
15356    }
15357
15358    #[tokio::test]
15359    async fn tls_parity_insecure_strict_bad_material_still_fails_closed() {
15360        // No server needed: the build must fail before any connection.
15361        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15362        // Store-rejected fixture: a PEM CERTIFICATE section whose body
15363        // is not base64 — the root store accepts 0 of 1 certs.
15364        let ca_path = dir.path().join("rejected-ca.pem");
15365        std::fs::write(
15366            &ca_path,
15367            b"-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
15368        )
15369        .expect("write rejected ca fixture"); // allow-unwrap(test)
15370
15371        let config = HttpConfig {
15372            tls: Some(TlsConfig {
15373                enabled: true,
15374                strict: true,
15375                insecure: true,
15376                ca_cert_path: Some(ca_path.display().to_string()),
15377                ..Default::default()
15378            }),
15379            ..Default::default()
15380        };
15381        // Raw force-guard + match: build_under_seam expects Ok, and the
15382        // strict material failure must surface as Err before any send —
15383        // verification-disable never masks strict material failure.
15384        let err = match force_webpki_fallback(|| build_client(&config, None)) {
15385            Err(e) => e,
15386            Ok(_) => panic!(
15387                "strict mode must fail closed on store-rejected CA material \
15388                 even with insecure=true"
15389            ),
15390        };
15391        assert_strict_fallback_error(err, &["tls.strict/webpki-fallback"]);
15392    }
15393
15394    #[test]
15395    fn strict_rebuild_failure_returns_typed_error() {
15396        let StrictMaterial {
15397            dir: _dir,
15398            ca_path,
15399            cert_path,
15400            key_path,
15401            ..
15402        } = strict_material_fixtures();
15403
15404        // VALID material throughout: no material error can precede the
15405        // rebuild terminal, so any typed Err must come from the
15406        // strict-fail-closed second-error terminal itself.
15407        let config = HttpConfig {
15408            tls: Some(TlsConfig {
15409                enabled: true,
15410                strict: true,
15411                ca_cert_path: Some(ca_path.display().to_string()),
15412                client_cert_path: Some(cert_path.display().to_string()),
15413                client_key_path: Some(key_path.display().to_string()),
15414                ..Default::default()
15415            }),
15416            ..Default::default()
15417        };
15418        let fallbacks_before = build_client_fallback_count();
15419        let err = match force_webpki_fallback_rebuild_failure(|| build_client(&config, None)) {
15420            Err(e) => e,
15421            Ok(_) => panic!(
15422                "strict mode must fail closed when the webpki fallback client \
15423                 rebuild fails — never the material-free emergency client"
15424            ),
15425        };
15426        assert_eq!(
15427            build_client_fallback_count() - fallbacks_before,
15428            1,
15429            "the rebuild-failure call must route through the webpki fallback exactly once"
15430        );
15431        assert_strict_fallback_error(
15432            err,
15433            &[
15434                "tls.strict/webpki-fallback:",
15435                "webpki fallback client rebuild failed",
15436                "forced webpki fallback rebuild failure",
15437            ],
15438        );
15439    }
15440
15441    #[test]
15442    fn non_strict_rebuild_failure_returns_emergency_client() {
15443        let StrictMaterial {
15444            dir: _dir,
15445            ca_path,
15446            cert_path,
15447            key_path,
15448            ..
15449        } = strict_material_fixtures();
15450
15451        let config = HttpConfig {
15452            tls: Some(TlsConfig {
15453                enabled: true,
15454                strict: false,
15455                ca_cert_path: Some(ca_path.display().to_string()),
15456                client_cert_path: Some(cert_path.display().to_string()),
15457                client_key_path: Some(key_path.display().to_string()),
15458                ..Default::default()
15459            }),
15460            ..Default::default()
15461        };
15462        let fallbacks_before = build_client_fallback_count();
15463        let _client = force_webpki_fallback_rebuild_failure(|| build_client(&config, None))
15464            .expect("non-strict rebuild failure must degrade to the emergency client"); // allow-unwrap(test)
15465        assert_eq!(
15466            build_client_fallback_count() - fallbacks_before,
15467            1,
15468            "the rebuild-failure call must route through the webpki fallback exactly once"
15469        );
15470    }
15471
15472    #[test]
15473    fn strict_rebuild_failure_surfaces_at_endpoint_creation() {
15474        let StrictMaterial {
15475            dir: _dir,
15476            ca_path,
15477            cert_path,
15478            key_path,
15479            ..
15480        } = strict_material_fixtures();
15481
15482        // VALID strict config: no material error can precede the
15483        // rebuild terminal, so any folded Err must come from the
15484        // strict-fail-closed rebuild terminal itself.
15485        let config = HttpConfig {
15486            tls: Some(TlsConfig {
15487                enabled: true,
15488                strict: true,
15489                ca_cert_path: Some(ca_path.display().to_string()),
15490                client_cert_path: Some(cert_path.display().to_string()),
15491                client_key_path: Some(key_path.display().to_string()),
15492                ..Default::default()
15493            }),
15494            ..Default::default()
15495        };
15496
15497        let endpoint_err = force_webpki_fallback_rebuild_failure(|| {
15498            // Construction must complete without panic; the rebuild-
15499            // failure error folds into strict_tls_error and surfaces at
15500            // endpoint creation (mirrors the forced-fallback fold test).
15501            let component = HttpComponent::with_config(config);
15502            component
15503                .create_endpoint("http://h/p", &NoOpComponentContext)
15504                .err()
15505        });
15506
15507        let err = endpoint_err.expect("folded rebuild failure must fail endpoint creation"); // allow-unwrap(test)
15508        assert_strict_fallback_error(
15509            err,
15510            &[
15511                "tls.strict/webpki-fallback:",
15512                "webpki fallback client rebuild failed",
15513            ],
15514        );
15515    }
15516
15517    #[tracing_test::traced_test]
15518    #[tokio::test]
15519    async fn non_strict_rebuild_failure_drops_custom_trust() {
15520        let StrictMaterial {
15521            dir: _dir,
15522            ca,
15523            ca_path,
15524            ..
15525        } = strict_material_fixtures();
15526
15527        // Server certified ONLY by the same CA the config carries: a
15528        // material-carrying fallback build completes this handshake,
15529        // the emergency client cannot.
15530        let (server_cert, server_key) =
15531            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15532        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15533
15534        let config = HttpConfig {
15535            tls: Some(TlsConfig {
15536                enabled: true,
15537                strict: false,
15538                ca_cert_path: Some(ca_path.display().to_string()),
15539                ..Default::default()
15540            }),
15541            ..Default::default()
15542        };
15543        // Arrange phase — build BOTH clients first, each inside its own
15544        // seam guard scope (the guards must not nest); requests run
15545        // AFTER both scopes close, matching the isolation pattern of
15546        // the parity tests.
15547        //
15548        // CONTROL: forced fallback WITHOUT the rebuild-failure seam —
15549        // the normal fallback build honors ca_cert_path, so this client
15550        // carries the CA and verifies the server.
15551        let control = force_webpki_fallback(|| {
15552            build_client(&config, None).expect("control client must build") // allow-unwrap(test)
15553        });
15554        // EMERGENCY: both seams armed — the rebuild-failure
15555        // substitution drops the custom trust, leaving Mozilla roots
15556        // only.
15557        let client = force_webpki_fallback_rebuild_failure(|| {
15558            build_client(&config, None).expect("emergency client must build") // allow-unwrap(test)
15559        });
15560
15561        // Assert phase — same server, and the ONLY delta between the
15562        // two clients is the rebuild-failure substitution: control
15563        // succeeds (positive control), emergency fails (causal trust
15564        // loss, not any incidental network condition).
15565        let control_resp = tokio::time::timeout(
15566            Duration::from_secs(15),
15567            control.get(format!("https://{addr}/")).send(),
15568        )
15569        .await
15570        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15571        .expect("the control client carries the CA — the CA-signed handshake must succeed"); // allow-unwrap(test)
15572        assert_eq!(control_resp.status().as_u16(), 200);
15573
15574        let send_result = tokio::time::timeout(
15575            Duration::from_secs(15),
15576            client.get(format!("https://{addr}/")).send(),
15577        )
15578        .await
15579        .expect("request must complete within the harness timeout"); // allow-unwrap(test)
15580        assert!(
15581            send_result.is_err(),
15582            "the emergency client carries Mozilla roots only — the configured \
15583             CA trust is provably dropped, so the CA-signed handshake must fail"
15584        );
15585
15586        // The rebuild terminal's system-broken error line is unchanged
15587        // by this change. Scope-filtered `logs_contain` (injected by
15588        // #[traced_test]) so sibling tests' events cannot pollute.
15589        assert!(
15590            logs_contain("webpki fallback client build failed — TLS stack broken process-wide"),
15591            "the rebuild-failure terminal must log the system-broken diagnosis"
15592        );
15593
15594        server.abort();
15595    }
15596
15597    #[tracing_test::traced_test]
15598    #[tokio::test]
15599    async fn tls_parity_nonstrict_itemwise_valid_ca_bad_identity() {
15600        let ca = gen_test_ca();
15601        let dir = tempfile::tempdir().expect("tempdir"); // allow-unwrap(test)
15602        let ca_path = dir.path().join("ca.pem");
15603        std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); // allow-unwrap(test)
15604        let (client_cert_pem, _client_key_pem) = gen_client_identity(&ca);
15605        let client_cert_path = dir.path().join("client-cert.pem");
15606        std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); // allow-unwrap(test)
15607        let (server_cert, server_key) =
15608            gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); // allow-unwrap(test)
15609        // No client-auth requirement: the identity item's fate is
15610        // client-side only.
15611        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15612
15613        let config = HttpConfig {
15614            tls: Some(TlsConfig {
15615                enabled: true,
15616                strict: false,
15617                ca_cert_path: Some(ca_path.display().to_string()),
15618                client_cert_path: Some(client_cert_path.display().to_string()),
15619                // UNREADABLE key path per the spec scenario: the mTLS
15620                // pair cannot load, so the identity item degrades.
15621                client_key_path: Some("/nonexistent/key.pem".to_string()),
15622                ..Default::default()
15623            }),
15624            ..Default::default()
15625        };
15626        let client = build_under_seam(&config);
15627
15628        let resp = tokio::time::timeout(
15629            Duration::from_secs(15),
15630            client.get(format!("https://{addr}/")).send(),
15631        )
15632        .await
15633        .expect("request must complete within the harness timeout") // allow-unwrap(test)
15634        .expect("the valid CA item must be carried despite the unreadable key"); // allow-unwrap(test)
15635        assert_eq!(resp.status().as_u16(), 200);
15636        let body = resp.text().await.expect("response body"); // allow-unwrap(test)
15637        assert_eq!(body, "ok");
15638
15639        // Item-wise degradation: the identity item warned and was
15640        // dropped; the CA item loaded cleanly — no Mozilla-roots
15641        // downgrade. Scope-filtered `logs_contain` (injected by
15642        // #[traced_test]) so sibling tests' events cannot pollute.
15643        assert!(
15644            logs_contain("client certificate NOT used"),
15645            "the unreadable key must degrade the identity item with the mTLS warn"
15646        );
15647        assert!(
15648            !logs_contain("falling back to bundled Mozilla roots"),
15649            "the CA loaded cleanly — the CA degrade warn must not fire"
15650        );
15651
15652        server.abort();
15653    }
15654
15655    #[tokio::test]
15656    async fn tls_parity_verifying_client_rejects_self_signed_without_insecure() {
15657        let (server_cert, server_key) = gen_self_signed_server_cert();
15658        let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15659
15660        // Strict, NO material, verification ON: the fallback trusts
15661        // only Mozilla roots, which cannot verify the self-signed
15662        // server — the control proving the two parity tests above pass
15663        // BECAUSE of the danger verifier, not because the harness
15664        // skips verification.
15665        let config = HttpConfig {
15666            tls: Some(TlsConfig {
15667                enabled: true,
15668                strict: true,
15669                ..Default::default()
15670            }),
15671            ..Default::default()
15672        };
15673        let client = build_under_seam(&config);
15674
15675        let send_result = tokio::time::timeout(
15676            Duration::from_secs(15),
15677            client.get(format!("https://{addr}/")).send(),
15678        )
15679        .await
15680        .expect("request must complete within the harness timeout"); // allow-unwrap(test)
15681        assert!(
15682            send_result.is_err(),
15683            "a verifying client must reject the self-signed server at the handshake"
15684        );
15685
15686        server.abort();
15687    }
15688}