Skip to main content

camel_component_http/
lib.rs

1pub mod bundle;
2pub(crate) mod client_cache;
3pub mod config;
4mod header_policy;
5pub mod health;
6pub mod registry;
7pub(crate) mod rest_match;
8pub(crate) mod ssrf;
9pub mod static_config;
10pub mod static_dispatch;
11pub mod static_endpoint;
12pub(crate) mod tls_reload;
13use crate::config::parse_ok_status_code_range;
14pub use bundle::HttpBundle;
15pub use bundle::HttpStaticBundle;
16pub(crate) use client_cache::{
17    HttpComponentKind, PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache,
18};
19pub use config::HttpConfig;
20pub use health::HttpHealthCheck;
21pub use registry::HttpRouteRegistry;
22pub use static_config::HttpStaticConfig;
23pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
24
25use std::collections::HashMap;
26use std::future::Future;
27use std::pin::Pin;
28
29use std::sync::{Arc, Mutex, OnceLock};
30use std::task::{Context, Poll};
31use std::time::Duration;
32
33use tokio::sync::OnceCell;
34use tower::Layer;
35use tower::Service;
36use tracing::debug;
37
38use axum::body::BodyDataStream;
39use camel_api::component_metadata::ComponentMetadata;
40use camel_auth::bearer_token_layer::BearerTokenLayer;
41use camel_auth::oauth2::TokenProvider;
42use camel_component_api::tls_source::ServerTlsSource;
43use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
44use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
45use camel_component_api::{UriComponents, UriConfig, parse_uri, raw_query_pairs};
46use futures::StreamExt;
47use futures::TryStreamExt;
48use futures::stream::BoxStream;
49
50// ---------------------------------------------------------------------------
51// HttpEndpointConfig
52// ---------------------------------------------------------------------------
53
54/// Configuration for an HTTP client (producer) endpoint.
55///
56/// # Memory Limits
57///
58/// HTTP operations enforce conservative memory limits to prevent denial-of-service
59/// attacks from untrusted network sources. These limits are significantly lower than
60/// file component limits (100MB) because HTTP typically handles API responses rather
61/// than large file transfers, and clients may be untrusted.
62///
63/// ## Default Limits
64///
65/// - **HTTP client body**: 10MB (typical API responses)
66/// - **HTTP server request**: 2MB (untrusted network input - see `HttpServerConfig`)
67/// - **HTTP server response**: 10MB (same as client - see `HttpServerConfig`)
68///
69/// ## Rationale
70///
71/// The 10MB limit for HTTP client responses is appropriate for most API interactions
72/// while providing protection against:
73/// - Malicious servers sending oversized responses
74/// - Runaway processes generating unexpectedly large payloads
75/// - Memory exhaustion attacks
76///
77/// The 2MB server request limit is even more conservative because it handles input
78/// from potentially untrusted clients on the public internet.
79///
80/// ## Overriding Limits
81///
82/// Override the default client body limit using the `maxBodySize` URI parameter:
83///
84/// ```text
85/// http://api.example.com/large-data?maxBodySize=52428800
86/// ```
87///
88/// For server endpoints, use `maxRequestBody` and `maxResponseBody` parameters:
89///
90/// ```text
91/// http://0.0.0.0:8080/upload?maxRequestBody=52428800
92/// ```
93///
94/// ## Behavior When Exceeded
95///
96/// When a body exceeds the configured limit:
97/// - An error is returned immediately
98/// - No memory is exhausted - the limit is checked before allocation
99/// - The HTTP connection is terminated cleanly
100///
101/// ## Security Considerations
102///
103/// HTTP endpoints should be treated with more caution than file endpoints because:
104/// - Clients may be unknown and untrusted
105/// - Network traffic can be spoofed or malicious
106/// - DoS attacks often exploit unbounded resource consumption
107///
108/// Only increase limits when you control both ends of the connection or when
109/// business requirements demand larger payloads.
110#[derive(Clone)]
111pub struct HttpEndpointConfig {
112    pub base_url: String,
113    pub http_method: Option<String>,
114    pub throw_exception_on_failure: bool,
115    pub ok_status_code_range: (u16, u16),
116    pub response_timeout: Option<Duration>,
117    /// Programmatic query parameters, serialized in declaration order with
118    /// minimal RFC-3986 encoding (`%20`, never `+`). Never populated from
119    /// the endpoint URI — set by callers via config construction.
120    pub query_params: Vec<(String, String)>,
121    /// Authored query bytes from the endpoint URI, verbatim (no decode, no
122    /// re-encode, no `RAW(...)` unwrapping). `Some("")` preserves a bare
123    /// `?` marker. Sole carrier of URI-authored pairs; consumed option
124    /// keys are filtered out at serialization time.
125    pub raw_query: Option<String>,
126    pub allow_internal: bool,
127    /// Public-cleartext transport consent (ADR-0081): when `false` (the
128    /// default), cleartext `http://` requests to PUBLIC targets are
129    /// rejected. Independent of `allow_internal` — internal-network
130    /// consent and cleartext-transport consent are separate decisions, and
131    /// there is deliberately no global cleartext lever.
132    pub allow_cleartext: bool,
133    pub blocked_hosts: Vec<String>,
134    pub max_body_size: usize,
135    pub read_timeout_ms: u64,
136    pub max_response_bytes: usize,
137    pub auth: HttpAuth,
138    pub token_provider: Option<Arc<dyn TokenProvider>>,
139    pub user_agent: Option<String>,
140    pub bridge_endpoint: bool,
141    pub connection_close: bool,
142    pub skip_request_headers: Vec<String>,
143    pub skip_response_headers: Vec<String>,
144    pub follow_redirects: bool,
145    pub max_redirects: usize,
146    /// CamelHttpUri host fence (`allowedUriHosts`): `None` when the option
147    /// is absent (override behavior unchanged); `Some` arms the fail-closed
148    /// fence. Parsed entries only — never re-serialized into the outbound
149    /// query.
150    pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
151}
152
153/// ADR-0051 redact-by-construction, ADR-0076 strictest-wins: query bytes
154/// (authored `raw_query` and programmatic `query_params`) may carry
155/// credentials. The display-surface Debug renders the raw view
156/// blanket-masked (mirroring `redact_url_for_diagnostics`) and programmatic
157/// values masked, mirroring `UriComponents`' sensitive-value masking.
158/// `base_url` routes through the canonical
159/// [`camel_api::redact::redact_url`] (string surgery, no `url::Url`
160/// roundtrip, so authored bytes are never WHATWG-normalized): userinfo is
161/// masked in every authority window, query and fragment bytes are dropped
162/// behind their sentinels, and the result is capped at 256 bytes (rc-yvjp3
163/// converged the former byte-preserving local variant). Wire fidelity is
164/// unaffected.
165impl std::fmt::Debug for HttpEndpointConfig {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("HttpEndpointConfig")
168            .field("base_url", &camel_api::redact::redact_url(&self.base_url))
169            .field("http_method", &self.http_method)
170            .field(
171                "throw_exception_on_failure",
172                &self.throw_exception_on_failure,
173            )
174            .field("ok_status_code_range", &self.ok_status_code_range)
175            .field("response_timeout", &self.response_timeout)
176            .field(
177                "query_params",
178                &self
179                    .query_params
180                    .iter()
181                    .map(|(key, _)| (key, "***"))
182                    .collect::<Vec<_>>(),
183            )
184            .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
185            .field("allow_internal", &self.allow_internal)
186            .field("allow_cleartext", &self.allow_cleartext)
187            .field("blocked_hosts", &self.blocked_hosts)
188            .field("max_body_size", &self.max_body_size)
189            .field("read_timeout_ms", &self.read_timeout_ms)
190            .field("max_response_bytes", &self.max_response_bytes)
191            .field("auth", &self.auth)
192            .field("token_provider", &self.token_provider)
193            .field("user_agent", &self.user_agent)
194            .field("bridge_endpoint", &self.bridge_endpoint)
195            .field("connection_close", &self.connection_close)
196            .field("skip_request_headers", &self.skip_request_headers)
197            .field("skip_response_headers", &self.skip_response_headers)
198            .field("follow_redirects", &self.follow_redirects)
199            .field("max_redirects", &self.max_redirects)
200            .field("allowed_uri_hosts", &self.allowed_uri_hosts)
201            .finish()
202    }
203}
204
205#[derive(Clone, PartialEq)]
206pub enum HttpAuth {
207    None,
208    Basic { username: String, password: String },
209    Bearer { token: String },
210}
211
212impl std::fmt::Debug for HttpAuth {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        match self {
215            HttpAuth::None => f.write_str("None"),
216            HttpAuth::Basic { username, .. } => f
217                .debug_struct("Basic")
218                .field("username", username)
219                .field("password", &"***")
220                .finish(),
221            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
222        }
223    }
224}
225
226/// Whether `key` names a camel-http endpoint option consumed at parse time.
227///
228/// Single metadata-driven owner of OUTBOUND option filtering (ADR-0041):
229/// derived from the `#[uri_param]` metadata behind
230/// [`HttpEndpointConfig::uri_options`], so the raw query filter consumes
231/// exactly the keys the component documents — no duplicated handwritten
232/// key lists. `from_components`'s manual typed parsing stays direct and
233/// unchanged; this predicate never re-wires it.
234fn is_consumed_option(key: &str) -> bool {
235    HttpEndpointConfig::uri_options()
236        .iter()
237        .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
238}
239
240impl UriConfig for HttpEndpointConfig {
241    /// Returns "http" as the primary scheme (also accepts "https")
242    fn scheme() -> &'static str {
243        "http"
244    }
245
246    fn from_uri(uri: &str) -> Result<Self, CamelError> {
247        let parts = parse_uri(uri)?;
248        Self::from_components(parts)
249    }
250
251    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
252        // Validate scheme - accept both http and https
253        if parts.scheme != "http" && parts.scheme != "https" {
254            return Err(CamelError::InvalidUri(format!(
255                "expected scheme 'http' or 'https', got '{}'",
256                parts.scheme
257            )));
258        }
259
260        // Construct base_url from scheme + path
261        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
262        let base_url = format!("{}:{}", parts.scheme, parts.path);
263
264        let http_method = parts.params.get("httpMethod").cloned();
265
266        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
267            Some(v) => parse_bool_param_http(v).map_err(|e| {
268                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
269            })?,
270            None => true,
271        };
272
273        // Parse status code range from "start-end" format (e.g., "200-299")
274        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
275            Some(v) => parse_ok_status_code_range(v)?,
276            None => (200, 299),
277        };
278
279        let response_timeout = match parts.params.get("responseTimeout") {
280            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
281                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
282            })?),
283            None => None,
284        };
285
286        // SSRF protection settings
287        let allow_internal = match parts.params.get("allowInternal") {
288            Some(v) => parse_bool_param_http(v).map_err(|e| {
289                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
290            })?,
291            None => false, // Default: block private IPs
292        };
293
294        // Public-cleartext transport consent (ADR-0081) — per-endpoint
295        // only, deliberately never inherited from the global HttpConfig.
296        let allow_cleartext = match parts.params.get("allowCleartext") {
297            Some(v) => parse_bool_param_http(v).map_err(|e| {
298                CamelError::InvalidUri(format!("invalid value for allowCleartext: {e}"))
299            })?,
300            None => false, // Default: reject cleartext http:// to public targets
301        };
302
303        // Parse comma-separated blocked hosts
304        let blocked_hosts = parts
305            .params
306            .get("blockedHosts")
307            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
308            .unwrap_or_default();
309
310        let max_body_size = match parts.params.get("maxBodySize") {
311            Some(v) => v.parse::<usize>().map_err(|e| {
312                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
313            })?,
314            None => 10 * 1024 * 1024, // Default: 10MB
315        };
316
317        let read_timeout_ms = match parts.params.get("readTimeout") {
318            Some(v) => v.parse::<u64>().map_err(|e| {
319                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
320            })?,
321            None => 30_000, // Default: 30s
322        };
323
324        let max_response_bytes = match parts.params.get("maxResponseBytes") {
325            Some(v) => v.parse::<usize>().map_err(|e| {
326                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
327            })?,
328            None => 10 * 1024 * 1024, // Default: 10MB
329        };
330
331        let auth = parse_auth_from_params(&parts.params)?;
332
333        let user_agent = parts.params.get("userAgent").cloned();
334
335        if parts.params.contains_key("cookieHandling") {
336            return Err(CamelError::InvalidUri(
337                "cookieHandling is not supported".into(),
338            ));
339        }
340
341        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
342            Some(v) => parse_bool_param_http(v).map_err(|e| {
343                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
344            })?,
345            None => false,
346        };
347
348        let connection_close = match parts.params.get("connectionClose") {
349            Some(v) => parse_bool_param_http(v).map_err(|e| {
350                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
351            })?,
352            None => false,
353        };
354
355        let skip_request_headers = parts
356            .params
357            .get("skipRequestHeaders")
358            .map(|v| {
359                v.split(',')
360                    .map(str::trim)
361                    .filter(|s| !s.is_empty())
362                    .map(|s| s.to_ascii_lowercase())
363                    .collect::<Vec<_>>()
364            })
365            .unwrap_or_default();
366
367        let skip_response_headers = parts
368            .params
369            .get("skipResponseHeaders")
370            .map(|v| {
371                v.split(',')
372                    .map(str::trim)
373                    .filter(|s| !s.is_empty())
374                    .map(|s| s.to_ascii_lowercase())
375                    .collect::<Vec<_>>()
376            })
377            .unwrap_or_default();
378
379        let follow_redirects = match parts.params.get("followRedirects") {
380            Some(v) => parse_bool_param_http(v).map_err(|e| {
381                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
382            })?,
383            None => false,
384        };
385
386        let max_redirects = match parts.params.get("maxRedirects") {
387            Some(v) => v.parse::<usize>().map_err(|e| {
388                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
389            })?,
390            None => 10,
391        };
392
393        // CamelHttpUri host fence: parsed eagerly so a malformed or empty
394        // allowlist fails endpoint creation (fail-closed), not resolution.
395        let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
396            Some(v) => Some(parse_allowed_uri_hosts(v)?),
397            None => None,
398        };
399
400        // Authored pairs ride raw_query verbatim (the sole carrier);
401        // query_params is programmatic-only — never auto-populated from
402        // URI leftovers. Consumed option keys are filtered at
403        // serialization time by `is_consumed_option`.
404        let raw_query = parts.raw_query.clone();
405
406        Ok(Self {
407            base_url,
408            http_method,
409            throw_exception_on_failure,
410            ok_status_code_range,
411            response_timeout,
412            query_params: Vec::new(),
413            raw_query,
414            allow_internal,
415            allow_cleartext,
416            blocked_hosts,
417            max_body_size,
418            read_timeout_ms,
419            max_response_bytes,
420            auth,
421            token_provider: None,
422            user_agent,
423            bridge_endpoint,
424            connection_close,
425            skip_request_headers,
426            skip_response_headers,
427            follow_redirects,
428            max_redirects,
429            allowed_uri_hosts,
430        })
431    }
432}
433
434/// Private container for macro-derived `uri_options()` and `metadata()`.
435///
436/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
437/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
438/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
439/// derivation targets this inner type whose fields are all URI-param-compatible.
440#[derive(Debug, Clone, UriConfig)]
441#[allow(dead_code)]
442#[uri_scheme = "http"]
443#[uri_config(
444    skip_impl,
445    metadata(
446        scheme = "http",
447        description = "HTTP client and server component",
448        producer,
449        consumer,
450        streaming
451    ),
452    crate = "camel_component_api"
453)]
454struct HttpEndpointUriConfig {
455    #[allow(dead_code)]
456    _base_url: String,
457
458    #[uri_param(
459        name = "httpMethod",
460        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
461    )]
462    http_method: Option<String>,
463
464    #[uri_param(
465        name = "throwExceptionOnFailure",
466        default = "true",
467        desc = "Throw on non-2xx status"
468    )]
469    throw_exception_on_failure: bool,
470
471    #[uri_param(
472        name = "okStatusCodeRange",
473        default = "200-299",
474        desc = "Success status code range"
475    )]
476    ok_status_code_range: String,
477
478    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
479    response_timeout: Option<u64>,
480
481    #[uri_param(
482        name = "connectTimeout",
483        desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
484    )]
485    connect_timeout: Option<u64>,
486
487    #[uri_param(
488        name = "allowInternal",
489        default = "false",
490        desc = "Allow private/internal network destinations (SSRF)"
491    )]
492    allow_internal: bool,
493
494    #[uri_param(
495        name = "allowCleartext",
496        default = "false",
497        desc = "Allow cleartext http:// to public targets (transport consent; ADR-0081)"
498    )]
499    allow_cleartext: bool,
500
501    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
502    blocked_hosts: Option<String>,
503
504    #[uri_param(
505        name = "maxBodySize",
506        default = "10485760",
507        desc = "Max request/response body bytes"
508    )]
509    max_body_size: u64,
510
511    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
512    read_timeout: Option<u64>,
513
514    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
515    max_response_bytes: Option<u64>,
516
517    #[uri_param(
518        name = "authMethod",
519        kind = "enum:Basic,Bearer",
520        desc = "Authentication method"
521    )]
522    auth_method: Option<String>,
523
524    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
525    auth_username: Option<String>,
526
527    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
528    auth_password: Option<String>,
529
530    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
531    auth_bearer_token: Option<String>,
532
533    #[uri_param(name = "userAgent", desc = "User-Agent header")]
534    user_agent: Option<String>,
535
536    #[uri_param(
537        name = "bridgeEndpoint",
538        default = "false",
539        desc = "Bridge endpoint mode"
540    )]
541    bridge_endpoint: bool,
542
543    #[uri_param(
544        name = "connectionClose",
545        default = "false",
546        desc = "Send Connection: close"
547    )]
548    connection_close: bool,
549
550    #[uri_param(
551        name = "skipRequestHeaders",
552        desc = "Comma-separated request headers to skip"
553    )]
554    skip_request_headers: Option<String>,
555
556    #[uri_param(
557        name = "skipResponseHeaders",
558        desc = "Comma-separated response headers to skip"
559    )]
560    skip_response_headers: Option<String>,
561
562    #[uri_param(
563        name = "followRedirects",
564        default = "false",
565        desc = "Follow HTTP redirects"
566    )]
567    follow_redirects: bool,
568
569    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
570    max_redirects: u64,
571
572    #[uri_param(
573        name = "allowedUriHosts",
574        desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
575    )]
576    allowed_uri_hosts: Option<String>,
577}
578
579impl HttpEndpointConfig {
580    /// Component metadata for the http/https scheme, derived from the
581    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
582    pub fn metadata() -> ComponentMetadata {
583        HttpEndpointUriConfig::metadata()
584    }
585
586    /// URI option definitions, derived from `#[uri_param]` fields.
587    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
588        HttpEndpointUriConfig::uri_options()
589    }
590}
591
592fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
593    let Some(method) = params.get("authMethod") else {
594        return Ok(HttpAuth::None);
595    };
596
597    if method.eq_ignore_ascii_case("none") {
598        return Ok(HttpAuth::None);
599    }
600
601    if method.eq_ignore_ascii_case("basic") {
602        let username = params.get("authUsername").cloned().ok_or_else(|| {
603            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
604        })?;
605        let password = params.get("authPassword").cloned().ok_or_else(|| {
606            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
607        })?;
608        return Ok(HttpAuth::Basic { username, password });
609    }
610
611    if method.eq_ignore_ascii_case("bearer") {
612        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
613            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
614        })?;
615        return Ok(HttpAuth::Bearer { token });
616    }
617
618    Err(CamelError::InvalidUri(format!(
619        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
620    )))
621}
622
623fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
624    match value.to_ascii_lowercase().as_str() {
625        "true" | "1" | "yes" => Ok(true),
626        "false" | "0" | "no" => Ok(false),
627        _ => Err(CamelError::InvalidUri(format!(
628            "invalid boolean value: '{value}'"
629        ))),
630    }
631}
632
633impl HttpEndpointConfig {
634    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
635        let parts = parse_uri(uri)?;
636        let mut endpoint = Self::from_components(parts.clone())?;
637        if endpoint.response_timeout.is_none() {
638            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
639        }
640        if !parts.params.contains_key("allowInternal") {
641            endpoint.allow_internal = config.allow_internal;
642        }
643        if !parts.params.contains_key("blockedHosts") {
644            endpoint.blocked_hosts = config.blocked_hosts.clone();
645        }
646        if !parts.params.contains_key("maxBodySize") {
647            endpoint.max_body_size = config.max_body_size;
648        }
649        if !parts.params.contains_key("readTimeout") {
650            endpoint.read_timeout_ms = config.read_timeout_ms;
651        }
652        if !parts.params.contains_key("maxResponseBytes") {
653            endpoint.max_response_bytes = config.max_response_bytes;
654        }
655        if !parts.params.contains_key("okStatusCodeRange")
656            && let Some(range) = &config.ok_status_code_range
657        {
658            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
659        }
660        if !parts.params.contains_key("followRedirects") {
661            endpoint.follow_redirects = config.follow_redirects;
662        }
663        if !parts.params.contains_key("maxRedirects") {
664            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
665        }
666
667        Ok(endpoint)
668    }
669}
670
671// ---------------------------------------------------------------------------
672// HttpServerConfig
673// ---------------------------------------------------------------------------
674
675/// Configuration for an HTTP server (consumer) endpoint.
676#[derive(Debug, Clone)]
677pub struct HttpServerConfig {
678    /// URI scheme ("http" or "https") parsed from the endpoint URI.
679    pub scheme: String,
680    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
681    pub host: String,
682    /// TCP port to listen on.
683    pub port: u16,
684    /// URL path this consumer handles, e.g. "/orders".
685    pub path: String,
686    /// Maximum request body size in bytes.
687    pub max_request_body: usize,
688    /// Maximum response body size for materializing streams in bytes.
689    pub max_response_body: usize,
690    /// Maximum number of in-flight requests handled concurrently by this server.
691    pub max_inflight_requests: usize,
692    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
693    /// the consumer registers as a method-aware REST endpoint and the
694    /// path is treated as a template (e.g. `/users/{id}` is matched
695    /// against any `/users/<value>`). When `None`, the consumer
696    /// registers in the legacy path-only `api_routes` registry.
697    /// Extracted from the `httpMethod=` URI param at config build time.
698    pub method: Option<String>,
699    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
700    /// `None` for plain HTTP servers.
701    pub tls_config: Option<crate::config::ServerTlsConfig>,
702}
703
704impl UriConfig for HttpServerConfig {
705    /// Returns "http" as the primary scheme (also accepts "https")
706    fn scheme() -> &'static str {
707        "http"
708    }
709
710    fn from_uri(uri: &str) -> Result<Self, CamelError> {
711        let parts = parse_uri(uri)?;
712        Self::from_components(parts)
713    }
714
715    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
716        // Validate scheme - accept both http and https
717        if parts.scheme != "http" && parts.scheme != "https" {
718            return Err(CamelError::InvalidUri(format!(
719                "expected scheme 'http' or 'https', got '{}'",
720                parts.scheme
721            )));
722        }
723
724        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
725        // Strip leading "//"
726        let authority_and_path = parts.path.trim_start_matches('/');
727
728        // Split on the first "/" to separate "host:port" from "/path"
729        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
730            (&authority_and_path[..idx], &authority_and_path[idx..])
731        } else {
732            (authority_and_path, "/")
733        };
734
735        let path = if path_suffix.is_empty() {
736            "/"
737        } else {
738            path_suffix
739        }
740        .to_string();
741
742        // Parse host:port from authority
743        let (host, port) = if let Some(colon) = authority.rfind(':') {
744            let port_str = &authority[colon + 1..];
745            match port_str.parse::<u16>() {
746                Ok(p) => (authority[..colon].to_string(), p),
747                Err(_) => {
748                    return Err(CamelError::InvalidUri(format!(
749                        "invalid port '{}' in authority",
750                        port_str
751                    )));
752                }
753            }
754        } else {
755            // Default port based on scheme: 443 for https, 80 for http
756            let default_port = if parts.scheme == "https" { 443 } else { 80 };
757            (authority.to_string(), default_port)
758        };
759
760        let max_request_body = parts
761            .params
762            .get("maxRequestBody")
763            .and_then(|v| v.parse::<usize>().ok())
764            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
765
766        let max_response_body = parts
767            .params
768            .get("maxResponseBody")
769            .and_then(|v| v.parse::<usize>().ok())
770            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
771
772        let max_inflight_requests = parts
773            .params
774            .get("maxInflightRequests")
775            .and_then(|v| v.parse::<usize>().ok())
776            .unwrap_or(1024);
777
778        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
779        // uppercase method the dispatcher compares against (axum's
780        // `req.method().to_string()` yields "GET"). Without this, a
781        // lower-case `httpMethod` would never match and silently 404.
782        // Review I5.
783        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
784
785        Ok(Self {
786            scheme: parts.scheme,
787            host,
788            port,
789            path,
790            max_request_body,
791            max_response_body,
792            max_inflight_requests,
793            method,
794            tls_config: {
795                let cert = parts.params.get("tlsCert").cloned();
796                let key = parts.params.get("tlsKey").cloned();
797                match (cert, key) {
798                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
799                        cert_path: c,
800                        key_path: k,
801                    }),
802                    (None, None) => None,
803                    _ => None, // partial — enforced in create_consumer, not here
804                }
805            },
806        })
807    }
808}
809
810impl HttpServerConfig {
811    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
812        let parts = parse_uri(uri)?;
813        let mut server = Self::from_components(parts.clone())?;
814        if !parts.params.contains_key("maxRequestBody") {
815            server.max_request_body = config.max_request_body;
816        }
817        if !parts.params.contains_key("maxResponseBody") {
818            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
819            server.max_response_body = config.max_body_size;
820        }
821        Ok(server)
822    }
823}
824
825// ---------------------------------------------------------------------------
826// RequestEnvelope / HttpReply
827// ---------------------------------------------------------------------------
828
829/// Body of the HTTP response: already-materialized bytes or a lazy stream.
830///
831/// **Internal plumbing** — subject to change without notice.
832pub enum HttpReplyBody {
833    Bytes(bytes::Bytes),
834    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
835}
836
837/// An inbound HTTP request sent from the Axum dispatch handler to an
838/// `HttpConsumer` receive loop.
839///
840/// **Internal plumbing** — subject to change without notice.
841pub struct RequestEnvelope {
842    pub method: String,
843    pub path: String,
844    pub query: String,
845    pub headers: http::HeaderMap,
846    pub body: StreamBody,
847    /// Path parameters extracted from a REST template match, e.g.
848    /// `id=42` for a request to `/users/42` matched against
849    /// `/users/{id}`. Empty for non-REST requests or for literal
850    /// template matches. The consumer turns these into
851    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
852    pub path_params: std::collections::HashMap<String, String>,
853    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
854}
855
856/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
857///
858/// **Internal plumbing** — subject to change without notice.
859pub struct HttpReply {
860    pub status: u16,
861    pub headers: Vec<(String, String)>,
862    pub body: HttpReplyBody,
863}
864
865// ---------------------------------------------------------------------------
866// HttpRouteRegistry / ServerRegistry
867// ---------------------------------------------------------------------------
868
869type ServerKey = (String, u16);
870
871/// Handle to a running Axum server on one interface/port.
872struct ServerHandle {
873    registry: HttpRouteRegistry,
874    /// Actual local address of the served listening socket (differs from the
875    /// configured `host:port` when spawning from a staged/pre-bound listener).
876    bound_addr: std::net::SocketAddr,
877    max_request_body: usize,
878    max_response_body: usize,
879    max_inflight_requests: usize,
880    is_tls: bool,
881    tls_cert_path: Option<String>,
882    tls_key_path: Option<String>,
883    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
884    /// dead-server eviction signal in `get_or_spawn`.
885    monitor_task: tokio::task::JoinHandle<()>,
886    /// Abort handle for the Axum server task itself. The JoinHandle is
887    /// consumed by `monitor_axum_task`; this survives on the handle so
888    /// crashed-server tests (and future ops tooling) can deterministically
889    /// kill the shared transport to exercise the death path.
890    /// Test-only today — no production reader yet (rc-szmob).
891    #[allow(dead_code)]
892    server_abort: tokio::task::AbortHandle,
893    // Retained so the reload handler (Task 7) can call reload_from_config()
894    // to hot-swap certs without restarting the server.
895    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
896    tls_source: Option<ServerTlsSource>,
897}
898
899/// Internal registry state: live server entries plus pre-bound listeners
900/// staged for consumption by the next spawn on the same key.
901#[derive(Default)]
902struct RegistryState {
903    entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
904    staged: HashMap<ServerKey, tokio::net::TcpListener>,
905}
906
907/// Process-global registry mapping (host, port) → running Axum server handle.
908pub struct ServerRegistry {
909    inner: Mutex<RegistryState>,
910}
911
912impl ServerRegistry {
913    /// Returns the global singleton.
914    pub fn global() -> &'static Self {
915        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
916        INSTANCE.get_or_init(|| ServerRegistry {
917            inner: Mutex::new(RegistryState::default()),
918        })
919    }
920
921    /// Returns route registry for `port`, spawning new Axum server if
922    /// none is running on that port yet.
923    #[allow(clippy::too_many_arguments)]
924    pub async fn get_or_spawn(
925        &'static self,
926        host: &str,
927        port: u16,
928        max_request_body: usize,
929        max_response_body: usize,
930        max_inflight_requests: usize,
931        runtime: Arc<dyn RuntimeObservability>,
932        route_id: String,
933        tls_config: Option<crate::config::ServerTlsConfig>,
934    ) -> Result<HttpRouteRegistry, CamelError> {
935        self.get_or_spawn_internal(
936            host,
937            port,
938            max_request_body,
939            max_response_body,
940            max_inflight_requests,
941            runtime,
942            route_id,
943            tls_config,
944            None,
945        )
946        .await
947    }
948
949    /// Like [`ServerRegistry::get_or_spawn`], but serves `listener` instead
950    /// of binding `host:port`. The registry key is derived from the listener's
951    /// actual local address, so callers must query that port afterwards. If an
952    /// entry for the key already holds a live server, the same compatibility
953    /// checks as `get_or_spawn` apply and the entry is reused; the passed
954    /// listener is simply dropped.
955    #[allow(clippy::too_many_arguments)]
956    pub async fn get_or_spawn_with_listener(
957        &'static self,
958        listener: tokio::net::TcpListener,
959        max_request_body: usize,
960        max_response_body: usize,
961        max_inflight_requests: usize,
962        runtime: Arc<dyn RuntimeObservability>,
963        route_id: String,
964        tls_config: Option<crate::config::ServerTlsConfig>,
965    ) -> Result<HttpRouteRegistry, CamelError> {
966        let addr = listener
967            .local_addr()
968            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
969        self.get_or_spawn_internal(
970            &addr.ip().to_string(),
971            addr.port(),
972            max_request_body,
973            max_response_body,
974            max_inflight_requests,
975            runtime,
976            route_id,
977            tls_config,
978            Some(listener),
979        )
980        .await
981    }
982
983    /// Stage a pre-bound listener so the next `get_or_spawn` for its
984    /// `(ip, port)` key serves this socket instead of binding a new one.
985    ///
986    /// The staged listener is consumed by exactly one spawn: the exact-key
987    /// `get_or_spawn` takes it under the registry lock, eliminating the bind
988    /// window between a port probe and server startup (itest-bound-ports).
989    pub async fn stage_listener(
990        &'static self,
991        listener: tokio::net::TcpListener,
992    ) -> Result<(), CamelError> {
993        let addr = listener
994            .local_addr()
995            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
996        let host = addr.ip().to_string();
997        use std::collections::hash_map::Entry;
998        let mut guard = self.inner.lock().map_err(|_| {
999            CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1000        })?;
1001        match guard.staged.entry((host.clone(), addr.port())) {
1002            Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
1003                "listener already staged for {host}:{}",
1004                addr.port()
1005            ))),
1006            Entry::Vacant(slot) => {
1007                slot.insert(listener);
1008                Ok(())
1009            }
1010        }
1011    }
1012
1013    /// Returns the bound address of the live server entry for `(host, port)`,
1014    /// if one is initialized.
1015    pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
1016        let guard = self.inner.lock().ok()?;
1017        guard
1018            .entries
1019            .get(&(host.to_string(), port))
1020            .and_then(|cell| cell.get())
1021            .map(|handle| handle.bound_addr)
1022    }
1023
1024    #[allow(clippy::too_many_arguments)]
1025    async fn get_or_spawn_internal(
1026        &'static self,
1027        host: &str,
1028        port: u16,
1029        max_request_body: usize,
1030        max_response_body: usize,
1031        max_inflight_requests: usize,
1032        runtime: Arc<dyn RuntimeObservability>,
1033        route_id: String,
1034        tls_config: Option<crate::config::ServerTlsConfig>,
1035        provided: Option<tokio::net::TcpListener>,
1036    ) -> Result<HttpRouteRegistry, CamelError> {
1037        let host_owned = host.to_string();
1038        let key = (host.to_string(), port);
1039
1040        let cell = {
1041            let mut guard = self.inner.lock().map_err(|_| {
1042                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1043            })?;
1044            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
1045            // The monitor task awaits the server task, so monitor_task.is_finished()
1046            // is a reliable proxy for the server being gone (either crashed or aborted).
1047            if let Some(existing) = guard.entries.get(&key)
1048                && let Some(handle) = existing.get()
1049                && handle.monitor_task.is_finished()
1050            {
1051                // Deregister TLS reload handler so a respawned HTTPS server
1052                // doesn't reload stale cert config from the crashed handler.
1053                if handle.is_tls {
1054                    let scheme = if handle.is_tls { "https" } else { "http" };
1055                    camel_component_api::tls_source::TlsReloadRegistry::global()
1056                        .unregister(scheme, host, port);
1057                }
1058                guard.entries.remove(&key);
1059            }
1060            guard
1061                .entries
1062                .entry(key)
1063                .or_insert_with(|| Arc::new(OnceCell::new()))
1064                .clone()
1065        };
1066
1067        if let Some(existing) = cell.get()
1068            && existing.max_request_body != max_request_body
1069        {
1070            return Err(CamelError::EndpointCreationFailed(format!(
1071                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1072                existing.max_request_body, max_request_body
1073            )));
1074        }
1075
1076        if let Some(existing) = cell.get()
1077            && existing.max_response_body != max_response_body
1078        {
1079            return Err(CamelError::EndpointCreationFailed(format!(
1080                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1081                existing.max_response_body, max_response_body
1082            )));
1083        }
1084
1085        if let Some(existing) = cell.get()
1086            && existing.max_inflight_requests != max_inflight_requests
1087        {
1088            return Err(CamelError::EndpointCreationFailed(format!(
1089                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1090                existing.max_inflight_requests, max_inflight_requests
1091            )));
1092        }
1093
1094        // TLS mode mismatch: plain vs TLS
1095        if let Some(existing) = cell.get()
1096            && existing.is_tls != tls_config.is_some()
1097        {
1098            return Err(CamelError::EndpointCreationFailed(format!(
1099                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1100                existing.is_tls,
1101                tls_config.is_some()
1102            )));
1103        }
1104
1105        // TLS cert/key mismatch: different cert on same TLS port
1106        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1107            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1108                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1109        {
1110            return Err(CamelError::EndpointCreationFailed(format!(
1111                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1112            )));
1113        }
1114
1115        let handle = cell
1116            .get_or_try_init(|| {
1117                let rt = Arc::clone(&runtime);
1118                let rid = route_id.clone();
1119                let key = (host_owned.clone(), port);
1120                async move {
1121                    // Resolve the listener source inside the init body so
1122                    // exactly one caller — the init winner — consumes a
1123                    // staged listener. Resolving it before the cell init let
1124                    // a racing caller strand the staged socket in the
1125                    // loser's hands: the winner then bound the same port and
1126                    // failed with EADDRINUSE. The sync registry lock here is
1127                    // never held across an await. Occupied cells never run
1128                    // this body, so they never touch the staged map.
1129                    let source = match provided {
1130                        Some(listener) => ListenerSource::Staged(listener),
1131                        None => {
1132                            let mut guard = self.inner.lock().map_err(|_| {
1133                                CamelError::EndpointCreationFailed(
1134                                    "ServerRegistry lock poisoned".into(),
1135                                )
1136                            })?;
1137                            match guard.staged.remove(&key) {
1138                                Some(listener) => ListenerSource::Staged(listener),
1139                                // Conflict check before any entry is
1140                                // initialized so the error leaves the staged
1141                                // slot untouched.
1142                                None => {
1143                                    if let Some((staged_host, _)) = guard
1144                                        .staged
1145                                        .keys()
1146                                        .find(|(_, staged_port)| *staged_port == port)
1147                                    {
1148                                        let staged_host = staged_host.clone();
1149                                        return Err(CamelError::EndpointCreationFailed(
1150                                            format!(
1151                                                "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1152                                            ),
1153                                        ));
1154                                    }
1155                                    ListenerSource::Bind
1156                                }
1157                            }
1158                        }
1159                    };
1160                    spawn_entry(
1161                        key,
1162                        source,
1163                        max_request_body,
1164                        max_response_body,
1165                        max_inflight_requests,
1166                        rt,
1167                        rid,
1168                        tls_config,
1169                    )
1170                    .await
1171                    .and_then(|handle| {
1172                        // spawn_entry returns a freshly created Arc (refcount
1173                        // 1), so unwrapping it back into the owned handle for
1174                        // the cell always succeeds here.
1175                        Arc::try_unwrap(handle).map_err(|_| {
1176                            CamelError::EndpointCreationFailed(
1177                                "spawned server handle has dangling clones".into(),
1178                            )
1179                        })
1180                    })
1181                }
1182            })
1183            .await?;
1184
1185        Ok(handle.registry.clone())
1186    }
1187
1188    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
1189    /// the server stays in the registry for potential restart. Path
1190    /// deregistration happens separately in the consumer's cleanup.
1191    pub async fn unregister(&self, host: &str, port: u16) {
1192        debug!(
1193            host = host,
1194            port = port,
1195            "consumer unregistered from HTTP server"
1196        );
1197    }
1198
1199    /// Reset the global registry — **test-only**.
1200    ///
1201    /// Clears all registered server handles so that tests can start from a clean
1202    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1203    /// process-global singleton in production and resetting it would break
1204    /// running servers.
1205    #[cfg(test)]
1206    pub fn reset() {
1207        let instance = Self::global();
1208        let mut guard = instance
1209            .inner
1210            .lock()
1211            .expect("ServerRegistry lock poisoned during test reset");
1212        guard.entries.clear();
1213        guard.staged.clear();
1214    }
1215}
1216
1217/// Where a spawned server's listening socket comes from: a fresh bind on
1218/// `key`, or a listener pre-bound (staged or passed) by the caller.
1219enum ListenerSource {
1220    Bind,
1221    Staged(tokio::net::TcpListener),
1222}
1223
1224/// Create the server handle for a vacant registry entry: serve `key` via a
1225/// freshly bound or caller-provided listener. This is the OnceCell init body
1226/// of `get_or_spawn`, extracted so the legacy and staged entry points share
1227/// one spawn path.
1228#[allow(clippy::too_many_arguments)]
1229async fn spawn_entry(
1230    key: ServerKey,
1231    source: ListenerSource,
1232    max_request_body: usize,
1233    max_response_body: usize,
1234    max_inflight_requests: usize,
1235    runtime: Arc<dyn RuntimeObservability>,
1236    route_id: String,
1237    tls_config: Option<crate::config::ServerTlsConfig>,
1238) -> Result<Arc<ServerHandle>, CamelError> {
1239    let rt = Arc::clone(&runtime);
1240    let rid = route_id.clone();
1241    let (host_owned, port) = key;
1242    let listener = match source {
1243        ListenerSource::Bind => {
1244            let addr = format!("{host_owned}:{port}");
1245            tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1246                CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1247            })?
1248        }
1249        ListenerSource::Staged(listener) => listener,
1250    };
1251    let bound_addr = listener
1252        .local_addr()
1253        .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1254    let server_exited = tokio_util::sync::CancellationToken::new();
1255    let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1256    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1257    // Constructed once in the TLS branch so they can be retained
1258    // on ServerHandle for the reload handler (Task 7).
1259    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1260    let tls_source: Option<ServerTlsSource>;
1261    let server_task = if let Some(ref tls) = tls_config {
1262        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1263        let source = ServerTlsSource {
1264            cert_path: std::path::PathBuf::from(&tls.cert_path),
1265            key_path: std::path::PathBuf::from(&tls.key_path),
1266            client_ca_path: None,
1267        };
1268        // Build the RustlsConfig once — clone() is cheap (Arc
1269        // internally) and shares the ArcSwap the reload handler
1270        // will mutate via reload_from_config().
1271        let rustls_cfg =
1272            axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1273        tls_rustls_cfg = Some(rustls_cfg.clone());
1274        tls_source = Some(source);
1275        // Convert tokio listener to std for axum-server
1276        let std_listener = listener.into_std().map_err(|e| {
1277            CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1278        })?;
1279        tokio::spawn(run_axum_server_tls(
1280            std_listener,
1281            rustls_cfg,
1282            registry.clone(),
1283            max_request_body,
1284            max_response_body,
1285            Arc::clone(&inflight),
1286            Arc::clone(&rt),
1287            rid.clone(),
1288        ))
1289    } else {
1290        tls_rustls_cfg = None;
1291        tls_source = None;
1292        tokio::spawn(run_axum_server(
1293            listener,
1294            registry.clone(),
1295            max_request_body,
1296            max_response_body,
1297            Arc::clone(&inflight),
1298            Arc::clone(&rt),
1299            rid.clone(),
1300        ))
1301    };
1302    let addr_for_monitor = format!("{host_owned}:{port}");
1303    let server_abort = server_task.abort_handle();
1304    let monitor_task = tokio::spawn(monitor_axum_task(
1305        server_task,
1306        addr_for_monitor,
1307        Arc::clone(&rt),
1308        rid,
1309        server_exited,
1310    ));
1311    let handle = ServerHandle {
1312        registry,
1313        bound_addr,
1314        max_request_body,
1315        max_response_body,
1316        max_inflight_requests,
1317        is_tls: tls_config.is_some(),
1318        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1319        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1320        monitor_task,
1321        server_abort,
1322        tls_config: tls_rustls_cfg,
1323        tls_source,
1324    };
1325    // Register reload handler (exactly-once: inside OnceCell init closure).
1326    // Note: HTTP servers are process-lifetime (no release/eviction path),
1327    // so handlers are never unregistered. If eviction is added later,
1328    // add TlsReloadRegistry::global().unregister() there.
1329    if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1330    {
1331        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1332            tls_cfg.clone(),
1333            source.clone(),
1334            host_owned.clone(),
1335            port,
1336        ));
1337        camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1338    }
1339    Ok(Arc::new(handle))
1340}
1341
1342// ---------------------------------------------------------------------------
1343// Axum server
1344// ---------------------------------------------------------------------------
1345
1346use axum::{
1347    Router,
1348    body::Body as AxumBody,
1349    extract::{Request, State},
1350    http::{Response, StatusCode},
1351    response::IntoResponse,
1352};
1353
1354#[derive(Clone)]
1355pub(crate) struct AppState {
1356    registry: HttpRouteRegistry,
1357    max_request_body: usize,
1358    max_response_body: usize,
1359    inflight: Arc<tokio::sync::Semaphore>,
1360}
1361
1362/// Hard wall-clock limit for one inbound request on the consumer side
1363/// (audit 2026-08-31, F2-1). A slow-drip client otherwise holds an
1364/// `inflight` semaphore permit (and its connection) indefinitely, starving
1365/// the consumer into 503s. 30s matches the documented component default
1366/// timeouts. Applies to the whole dispatch; streaming bodies are additionally
1367/// protected by the byte cap in `dispatch_handler`.
1368const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1369
1370async fn run_axum_server(
1371    listener: tokio::net::TcpListener,
1372    registry: HttpRouteRegistry,
1373    max_request_body: usize,
1374    max_response_body: usize,
1375    inflight: Arc<tokio::sync::Semaphore>,
1376    runtime: Arc<dyn RuntimeObservability>,
1377    route_id: String,
1378) {
1379    let state = AppState {
1380        registry,
1381        max_request_body,
1382        max_response_body,
1383        inflight,
1384    };
1385    let app = Router::new()
1386        .fallback(dispatch_handler)
1387        .with_state(state)
1388        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1389            StatusCode::REQUEST_TIMEOUT,
1390            CONSUMER_REQUEST_TIMEOUT,
1391        ));
1392
1393    axum::serve(listener, app).await.unwrap_or_else(|e| {
1394        runtime
1395            .metrics()
1396            .increment_errors(&route_id, "e:http:accept");
1397        // log-policy: outside-contract
1398        tracing::error!(error = %e, "Axum server error");
1399    });
1400}
1401
1402#[allow(clippy::too_many_arguments)]
1403async fn run_axum_server_tls(
1404    listener: std::net::TcpListener,
1405    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1406    registry: HttpRouteRegistry,
1407    max_request_body: usize,
1408    max_response_body: usize,
1409    inflight: Arc<tokio::sync::Semaphore>,
1410    runtime: Arc<dyn RuntimeObservability>,
1411    route_id: String,
1412) {
1413    let state = AppState {
1414        registry,
1415        max_request_body,
1416        max_response_body,
1417        inflight,
1418    };
1419    let app = Router::new()
1420        .fallback(dispatch_handler)
1421        .with_state(state)
1422        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1423            StatusCode::REQUEST_TIMEOUT,
1424            CONSUMER_REQUEST_TIMEOUT,
1425        ));
1426
1427    // RustlsConfig is now constructed once in get_or_spawn and retained on
1428    // ServerHandle so the reload handler can call reload_from_config() on it.
1429
1430    // axum-server 0.8: from_tcp_rustls is fallible (TLS acceptor setup).
1431    let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1432        Ok(server) => server,
1433        Err(e) => {
1434            runtime
1435                .metrics()
1436                .increment_errors(&route_id, "e:http:accept-tls");
1437            // log-policy: outside-contract
1438            tracing::error!(error = %e, "Axum TLS server setup error");
1439            return;
1440        }
1441    };
1442
1443    server
1444        .serve(app.into_make_service())
1445        .await
1446        .unwrap_or_else(|e| {
1447            runtime
1448                .metrics()
1449                .increment_errors(&route_id, "e:http:accept-tls");
1450            // log-policy: outside-contract
1451            tracing::error!(error = %e, "Axum TLS server error");
1452        });
1453}
1454
1455/// Monitors the shared Axum server task of one (host, port).
1456///
1457/// On unexpected exit (panic or abort) it records the structured error
1458/// event and cancels the server's `server_exited` token. Every
1459/// `HttpConsumer` hosted on that server observes the cancellation in its
1460/// `start()` loop and returns `Err`, which camel-core's consumer watcher
1461/// turns into a per-route `CrashNotification` → `FailRoute` → supervision
1462/// backoff restart (ADR-0007). A clean exit (`Ok(())` — process shutdown)
1463/// cancels nothing: route stops own their termination.
1464async fn monitor_axum_task(
1465    handle: tokio::task::JoinHandle<()>,
1466    addr: String,
1467    runtime: Arc<dyn RuntimeObservability>,
1468    route_id: String,
1469    server_exited: tokio_util::sync::CancellationToken,
1470) {
1471    match handle.await {
1472        Ok(()) => {
1473            // Clean exit (process shutdown or normal stop)
1474        }
1475        Err(join_err) => {
1476            runtime
1477                .metrics()
1478                .increment_errors(&route_id, "e:http:server-task-exited");
1479            // log-policy: outside-contract
1480            tracing::error!(
1481                addr = %addr,
1482                error = %join_err,
1483                "Axum server task exited unexpectedly — all routes on this port are now dead"
1484            );
1485            // Fail every hosted route's consumer: each `start()` returns Err
1486            // and camel-core emits one CrashNotification per route (ADR-0007
1487            // parity with per-route transport death).
1488            server_exited.cancel();
1489        }
1490    }
1491}
1492
1493/// Load a rustls ServerConfig from PEM cert/key files.
1494/// Adapted from camel-ws lib.rs load_tls_config.
1495fn load_tls_config(
1496    cert_path: &str,
1497    key_path: &str,
1498) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1499    use std::fs::File;
1500    use std::io::BufReader;
1501
1502    let cert_file = File::open(cert_path)
1503        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1504    let key_file = File::open(key_path)
1505        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1506
1507    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1508        .collect::<Result<Vec<_>, _>>()
1509        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1510
1511    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1512        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1513        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1514
1515    tokio_rustls::rustls::ServerConfig::builder()
1516        .with_no_client_auth()
1517        .with_single_cert(certs, key)
1518        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1519}
1520
1521async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1522    let path = req.uri().path().to_owned();
1523    let method = req.method().to_string();
1524
1525    // Dispatch precedence (spec §7.2 / ADR-0009):
1526    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1527    //   2. Templated API path match (REST, method-aware, by specificity)
1528    //   3. Static mount longest-prefix
1529    //   4. SPA fallback
1530    //
1531    // Legacy exact runs first: it is a cheap HashMap get, and the two
1532    // registries are mutually exclusive per route — a legacy route carries
1533    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1534    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1535    // exact hit can never shadow a REST route that should have matched,
1536    // and running exact-first honours the documented precedence (the prior
1537    // REST-first order let a templated `GET /api/{resource}` steal a
1538    // request meant for an exact `GET /api/users`). Intra-REST method
1539    // disambiguation is handled inside `match_endpoint`, not by this
1540    // ordering. Review C2.
1541    let api_sender = {
1542        let inner = state.registry.inner.read().await;
1543        inner.api_routes.get(&path).cloned()
1544    }; // lock released BEFORE any IO
1545
1546    let (rest_sender, path_params) = if api_sender.is_some() {
1547        // Exact legacy match won — skip the templated scan entirely.
1548        (None, Default::default())
1549    } else {
1550        let inner = state.registry.inner.read().await;
1551        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1552            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1553            rest_match::MatchOutcome::Ambiguous => {
1554                // Ambiguous registration should have been rejected at
1555                // lowering time (rest.rs). Reaching here means two
1556                // equal-specificity templates matched one request —
1557                // surface a loud error rather than a silent 404. Review C3.
1558                // log-policy: handler-owned
1559                tracing::warn!(
1560                    method = %method,
1561                    path = %path,
1562                    "ambiguous REST template match — returning 500"
1563                );
1564                return Response::builder()
1565                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1566                    .body(AxumBody::from("Internal Server Error"))
1567                    .expect("infallible"); // allow-unwrap
1568            }
1569            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1570        }
1571    }; // lock released BEFORE any IO
1572
1573    let sender = api_sender.or(rest_sender);
1574
1575    if let Some(sender) = sender {
1576        let query = req.uri().query().unwrap_or("").to_string();
1577        let headers = req.headers().clone();
1578
1579        // Check Content-Length against limit BEFORE opening the stream
1580        let content_length: Option<u64> = headers
1581            .get(http::header::CONTENT_LENGTH)
1582            .and_then(|v| v.to_str().ok())
1583            .and_then(|s| s.parse().ok());
1584
1585        if let Some(len) = content_length
1586            && len > state.max_request_body as u64
1587        {
1588            return Response::builder()
1589                .status(StatusCode::PAYLOAD_TOO_LARGE)
1590                .body(AxumBody::from("Request body exceeds configured limit"))
1591                .expect("infallible"); // allow-unwrap
1592        }
1593
1594        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1595            Ok(permit) => permit,
1596            Err(_) => {
1597                return Response::builder()
1598                    .status(StatusCode::SERVICE_UNAVAILABLE)
1599                    .body(AxumBody::from("Service Unavailable"))
1600                    .expect("infallible"); // allow-unwrap
1601            }
1602        };
1603
1604        // Build StreamBody from Axum body WITHOUT materializing.
1605        // SECURITY (audit 2026-08-31, F2-1): the Content-Length pre-check above
1606        // cannot see chunked/no-length requests. Wrap the stream with a hard
1607        // byte cap so ANY downstream consumption fails closed once
1608        // max_request_body is exceeded — the cap travels with the body.
1609        let content_type = headers
1610            .get(http::header::CONTENT_TYPE)
1611            .and_then(|v| v.to_str().ok())
1612            .map(|s| s.to_string());
1613
1614        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1615        let max_body = state.max_request_body;
1616        let mut seen: u64 = 0;
1617        let capped_stream =
1618            data_stream
1619                .map_err(|e| CamelError::Io(e.to_string()))
1620                .map(move |chunk| match chunk {
1621                    Ok(bytes) => {
1622                        seen = seen.saturating_add(bytes.len() as u64);
1623                        if seen > max_body as u64 {
1624                            Err(CamelError::ProcessorError(format!(
1625                                "Request body exceeds configured limit of {max_body} bytes"
1626                            )))
1627                        } else {
1628                            Ok(bytes)
1629                        }
1630                    }
1631                    Err(e) => Err(e),
1632                });
1633        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1634
1635        let stream_body = StreamBody {
1636            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1637            metadata: StreamMetadata {
1638                size_hint: content_length,
1639                content_type,
1640                origin: None,
1641            },
1642        };
1643
1644        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1645        let envelope = RequestEnvelope {
1646            method,
1647            path,
1648            query,
1649            headers,
1650            body: stream_body,
1651            path_params,
1652            reply_tx,
1653        };
1654
1655        if sender.send(envelope).await.is_err() {
1656            return Response::builder()
1657                .status(StatusCode::SERVICE_UNAVAILABLE)
1658                .body(AxumBody::from("Consumer unavailable"))
1659                .expect("infallible"); // allow-unwrap
1660        }
1661
1662        match reply_rx.await {
1663            Ok(reply) => {
1664                let reply = match reply.body {
1665                    HttpReplyBody::Bytes(b)
1666                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1667                    {
1668                        HttpReply {
1669                            status: 500,
1670                            headers: vec![],
1671                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1672                                "Response body exceeds configured limit",
1673                            )),
1674                        }
1675                    }
1676                    _ => reply,
1677                };
1678
1679                let status =
1680                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1681                let mut builder = Response::builder().status(status);
1682                for (k, v) in &reply.headers {
1683                    builder = builder.header(k.as_str(), v.as_str());
1684                }
1685                match reply.body {
1686                    HttpReplyBody::Bytes(b) => {
1687                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1688                            Response::builder()
1689                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1690                                .body(AxumBody::from("Invalid response headers from consumer"))
1691                                .expect("infallible") // allow-unwrap
1692                        })
1693                    }
1694                    HttpReplyBody::Stream(stream) => builder
1695                        .body(AxumBody::from_stream(stream))
1696                        .unwrap_or_else(|_| {
1697                            Response::builder()
1698                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1699                                .body(AxumBody::from("Invalid response headers from consumer"))
1700                                .expect("infallible") // allow-unwrap
1701                        }),
1702                }
1703            }
1704            Err(_) => Response::builder()
1705                .status(StatusCode::INTERNAL_SERVER_ERROR)
1706                .body(AxumBody::from("Pipeline error"))
1707                .expect("infallible"), // allow-unwrap
1708        }
1709    } else {
1710        // No API route matched — try static mounts
1711        static_dispatch::dispatch_static(&state, req, &path).await
1712    }
1713}
1714
1715fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1716    len > max
1717}
1718
1719fn title_case_header(name: &str) -> String {
1720    name.split('-')
1721        .map(|part| {
1722            let mut chars = part.chars();
1723            match chars.next() {
1724                None => String::new(),
1725                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1726            }
1727        })
1728        .collect::<Vec<_>>()
1729        .join("-")
1730}
1731
1732// ---------------------------------------------------------------------------
1733// HttpConsumer
1734// ---------------------------------------------------------------------------
1735
1736/// Kernel authentication state captured from a route's [`SecurityContext`]
1737/// (`unify-transport-auth`, Task 2.9).
1738///
1739/// Same construction-order lifecycle as gRPC's `GrpcKernelAuth` (Task 2.1):
1740/// the compiled plan and the provider registry arrive via
1741/// `Consumer::set_security_context` before `start()` accepts requests. A
1742/// context lacking either piece keeps `kernel = None` — a plan without
1743/// providers can never mint a principal (fail-closed, never a silently
1744/// unauthenticated route: the controller's strict-mode dispatch check then
1745/// denies carrier-less Exchanges on non-Public plans).
1746pub(crate) struct HttpKernelAuth {
1747    pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1748    pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1749}
1750
1751impl HttpKernelAuth {
1752    /// Capture the kernel state from a route's security context.
1753    ///
1754    /// `None` unless both the compiled plan and the provider registry are
1755    /// present.
1756    pub(crate) fn from_security_context(
1757        ctx: &camel_component_api::SecurityContext,
1758    ) -> Option<Self> {
1759        Some(Self {
1760            plan: ctx.plan.clone()?,
1761            providers: ctx.providers.clone()?,
1762        })
1763    }
1764}
1765
1766/// Capacity for the per-route RequestEnvelope channel.
1767///
1768/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1769/// permit from before `send()` until its reply, so at most N envelopes can be
1770/// outstanding at any time. A buffer of N therefore can never fill before the
1771/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1772/// and the semaphore stays the single, URI-configurable backpressure point.
1773/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1774/// (rc-3y6j: 64 vs default 1024 permits).
1775///
1776/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1777/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1778/// start panic-free (the empty semaphore still 503s every request).
1779fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1780    max_inflight_requests.max(1)
1781}
1782
1783pub struct HttpConsumer {
1784    config: HttpServerConfig,
1785    /// Runtime observability handle for ADR-0012 metrics and health calls.
1786    runtime: Arc<dyn RuntimeObservability>,
1787    /// Kernel authentication state (plan + providers), set via
1788    /// `set_security_context` before `start()` (Task 2.9). `None` for routes
1789    /// without route-level security (Public under the per-bind gate).
1790    kernel: Option<Arc<HttpKernelAuth>>,
1791}
1792
1793impl HttpConsumer {
1794    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1795        Self {
1796            config,
1797            runtime,
1798            kernel: None,
1799        }
1800    }
1801}
1802
1803#[async_trait::async_trait]
1804impl Consumer for HttpConsumer {
1805    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1806        use camel_component_api::{Body, Exchange, Message};
1807
1808        let registry = ServerRegistry::global()
1809            .get_or_spawn(
1810                &self.config.host,
1811                self.config.port,
1812                self.config.max_request_body,
1813                self.config.max_response_body,
1814                self.config.max_inflight_requests,
1815                self.runtime.clone(),
1816                ctx.route_id().to_string(),
1817                self.config.tls_config.clone(),
1818            )
1819            .await?;
1820
1821        // Create channel for this path and register it. Capacity matches the
1822        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1823        // the channel can never become a second backpressure point.
1824        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1825            envelope_channel_capacity(self.config.max_inflight_requests),
1826        );
1827        // When the from-URI carries `httpMethod=...` (REST-lowered
1828        // route), register the consumer as a method-aware REST endpoint
1829        // so the dispatcher can route by (method, path template).
1830        // Otherwise fall back to the legacy path-only api_routes
1831        // registry. The two registries never overlap for the same
1832        // route: each consumer registers in exactly one of them.
1833        if let Some(method) = self.config.method.clone() {
1834            let segments = rest_match::parse_path_template(&self.config.path);
1835            registry
1836                .register_rest_endpoint(method, segments, env_tx)
1837                .await;
1838        } else {
1839            registry
1840                .register_api_route(self.config.path.clone(), env_tx)
1841                .await;
1842        }
1843
1844        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1845        // (inside get_or_spawn above), (2) the axum server task was spawned,
1846        // and (3) this route's path/REST endpoint was registered. At this
1847        // point the listener is genuinely accepting connections and any
1848        // request to this route will be dispatched (not 404'd). The runtime
1849        // uses this signal to publish RouteStarted and to release
1850        // ctx.start() so external benchmarks can emit a reliable
1851        // listener-bound marker.
1852        ctx.mark_ready();
1853
1854        // rc-nftni (drainclaim): capture the context-global counter once;
1855        // every envelope this raw-sender consumer constructs carries a
1856        // claim minted at the acceptance dequeue below.
1857        let in_flight = ctx.in_flight_counter();
1858
1859        let path = self.config.path.clone();
1860        let registry_for_cleanup = registry.clone();
1861        let server_exited = registry.server_exited.clone();
1862        let cancel_token = ctx.cancel_token();
1863        let kernel = self.kernel.clone();
1864        // Set when the loop exits because the shared server died. The
1865        // post-loop cleanup still runs, then `start()` returns Err so
1866        // camel-core's consumer watcher emits a CrashNotification for THIS
1867        // route and supervision backoff engages (ADR-0007).
1868        let mut server_died = false;
1869        loop {
1870            tokio::select! {
1871                _ = ctx.cancelled() => {
1872                    break;
1873                }
1874                _ = server_exited.cancelled() => {
1875                    // Shared transport death: this route's consumer cannot
1876                    // continue. Fail (do NOT hang in Running) — parity with
1877                    // per-route transport death, which also surfaces as a
1878                    // consumer-task error.
1879                    server_died = true;
1880                    break;
1881                }
1882                 envelope = env_rx.recv() => {
1883                    let Some(envelope) = envelope else { break; };
1884
1885                    // rc-nftni: mint at acceptance — the dequeue of the
1886                    // dispatcher's RequestEnvelope is where this consumer
1887                    // takes ownership of the wire request. The claim is held
1888                    // across the authn await, the route channel, and the
1889                    // pipeline; every early exit in the per-request task
1890                    // (cancel-503, auth denial) drops it, and a failed push
1891                    // rolls it back with the dropped envelope (RAII).
1892                    let claim =
1893                        in_flight.as_ref().map(camel_component_api::InFlightClaim::attach);
1894
1895                    // Build Exchange from HTTP request
1896                    let mut msg = Message::default();
1897
1898                    // Set standard Camel HTTP headers
1899                    msg.set_header("CamelHttpMethod",
1900                        serde_json::Value::String(envelope.method.clone()));
1901                    msg.set_header("CamelHttpPath",
1902                        serde_json::Value::String(envelope.path.clone()));
1903                    msg.set_header("CamelHttpQuery",
1904                        serde_json::Value::String(envelope.query.clone()));
1905
1906                    // Set path-parameter headers from REST template
1907                    // match. Expert guidance E2: the consumer is
1908                    // responsible for translating the dispatcher's
1909                    // matched params into `CamelHttpPath_<param>`
1910                    // headers on the Exchange, matching the convention
1911                    // used by Camel HTTP for templated routes.
1912                    for (param_name, param_value) in &envelope.path_params {
1913                        msg.set_header(
1914                            format!("CamelHttpPath_{param_name}"),
1915                            serde_json::Value::String(param_value.clone()),
1916                        );
1917                    }
1918
1919                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1920                    for (k, v) in &envelope.headers {
1921                        if let Ok(val_str) = v.to_str() {
1922                            msg.set_header(
1923                                title_case_header(k.as_str()),
1924                                serde_json::Value::String(val_str.to_string()),
1925                            );
1926                        }
1927                    }
1928
1929                    // Body: always arrives as Body::Stream (native streaming)
1930                    // Routes can call into_bytes() if they need to materialize
1931                    msg.body = Body::Stream(envelope.body);
1932
1933                    #[allow(unused_mut)]
1934                    let mut exchange = Exchange::new(msg);
1935
1936                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1937                    #[cfg(feature = "otel")]
1938                    {
1939                        let headers: HashMap<String, String> = envelope
1940                            .headers
1941                            .iter()
1942                            .filter_map(|(k, v)| {
1943                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1944                            })
1945                            .collect();
1946                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1947                    }
1948
1949                    let reply_tx = envelope.reply_tx;
1950                    let sender = ctx.sender().clone();
1951                    let path_clone = path.clone();
1952                    let cancel = cancel_token.clone();
1953                    // Task 2.9 boundary-auth inputs: the raw header map and
1954                    // the request URI (path + query) feed kernel credential
1955                    // extraction inside the per-request task.
1956                    let auth_headers = envelope.headers.clone();
1957                    let auth_uri: http::Uri = {
1958                        let full = if envelope.query.is_empty() {
1959                            envelope.path.clone()
1960                        } else {
1961                            format!("{}?{}", envelope.path, envelope.query)
1962                        };
1963                        // A malformed path cannot become a valid `Uri`; the
1964                        // empty default then carries no credentials, so
1965                        // extraction finds nothing and authn fails closed.
1966                        full.parse().unwrap_or_default()
1967                    };
1968                    let kernel = kernel.clone();
1969
1970                    // Spawn a task to handle this request concurrently
1971                    //
1972                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1973                    // true concurrent request processing. This change was introduced as part of the
1974                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1975                    //
1976                    // Rationale:
1977                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1978                    //    the consumer's main loop until the pipeline processing completes
1979                    // 2. This blocking would prevent multiple HTTP requests from being processed
1980                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1981                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1982                    //    defeating the purpose of pipeline-side concurrency
1983                    // 4. By spawning a task per request, we allow the consumer loop to continue
1984                    //    accepting new requests while existing ones are processed in the pipeline
1985                    //
1986                    // This approach effectively decouples request acceptance from pipeline processing,
1987                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1988                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1989                    tokio::spawn(async move {
1990                        // Check for cancellation before sending to pipeline.
1991                        // Returns 503 (Service Unavailable) instead of letting the request
1992                        // enter a shutting-down pipeline. This is a behavioral change from
1993                        // the pre-concurrency implementation where cancellation during
1994                        // processing would result in a 500 (Internal Server Error).
1995                        // 503 is more semantically correct: the server is temporarily
1996                        // unable to handle the request due to shutdown.
1997                        if cancel.is_cancelled() {
1998                            let _ = reply_tx.send(HttpReply {
1999                                status: 503,
2000                                headers: vec![],
2001                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2002                            });
2003                            return;
2004                        }
2005
2006                        // ADR-0061 Task 2.9: kernel authentication at the
2007                        // request boundary. A `Public` plan passes through
2008                        // with no extraction; any other mode extracts per
2009                        // the plan's sources, authenticates through the
2010                        // kernel, and installs the typed carrier BEFORE the
2011                        // pipeline runs. A denial renders in the HTTP idiom
2012                        // (401 via `pipeline_error_to_reply`) and the route
2013                        // body never sees the request.
2014                        if let Some(kernel) = kernel.as_ref()
2015                            && !matches!(
2016                                kernel.plan.access_mode,
2017                                camel_api::security_policy::AccessMode::Public
2018                            )
2019                        {
2020                            let principal = match camel_auth::extract_token_multi(
2021                                &auth_headers,
2022                                &auth_uri,
2023                                &kernel.plan.credential_sources,
2024                            ) {
2025                                Some(extracted) => {
2026                                    match camel_auth::kernel_authenticate(
2027                                        &kernel.plan,
2028                                        &kernel.providers,
2029                                        &extracted,
2030                                    )
2031                                    .await
2032                                    {
2033                                        Ok(principal) => principal,
2034                                        Err(e) => {
2035                                            // log-policy: handler-owned
2036                                            tracing::warn!(
2037                                                path = %path_clone,
2038                                                error = %e,
2039                                                "HTTP request authentication failed"
2040                                            );
2041                                            let _ = reply_tx.send(pipeline_error_to_reply(
2042                                                e,
2043                                                &path_clone,
2044                                            ));
2045                                            return;
2046                                        }
2047                                    }
2048                                }
2049                                None => {
2050                                    // log-policy: handler-owned
2051                                    tracing::warn!(
2052                                        path = %path_clone,
2053                                        "HTTP request rejected: no credential found in any source"
2054                                    );
2055                                    let _ = reply_tx.send(pipeline_error_to_reply(
2056                                        CamelError::Unauthenticated(
2057                                            "no credential found in any source".to_string(),
2058                                        ),
2059                                        &path_clone,
2060                                    ));
2061                                    return;
2062                                }
2063                            };
2064                            camel_auth::install_carrier(&mut exchange, &principal);
2065                        }
2066
2067                        // Send through pipeline and await result
2068                        let (tx, rx) = tokio::sync::oneshot::channel();
2069                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
2070                            exchange,
2071                            reply_tx: Some(tx),
2072                            // rc-nftni: the acceptance-minted claim rides the
2073                            // envelope; the pipeline drain sites take it and
2074                            // hold it across the pipeline (release at
2075                            // completion; rejection paths above already
2076                            // dropped it).
2077                            in_flight_claim: claim,
2078                        };
2079
2080                        let result = match sender.send(envelope).await {
2081                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2082                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2083                        }
2084                        .and_then(|r| r);
2085
2086                        let reply = match result {
2087                            Ok(out) => {
2088                                let status = out
2089                                    .input
2090                                    .header("CamelHttpResponseCode")
2091                                    .and_then(|v| {
2092                                        let raw = v.as_u64()
2093                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2094                                        let code = raw as u16;
2095                                        (100..1000).contains(&code).then_some(code)
2096                                    })
2097                                    .unwrap_or(200);
2098
2099                                let user_content_type = out
2100                                    .input
2101                                    .header("Content-Type")
2102                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2103
2104                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2105                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2106                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2107                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2108                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2109                                        v.to_string().into_bytes(),
2110                                    )), Some("application/json".to_string())),
2111                                    Body::Stream(s) => {
2112                                        let ct = s.metadata.content_type.clone();
2113                                        match s.stream.lock().await.take() {
2114                                            Some(stream) => (
2115                                                HttpReplyBody::Stream(stream),
2116                                                ct,
2117                                            ),
2118                                            None => {
2119                                                // log-policy: system-broken
2120                                                tracing::error!(
2121                                                    "Body::Stream already consumed before HTTP reply — returning 500"
2122                                                );
2123                                                let error_reply = HttpReply {
2124                                                    status: 500,
2125                                                    headers: vec![],
2126                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2127                                                };
2128                                                if reply_tx.send(error_reply).is_err() {
2129                                                    debug!("reply_tx dropped before error reply could be sent");
2130                                                }
2131                                                return;
2132                                            }
2133                                        }
2134                                    }
2135                                    // Empty and future variants produce an empty reply body.
2136                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2137                                };
2138
2139                                let resp_headers = select_response_headers(
2140                                    &out.input.headers,
2141                                    user_content_type,
2142                                    inferred_content_type,
2143                                );
2144
2145                                HttpReply {
2146                                    status,
2147                                    headers: resp_headers,
2148                                    body: reply_body,
2149                                }
2150                            }
2151                            Err(e) => {
2152                                pipeline_error_to_reply(e, &path_clone)
2153                            }
2154                        };
2155
2156                        // Reply to Axum handler (ignore error if client disconnected)
2157                        let _ = reply_tx.send(reply);
2158                    });
2159                }
2160            }
2161        }
2162
2163        // Deregister this consumer. Mirror the registration choice:
2164        // REST-registered consumers remove their (method, path) endpoint
2165        // WITHOUT touching sibling verbs on the same template (review C1);
2166        // legacy consumers clean up api_routes.
2167        if let Some(method) = &self.config.method {
2168            registry_for_cleanup
2169                .unregister_rest_endpoint(method, &path)
2170                .await;
2171        } else {
2172            registry_for_cleanup.unregister_api_route(&path).await;
2173        }
2174
2175        // Leave the shared-server entry: `unregister` is a no-op today (no
2176        // refcount exists — stale D-L10 wording removed, rc-szmob review).
2177        // Dead servers are evicted lazily by `get_or_spawn_internal`, which
2178        // checks `monitor_task.is_finished()` and rebinds on the next spawn
2179        // (e.g. a supervision restart after this consumer's Err).
2180        ServerRegistry::global()
2181            .unregister(&self.config.host, self.config.port)
2182            .await;
2183
2184        if server_died {
2185            // log-policy: system-broken
2186            tracing::error!(
2187                host = %camel_api::redact::redact_host(&self.config.host),
2188                port = self.config.port,
2189                path = %path,
2190                "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2191            );
2192            // The error value is logged upstream by supervision (ADR-0076):
2193            // the host must ride the canonical masker, message structure
2194            // unchanged (bd rc-8bxeo item 3).
2195            return Err(CamelError::RouteError(format!(
2196                "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2197                camel_api::redact::redact_host(&self.config.host),
2198                self.config.port
2199            )));
2200        }
2201
2202        Ok(())
2203    }
2204
2205    async fn stop(&mut self) -> Result<(), CamelError> {
2206        Ok(())
2207    }
2208
2209    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2210        camel_component_api::ConcurrencyModel::Concurrent { max: None }
2211    }
2212
2213    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
2214    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
2215    // Opting into Explicit startup makes ctx.start() await the bind+register
2216    // completion so listeners fail fast on bind errors (previously a silent
2217    // background log) and external markers can reliably detect listener-bound
2218    // state.
2219    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2220        camel_component_api::ConsumerStartupMode::Explicit
2221    }
2222
2223    // Task 2.9: capture the kernel state (compiled plan + provider registry)
2224    // wired by the route controller before start(). See `HttpKernelAuth`.
2225    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2226        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2227    }
2228}
2229
2230// ---------------------------------------------------------------------------
2231// HttpComponent / HttpsComponent
2232// ---------------------------------------------------------------------------
2233
2234pub struct HttpComponent {
2235    config: HttpConfig,
2236    pinned_cache: std::sync::Arc<PinnedClientCache>,
2237    client: reqwest::Client,
2238    /// Set at construction when `tls.strict` is on and the configured
2239    /// material fails to load; surfaced as an endpoint-creation failure
2240    /// (rc-ayrwk).
2241    strict_tls_error: Option<CamelError>,
2242}
2243
2244#[cfg(test)]
2245thread_local! {
2246    static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2247}
2248
2249pub(crate) fn build_client(
2250    config: &HttpConfig,
2251    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2252) -> reqwest::Client {
2253    #[cfg(test)]
2254    BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2255
2256    let mut builder = reqwest::Client::builder()
2257        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
2258        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2259        .pool_max_idle_per_host(config.pool_max_idle_per_host)
2260        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2261
2262    // Redirects are always handled manually in the producer's send path
2263    // so that each hop can be SSRF-validated. reqwest's built-in redirect
2264    // policy is sync and cannot perform async DNS resolution or SSRF checks.
2265    builder = builder.redirect(reqwest::redirect::Policy::none());
2266
2267    if let Some((host, addrs)) = resolve_override {
2268        builder = builder.resolve_to_addrs(host, addrs);
2269    }
2270
2271    if let Some(tls) = &config.tls
2272        && tls.enabled
2273    {
2274        if tls.insecure || !tls.verify_peer {
2275            // log-policy: handler-owned
2276            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2277            builder = builder.danger_accept_invalid_certs(true);
2278        }
2279
2280        if let Some(ca_path) = &tls.ca_cert_path {
2281            // Audit 2026-08-31, F2-7: a configured CA that fails to load must
2282            // never degrade silently to system roots. Loud warn (config error
2283            // class: fail-fast would break existing deployments relying on the
2284            // fallback; the warning is the operator signal).
2285            match std::fs::read(ca_path) {
2286                Ok(ca_bytes) => {
2287                    // Under the rustls backend `Certificate::from_pem`
2288                    // never fails (it defers parsing), so the parse-error
2289                    // warn below is effectively dead and a file with zero
2290                    // parseable PEM CERTIFICATE sections would silently
2291                    // contribute no roots. Warn on that case explicitly
2292                    // (e_glm stage-4 finding 1).
2293                    let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2294                        .filter(|r| r.is_ok())
2295                        .count();
2296                    if pem_sections == 0 {
2297                        // log-policy: handler-owned
2298                        tracing::warn!(
2299                            "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2300                        );
2301                    }
2302                    match reqwest::Certificate::from_pem(&ca_bytes)
2303                        .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2304                    {
2305                        Ok(ca_cert) => {
2306                            builder = builder.add_root_certificate(ca_cert);
2307                        }
2308                        Err(e) => {
2309                            // log-policy: handler-owned
2310                            tracing::warn!(
2311                                error = %e,
2312                                "configured CA certificate failed to parse — falling back to system roots"
2313                            );
2314                        }
2315                    }
2316                }
2317                Err(e) => {
2318                    // log-policy: handler-owned
2319                    tracing::warn!(
2320                        error = %e,
2321                        "configured CA certificate file unreadable — falling back to system roots"
2322                    );
2323                }
2324            }
2325        }
2326
2327        // mTLS identity: BOTH files must load and parse, or the identity is
2328        // absent. A partial failure previously meant silently downgrading to
2329        // non-mTLS — now loud.
2330        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2331            match (std::fs::read(cert_path), std::fs::read(key_path)) {
2332                (Ok(cert_bytes), Ok(key_bytes)) => {
2333                    let mut identity_pem = cert_bytes;
2334                    identity_pem.extend_from_slice(&key_bytes);
2335                    match reqwest::Identity::from_pem(&identity_pem) {
2336                        Ok(identity) => {
2337                            builder = builder.identity(identity);
2338                        }
2339                        Err(e) => {
2340                            // log-policy: handler-owned
2341                            tracing::warn!(
2342                                error = %e,
2343                                "configured mTLS identity failed to parse — client certificate NOT used"
2344                            );
2345                        }
2346                    }
2347                }
2348                (cert_r, key_r) => {
2349                    // log-policy: handler-owned
2350                    tracing::warn!(
2351                        cert_ok = cert_r.is_ok(),
2352                        key_ok = key_r.is_ok(),
2353                        "configured mTLS cert/key file unreadable — client certificate NOT used"
2354                    );
2355                }
2356            }
2357        }
2358    }
2359
2360    builder
2361        .build()
2362        .expect("reqwest::Client::build() with valid config should not fail") // allow-unwrap
2363}
2364
2365/// Eagerly load and parse the configured TLS material when strict mode is
2366/// on (audit 2026-08-31 R3 / rc-ayrwk). Returns the first failure as an
2367/// `EndpointCreationFailed` error; `None` when the material loads, or when
2368/// strict mode is off (the permissive F2-7 fallback with its loud warns
2369/// stays the default for back-compat).
2370///
2371/// Mirrors the four load sites in [`build_client`]: CA unreadable, CA
2372/// unparseable, mTLS cert/key unreadable, mTLS identity unparseable.
2373fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2374    let tls = config.tls.as_ref()?;
2375    if !tls.enabled || !tls.strict {
2376        return None;
2377    }
2378    if let Some(ca_path) = &tls.ca_cert_path {
2379        match std::fs::read(ca_path) {
2380            Ok(ca_bytes) => {
2381                // `reqwest::Certificate::{from_pem,from_der}` defer parsing
2382                // under rustls, and unparseable entries are silently
2383                // skipped at client build — so strict validation must be
2384                // eager AND match what the backend actually enforces:
2385                // a PEM bundle with at least one parseable CERTIFICATE
2386                // section (rustls-pemfile). A raw-DER file is rejected
2387                // outright: the rustls backend never honors lone-DER
2388                // bytes here (they wrap unvalidated and are dropped at
2389                // root-store insertion), so certifying one under strict
2390                // would certify an unenforced config (e_glm stage-4
2391                // finding 1). Operators convert DER bundles to PEM.
2392                let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2393                    .filter(|r| r.is_ok())
2394                    .count();
2395                if pem_sections == 0 {
2396                    return Some(CamelError::EndpointCreationFailed(format!(
2397                        "tls.strict: configured CA certificate '{ca_path}' has no \
2398                         parseable PEM CERTIFICATE section (DER bundles are not \
2399                         enforced by the TLS backend — convert to PEM)"
2400                    )));
2401                }
2402            }
2403            Err(e) => {
2404                return Some(CamelError::EndpointCreationFailed(format!(
2405                    "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
2406                )));
2407            }
2408        }
2409    }
2410    // A half-configured mTLS pair (cert XOR key) previously degraded
2411    // silently to non-mTLS even under strict — reject it (e_glm stage-4
2412    // finding 2).
2413    if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
2414        return Some(CamelError::EndpointCreationFailed(
2415            "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
2416        ));
2417    }
2418    if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2419        match (std::fs::read(cert_path), std::fs::read(key_path)) {
2420            (Ok(mut cert_bytes), Ok(key_bytes)) => {
2421                cert_bytes.extend_from_slice(&key_bytes);
2422                if reqwest::Identity::from_pem(&cert_bytes).is_err() {
2423                    return Some(CamelError::EndpointCreationFailed(
2424                        "tls.strict: configured mTLS identity failed to parse".to_string(),
2425                    ));
2426                }
2427            }
2428            _ => {
2429                return Some(CamelError::EndpointCreationFailed(
2430                    "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
2431                ));
2432            }
2433        }
2434    }
2435    None
2436}
2437
2438#[cfg(test)]
2439pub(crate) fn build_client_call_count() -> u64 {
2440    BUILD_CLIENT_CALLS.with(|c| c.get())
2441}
2442
2443impl HttpComponent {
2444    pub fn new() -> Self {
2445        let config = HttpConfig::default();
2446        let strict_err = strict_tls_error(&config);
2447        Self {
2448            client: build_client(&config, None),
2449            config,
2450            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2451                PINNED_CLIENT_TTL,
2452                PINNED_CLIENT_MAX_ENTRIES,
2453            )),
2454            strict_tls_error: strict_err,
2455        }
2456    }
2457
2458    pub fn with_config(config: HttpConfig) -> Self {
2459        let strict_err = strict_tls_error(&config);
2460        Self {
2461            client: build_client(&config, None),
2462            config,
2463            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2464                PINNED_CLIENT_TTL,
2465                PINNED_CLIENT_MAX_ENTRIES,
2466            )),
2467            strict_tls_error: strict_err,
2468        }
2469    }
2470
2471    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2472        match config {
2473            Some(cfg) => Self::with_config(cfg),
2474            None => Self::new(),
2475        }
2476    }
2477}
2478
2479impl Default for HttpComponent {
2480    fn default() -> Self {
2481        Self::new()
2482    }
2483}
2484
2485impl Component for HttpComponent {
2486    fn scheme(&self) -> &str {
2487        "http"
2488    }
2489
2490    fn metadata(&self) -> ComponentMetadata {
2491        HttpEndpointConfig::metadata()
2492    }
2493
2494    fn create_endpoint(
2495        &self,
2496        uri: &str,
2497        ctx: &dyn camel_component_api::ComponentContext,
2498    ) -> Result<Box<dyn Endpoint>, CamelError> {
2499        if let Some(err) = &self.strict_tls_error {
2500            return Err(err.clone());
2501        }
2502        self.config.validate()?;
2503        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2504        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2505        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2506            server_config.host.clone(),
2507            server_config.port,
2508        )));
2509        self.pinned_cache
2510            .wire(HttpComponentKind::Http, ctx.metrics());
2511        Ok(Box::new(HttpEndpoint {
2512            uri: uri.to_string(),
2513            config,
2514            server_config,
2515            client: self.client.clone(),
2516            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2517            http_config: self.config.clone(),
2518        }))
2519    }
2520}
2521
2522pub struct HttpsComponent {
2523    config: HttpConfig,
2524    pinned_cache: std::sync::Arc<PinnedClientCache>,
2525    client: reqwest::Client,
2526    /// Set at construction when `tls.strict` is on and the configured
2527    /// material fails to load; surfaced as an endpoint-creation failure
2528    /// (rc-ayrwk).
2529    strict_tls_error: Option<CamelError>,
2530}
2531
2532impl HttpsComponent {
2533    pub fn new() -> Self {
2534        let config = HttpConfig::default();
2535        let strict_err = strict_tls_error(&config);
2536        Self {
2537            client: build_client(&config, None),
2538            config,
2539            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2540                PINNED_CLIENT_TTL,
2541                PINNED_CLIENT_MAX_ENTRIES,
2542            )),
2543            strict_tls_error: strict_err,
2544        }
2545    }
2546
2547    pub fn with_config(config: HttpConfig) -> Self {
2548        let strict_err = strict_tls_error(&config);
2549        Self {
2550            client: build_client(&config, None),
2551            config,
2552            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2553                PINNED_CLIENT_TTL,
2554                PINNED_CLIENT_MAX_ENTRIES,
2555            )),
2556            strict_tls_error: strict_err,
2557        }
2558    }
2559
2560    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2561        match config {
2562            Some(cfg) => Self::with_config(cfg),
2563            None => Self::new(),
2564        }
2565    }
2566}
2567
2568impl Default for HttpsComponent {
2569    fn default() -> Self {
2570        Self::new()
2571    }
2572}
2573
2574impl Component for HttpsComponent {
2575    fn scheme(&self) -> &str {
2576        "https"
2577    }
2578
2579    fn metadata(&self) -> ComponentMetadata {
2580        // HTTPS shares the same URI option surface and capabilities as HTTP.
2581        // Only the scheme and description differ.
2582        let mut meta = HttpEndpointConfig::metadata();
2583        meta.scheme = "https".to_string();
2584        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2585        meta
2586    }
2587
2588    fn create_endpoint(
2589        &self,
2590        uri: &str,
2591        ctx: &dyn camel_component_api::ComponentContext,
2592    ) -> Result<Box<dyn Endpoint>, CamelError> {
2593        if let Some(err) = &self.strict_tls_error {
2594            return Err(err.clone());
2595        }
2596        self.config.validate()?;
2597        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2598        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2599        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2600            server_config.host.clone(),
2601            server_config.port,
2602        )));
2603        self.pinned_cache
2604            .wire(HttpComponentKind::Https, ctx.metrics());
2605        Ok(Box::new(HttpEndpoint {
2606            uri: uri.to_string(),
2607            config,
2608            server_config,
2609            client: self.client.clone(),
2610            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2611            http_config: self.config.clone(),
2612        }))
2613    }
2614}
2615
2616// ---------------------------------------------------------------------------
2617// HttpEndpoint
2618// ---------------------------------------------------------------------------
2619
2620struct HttpEndpoint {
2621    uri: String,
2622    config: HttpEndpointConfig,
2623    server_config: HttpServerConfig,
2624    client: reqwest::Client,
2625    pinned_cache: std::sync::Arc<PinnedClientCache>,
2626    http_config: HttpConfig,
2627}
2628
2629impl Endpoint for HttpEndpoint {
2630    fn uri(&self) -> &str {
2631        &self.uri
2632    }
2633
2634    fn create_consumer(
2635        &self,
2636        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2637    ) -> Result<Box<dyn Consumer>, CamelError> {
2638        // Scheme/config consistency check (spec §5) — uses parsed scheme
2639        // from HttpServerConfig, not a fragile port-443 heuristic.
2640        let scheme_is_https = self.server_config.scheme == "https";
2641        let has_tls = self.server_config.tls_config.is_some();
2642
2643        if scheme_is_https && !has_tls {
2644            return Err(CamelError::EndpointCreationFailed(
2645                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2646            ));
2647        }
2648        if !scheme_is_https && has_tls {
2649            return Err(CamelError::EndpointCreationFailed(
2650                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2651            ));
2652        }
2653        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2654    }
2655
2656    fn create_producer(
2657        &self,
2658        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2659        _ctx: &ProducerContext,
2660    ) -> Result<BoxProcessor, CamelError> {
2661        let producer = HttpProducer {
2662            config: Arc::new(self.config.clone()),
2663            client: self.client.clone(),
2664            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2665            http_config: Arc::new(self.http_config.clone()),
2666            runtime: rt,
2667        };
2668        if let Some(ref provider) = self.config.token_provider {
2669            let layer = BearerTokenLayer::new(Arc::clone(provider));
2670            Ok(BoxProcessor::new(layer.layer(producer)))
2671        } else {
2672            Ok(BoxProcessor::new(producer))
2673        }
2674    }
2675}
2676
2677// ---------------------------------------------------------------------------
2678// HttpProducer
2679// ---------------------------------------------------------------------------
2680
2681#[derive(Clone)]
2682struct HttpProducer {
2683    config: Arc<HttpEndpointConfig>,
2684    client: reqwest::Client,
2685    pinned_cache: std::sync::Arc<PinnedClientCache>,
2686    http_config: Arc<HttpConfig>,
2687    /// Runtime observability handle powering the component-ops facade at
2688    /// the request boundary (`("http","request")`, dashboard-observability
2689    /// Task 4.3). The retained `e:http:accept*` labels are consumer-side
2690    /// (server accept loop) — different boundary, no collision with
2691    /// `e:http:request`.
2692    runtime: Arc<dyn RuntimeObservability>,
2693}
2694
2695impl HttpProducer {
2696    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2697        if let Some(ref method) = config.http_method {
2698            return method.to_uppercase();
2699        }
2700        if let Some(method) = exchange
2701            .input
2702            .header("CamelHttpMethod")
2703            .and_then(|v| v.as_str())
2704        {
2705            return method.to_uppercase();
2706        }
2707        if !exchange.input.body.is_empty() {
2708            return "POST".to_string();
2709        }
2710        "GET".to_string()
2711    }
2712
2713    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2714        // bridgeEndpoint=true: exchange URL headers (CamelHttpUri,
2715        // CamelHttpPath, CamelHttpQuery) are ignored per Apache Camel
2716        // bridging semantics. The endpoint's own query still rides: the
2717        // same raw-preserving, consumed-option-filtered query as the
2718        // non-bridge path (bridgeEndpoint itself is a consumed option),
2719        // with programmatic query_params appending absent keys after the
2720        // raw base. This check MUST come before the CamelHttpUri override
2721        // so bridging wins over that header.
2722        if config.bridge_endpoint {
2723            let Some(query) = resolve_endpoint_query(config)? else {
2724                return Ok(config.base_url.clone());
2725            };
2726            // Validation only (rc-ph7z2): a malformed base still errors
2727            // through the redacted-diagnostic path below. The parsed value
2728            // is NEVER re-emitted — assembly is verbatim string
2729            // composition, authored bytes end-to-end: no WHATWG
2730            // normalization (dot-segment collapse, default-port strip,
2731            // scheme/host lowercasing), matching every other arm (Papal
2732            // Direction A).
2733            let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2734                CamelError::ProcessorError(format!(
2735                    "invalid base URL '{}': {e}",
2736                    redact_url_for_diagnostics(&config.base_url)
2737                ))
2738            })?;
2739            let mut url = config.base_url.clone();
2740            url.push('?');
2741            url.push_str(&query);
2742            return Ok(url);
2743        }
2744
2745        if let Some(uri) = exchange
2746            .input
2747            .header("CamelHttpUri")
2748            .and_then(|v| v.as_str())
2749        {
2750            // Host fence (allowedUriHosts): opt-in, fail-closed. Evaluated
2751            // on the raw override before any path/query assembly; a
2752            // rejection renders the URL only through the diagnostics
2753            // redaction path (ADR-0051).
2754            if let Some(fence) = &config.allowed_uri_hosts
2755                && !uri_host_allowed(uri, fence)?
2756            {
2757                return Err(CamelError::ProcessorError(format!(
2758                    "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2759                    redact_url_for_diagnostics(uri)
2760                )));
2761            }
2762            // The override replaces the base URL; its own query is the
2763            // higher-precedence source for composition (ADR-0071) — the
2764            // endpoint base query does not ride an override. Split at the
2765            // first `?` so CamelHttpPath applies to the path component
2766            // and the queries merge at pair level, never a second `?`
2767            // marker.
2768            let (base, override_query) = match uri.split_once('?') {
2769                Some((base, query)) => (base, Some(query)),
2770                None => (uri, None),
2771            };
2772            // Resolve-time span validation for the override URI's own query
2773            // (rc-m4xk1): a forbidden byte is a resolve error naming the
2774            // byte, never a verbatim ride that later surfaces as a reqwest
2775            // send error. Covers both downstream arms — the verbatim push
2776            // and merge_header_query, which validates only the header side.
2777            if let Some(query) = override_query {
2778                for (_key, span) in raw_query_pairs(query)? {
2779                    validate_raw_query_span(span)?;
2780                }
2781            }
2782            let mut url = base.to_string();
2783            if let Some(path) = exchange
2784                .input
2785                .header("CamelHttpPath")
2786                .and_then(|v| v.as_str())
2787            {
2788                if !url.ends_with('/') && !path.starts_with('/') {
2789                    url.push('/');
2790                }
2791                url.push_str(path);
2792            }
2793            if let Some(query) = exchange
2794                .input
2795                .header("CamelHttpQuery")
2796                .and_then(|v| v.as_str())
2797            {
2798                if let Some(merged) = merge_header_query(override_query, query)? {
2799                    url.push('?');
2800                    url.push_str(&merged);
2801                }
2802                return Ok(url);
2803            }
2804            if let Some(query) = override_query {
2805                url.push('?');
2806                url.push_str(query);
2807            }
2808            return Ok(url);
2809        }
2810
2811        let mut url = config.base_url.clone();
2812
2813        if let Some(path) = exchange
2814            .input
2815            .header("CamelHttpPath")
2816            .and_then(|v| v.as_str())
2817        {
2818            if !url.ends_with('/') && !path.starts_with('/') {
2819                url.push('/');
2820            }
2821            url.push_str(path);
2822        }
2823
2824        if let Some(query) = exchange
2825            .input
2826            .header("CamelHttpQuery")
2827            .and_then(|v| v.as_str())
2828        {
2829            // Compose: the endpoint query (raw-preserving,
2830            // consumed-option-filtered) comes first and wins collisions;
2831            // header pairs append verbatim for absent keys (ADR-0071).
2832            // An empty header leaves the endpoint query unchanged.
2833            if let Some(merged) =
2834                merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2835            {
2836                url.push('?');
2837                url.push_str(&merged);
2838            }
2839            return Ok(url);
2840        }
2841
2842        if let Some(query) = resolve_endpoint_query(config)? {
2843            url.push('?');
2844            url.push_str(&query);
2845        }
2846
2847        Ok(url)
2848    }
2849
2850    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2851        status >= range.0 && status <= range.1
2852    }
2853}
2854
2855/// One allowlist entry of the `CamelHttpUri` host fence (`allowedUriHosts`
2856/// endpoint option). DNS hosts are stored ASCII-lowercased; IPv6 literals
2857/// in bracketed canonical form (the `url` crate's host serialization). A
2858/// `port` of `None` is a host-only entry and permits any port.
2859#[derive(Clone, Debug, PartialEq, Eq)]
2860pub struct AllowedUriHost {
2861    /// Canonical host: lowercased DNS name or bracketed IPv6 literal.
2862    pub host: String,
2863    /// `Some` pins the entry to one effective port; `None` permits any.
2864    pub port: Option<u16>,
2865}
2866
2867/// Parse the `allowedUriHosts` option value: comma-separated `host` or
2868/// `host:port` entries. Bracketed IPv6 is supported (`[::1]:8443`, bare
2869/// `[::1]` host-only). Empty segments are dropped. Segments are parsed
2870/// through the `url` crate (with an `http://` scheme injected) so DNS
2871/// names are lowercased and ports range-checked; anything it rejects is a
2872/// malformed entry. A value yielding zero valid entries is also an error.
2873/// Both failure modes fail endpoint creation (fail-closed).
2874fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2875    let mut entries = Vec::new();
2876    for segment in raw.split(',') {
2877        let segment = segment.trim();
2878        if segment.is_empty() {
2879            continue;
2880        }
2881        let parsed = url::Url::parse(&format!("http://{segment}"))
2882            .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2883        // A segment carrying a path or userinfo is a typo'd entry — the
2884        // spec's "any other malformed entry" clause. Silently narrowing it
2885        // to its hostname would widen or skew the fence.
2886        if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2887            return Err(invalid_allowed_uri_host_entry(segment));
2888        }
2889        let Some(host) = parsed.host_str() else {
2890            return Err(invalid_allowed_uri_host_entry(segment));
2891        };
2892        entries.push(AllowedUriHost {
2893            host: host.to_string(),
2894            port: parsed.port(),
2895        });
2896    }
2897    if entries.is_empty() {
2898        return Err(CamelError::InvalidUri(
2899            "allowedUriHosts declares no valid host entries".to_string(),
2900        ));
2901    }
2902    Ok(entries)
2903}
2904
2905fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2906    CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2907}
2908
2909/// Whether `url_str` matches the fence. Parse failure or a host-less URL
2910/// is fail-closed (`Ok(false)`). DNS hosts compare case-insensitively
2911/// (both sides are lowercased by the `url` crate); IPv6 compares in
2912/// bracketed canonical form. A host-only entry permits any port; a
2913/// `host:port` entry matches only the effective port — the explicit port
2914/// or the scheme default (443 for https, 80 for http).
2915pub(crate) fn uri_host_allowed(
2916    url_str: &str,
2917    fence: &[AllowedUriHost],
2918) -> Result<bool, CamelError> {
2919    let Ok(parsed) = url::Url::parse(url_str) else {
2920        return Ok(false);
2921    };
2922    let Some(host) = parsed.host_str() else {
2923        return Ok(false);
2924    };
2925    let effective_port = parsed.port().or(match parsed.scheme() {
2926        "https" => Some(443_u16),
2927        "http" => Some(80),
2928        _ => None,
2929    });
2930    Ok(fence.iter().any(|entry| {
2931        entry.host == host
2932            && match entry.port {
2933                None => true,
2934                Some(port) => effective_port == Some(port),
2935            }
2936    }))
2937}
2938
2939/// Serialize the outbound query for the endpoint base.
2940///
2941/// Authored raw pairs come first, byte-for-byte minus consumed option keys
2942/// (order, separators and authored escapes — including `RAW(...)` text —
2943/// preserved); then programmatic `query_params` entries whose key is absent
2944/// from the authored pairs, in declaration order with minimal RFC-3986
2945/// encoding (`%20`, never `+`). Authored keys always win — no duplication,
2946/// no override.
2947///
2948/// Returns `Ok(None)` when no query component is emitted: no pairs at all,
2949/// or a non-empty raw query whose every pair was consumed. A bare `?`
2950/// marker (`raw_query == Some("")`) always emits the query component.
2951fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2952    let mut parts: Vec<String> = Vec::new();
2953    let mut authored_keys = std::collections::HashSet::new();
2954
2955    if let Some(raw) = config.raw_query.as_deref() {
2956        for (key, span) in raw_query_pairs(raw)? {
2957            authored_keys.insert(key.clone());
2958            if is_consumed_option(&key) {
2959                continue;
2960            }
2961            validate_raw_query_span(span)?;
2962            parts.push(span.to_string());
2963        }
2964    }
2965
2966    for (key, value) in &config.query_params {
2967        if !authored_keys.contains(key.as_str()) {
2968            parts.push(format!(
2969                "{}={}",
2970                encode_query_component(key),
2971                encode_query_component(value)
2972            ));
2973        }
2974    }
2975
2976    if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2977        return Ok(None);
2978    }
2979    Ok(Some(parts.join("&")))
2980}
2981
2982/// Compose the outbound query when a `CamelHttpQuery` exchange header is
2983/// present (ADR-0071). `higher_precedence` — the endpoint query in the
2984/// base arm, the override URI's own query in the override arm — comes
2985/// first and wins any key collision; header pairs append verbatim for
2986/// absent keys only. An empty header leaves the higher-precedence query
2987/// unchanged (no additional `?` marker). Header spans are validated, not
2988/// re-encoded: a byte forbidden in a query component is a resolve error
2989/// naming the byte (Wave-A law).
2990fn merge_header_query(
2991    higher_precedence: Option<&str>,
2992    header_query: &str,
2993) -> Result<Option<String>, CamelError> {
2994    if header_query.is_empty() {
2995        return Ok(higher_precedence.map(str::to_string));
2996    }
2997    let mut parts: Vec<String> = Vec::new();
2998    let mut higher_keys = std::collections::HashSet::new();
2999    for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
3000        higher_keys.insert(key);
3001        parts.push(span.to_string());
3002    }
3003    for (key, span) in raw_query_pairs(header_query)? {
3004        validate_raw_query_span(span)?;
3005        if !higher_keys.contains(key.as_str()) {
3006            parts.push(span.to_string());
3007        }
3008    }
3009    if parts.is_empty() {
3010        return Ok(None);
3011    }
3012    Ok(Some(parts.join("&")))
3013}
3014
3015/// Bytes that may appear unescaped in a URI query component. RFC 3986
3016/// (`query = *( pchar / "/" / "?" )`) admits unreserved, sub-delims, `:`,
3017/// `@`, `/`, `?`, and `%` — with ONE deliberate exclusion from the RFC set:
3018/// the apostrophe (`'`, 0x27). reqwest's WHATWG URL parser re-encodes 0x27
3019/// to `%27` in the special-query percent-encode set (http/https), so an
3020/// authored apostrophe can never ride the wire verbatim; admitting it would
3021/// silently normalize authored bytes (rc-nmupb). Authors write `%27`
3022/// explicitly when they mean the byte on the wire. The WHATWG set's other
3023/// extras (`"`, `` ` ``, `<`, `>`) are already rejected here — they are not
3024/// RFC 3986 query-legal bytes, so no special exclusion is needed for them.
3025fn is_legal_query_byte(byte: u8) -> bool {
3026    matches!(byte,
3027        b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
3028        | b'-' | b'.' | b'_' | b'~'
3029        | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
3030        | b':' | b'@' | b'/' | b'?'
3031        | b'%')
3032}
3033
3034/// Reject an authored raw pair carrying a byte that is not legal in a query
3035/// component (e.g. literal space, `#`, non-ASCII). The serializer never
3036/// silently re-encodes operator-authored bytes: "byte-for-byte" is bounded
3037/// to wire-legal bytes, and the check fires before the resolved string
3038/// reaches any consumer (SSRF pre-check, diagnostics redaction).
3039fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
3040    for &byte in span.as_bytes() {
3041        if !is_legal_query_byte(byte) {
3042            return Err(CamelError::ProcessorError(format!(
3043                "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
3044            )));
3045        }
3046    }
3047    Ok(())
3048}
3049
3050/// Minimal RFC-3986 percent-encoding for one programmatic query component:
3051/// unreserved bytes pass through, every other byte encodes as uppercase
3052/// hex. A space encodes as `%20`, never `+`.
3053fn encode_query_component(component: &str) -> String {
3054    const HEX: &[u8; 16] = b"0123456789ABCDEF";
3055    let mut out = String::with_capacity(component.len());
3056    for &byte in component.as_bytes() {
3057        match byte {
3058            b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3059                out.push(byte as char);
3060            }
3061            _ => {
3062                out.push('%');
3063                out.push(HEX[(byte >> 4) as usize] as char);
3064                out.push(HEX[(byte & 0x0f) as usize] as char);
3065            }
3066        }
3067    }
3068    out
3069}
3070
3071/// Redact credentials from a URL before it reaches logs or error values
3072/// (ADR-0051 redact-by-construction). Masks userinfo (`user:pass@`) and
3073/// the query string (which commonly carries API keys/tokens). Host and
3074/// path stay visible for diagnosability. Fragments are never echoed: a
3075/// fragment (OAuth2 callback tokens such as `#access_token=...`) is
3076/// dropped and replaced with the `#[redacted]` sentinel in both the
3077/// parsed arm and the unparseable arm. Fail-closed: when the parse fails
3078/// and any authority window contains `@`, only the `[redacted]`
3079/// sentinel is returned. Every authority window is scanned: windows are
3080/// enumerated over maximal runs of `/` and `\` — pure-slash runs of two
3081/// or more characters, backslash-bearing runs only behind an RFC 3986
3082/// scheme prefix (see [`camel_api::redact`] for the canonical window
3083/// rule) — each window starts immediately after the run (so evaders like
3084/// `scheme:////user:pass@evil/` cannot hide a `@` behind a slash run)
3085/// and ends at the next `/`, `?`, or `#`; scanning all windows keeps
3086/// later `//user:pass@` substrings from hiding behind a benign first
3087/// window.
3088///
3089/// The parsed arm keeps `url::Url::parse` (the authority can only be
3090/// judged by the parser) and masks the real authority accessors, then
3091/// delegates wholesale to the canonical string surgery in
3092/// [`camel_api::redact::redact_url`]: rust-url can park later-window
3093/// userinfo bytes in the path (`https://h//user:pass@evil/`), and the
3094/// canonical helper owns window masking, `?`/`#` sentinel composition
3095/// (one per distinct introducer, first-occurrence order), and the
3096/// 256-byte UTF-8 cap. The unparseable arm delegates to
3097/// [`camel_api::redact::redact_url_fail_closed`].
3098pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3099    match url::Url::parse(raw) {
3100        Ok(mut u) => {
3101            // Fail closed when an authority marker was accepted but no
3102            // host was stored: userinfo-shaped bytes can hide in the path
3103            // behind the marker, and empty-host schemes (`file:///us@r/x`,
3104            // `unix:///@socket`) can put a `@` in that window too. Such
3105            // inputs are sentineled wholesale — deliberate fail-closed
3106            // over-redaction per ADR-0051.
3107            if !u.cannot_be_a_base()
3108                && u.host_str().is_none()
3109                && camel_api::redact::window_has_at_sign(raw)
3110            {
3111                return "[redacted]".to_string();
3112            }
3113            if !u.username().is_empty() || u.password().is_some() {
3114                let _ = u.set_username("***");
3115                let _ = u.set_password(None);
3116            }
3117            // Query and fragment stay on the rendered URL; the canonical
3118            // redactor drops them and composes the sentinels.
3119            let s = u.to_string();
3120            camel_api::redact::redact_url(&s)
3121        }
3122        Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3123    }
3124}
3125
3126/// Maximum bytes of an upstream error response body embedded into
3127/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
3128/// malicious or compromised upstream), so it is truncated and lossy-decoded to
3129/// bound log injection / DLQ payload size.
3130const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3131
3132fn truncate_error_body(body: &[u8]) -> String {
3133    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3134        String::from_utf8_lossy(body).into_owned()
3135    } else {
3136        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3137        s.push_str("...[truncated]");
3138        s
3139    }
3140}
3141
3142impl HttpProducer {
3143    /// Whether the HTTP method is entity-enclosing (may carry a request
3144    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
3145    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
3146    /// §9.3.1/§9.3.2).
3147    fn is_entity_enclosing(method: &str) -> bool {
3148        matches!(method, "POST" | "PUT" | "PATCH")
3149    }
3150}
3151
3152impl Service<Exchange> for HttpProducer {
3153    type Response = Exchange;
3154    type Error = CamelError;
3155    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3156
3157    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3158        Poll::Ready(Ok(()))
3159    }
3160
3161    fn call(&mut self, exchange: Exchange) -> Self::Future {
3162        let config = self.config.clone();
3163        let shared_client = self.client.clone();
3164        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3165        let http_config = self.http_config.clone();
3166        let component_metrics = self.runtime.component_metrics();
3167
3168        Box::pin(async move {
3169            let mut exchange = exchange;
3170            let outcome = async {
3171                let method_str = HttpProducer::resolve_method(&exchange, &config);
3172                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
3173                // and PATCH may carry a request body. Any other resolved method
3174                // drops the exchange body before the request is built (Apache
3175                // Camel `HttpMethods` parity).
3176                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3177                let url = HttpProducer::resolve_url(&exchange, &config)?;
3178
3179                // SECURITY: Validate URL for SSRF
3180                ssrf::validate_url_for_ssrf(&url, &config)?;
3181
3182                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
3183                // (L-H2). When the URL uses a domain name and SSRF protection is active,
3184                // reuse the endpoint's cached DNS-pinned client for that validated
3185                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
3186                // repeated requests keep one connection pool without re-resolving DNS.
3187                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
3188                // URLs use the endpoint's unpinned shared client.
3189                let resolved = ssrf::resolve_initial_url_for_ssrf(
3190                    &url,
3191                    config.allow_internal,
3192                    config.allow_cleartext,
3193                )
3194                .await?;
3195                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3196                    pinned_cache
3197                        .get_or_build(host.as_str(), addrs, || {
3198                            build_client(&http_config, Some((host.as_str(), addrs)))
3199                        })
3200                        .await
3201                } else {
3202                    shared_client.clone()
3203                };
3204
3205                debug!(
3206                    correlation_id = %exchange.correlation_id(),
3207                    method = %method_str,
3208                    url = %redact_url_for_diagnostics(&url),
3209                    "HTTP request"
3210                );
3211
3212                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3213                    CamelError::ProcessorError(format!(
3214                        "Invalid HTTP method '{}': {}",
3215                        method_str, e
3216                    ))
3217                })?;
3218
3219                // Collect headers for potential redirect replay
3220                let mut collected_headers: Vec<(
3221                    reqwest::header::HeaderName,
3222                    reqwest::header::HeaderValue,
3223                )> = Vec::new();
3224
3225                if let Some(user_agent) = &config.user_agent
3226                    && !config.bridge_endpoint
3227                {
3228                    match constructed_header("user-agent", user_agent) {
3229                        Ok((_, val)) => {
3230                            collected_headers.push((reqwest::header::USER_AGENT, val));
3231                        }
3232                        Err(drop) => debug!(
3233                            correlation_id = %exchange.correlation_id(),
3234                            header = %drop.name,
3235                            "outbound header dropped: {}",
3236                            drop.reason
3237                        ),
3238                    }
3239                }
3240
3241                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
3242                #[cfg(feature = "otel")]
3243                let should_inject_otel = !config.bridge_endpoint;
3244                #[cfg(feature = "otel")]
3245                if should_inject_otel {
3246                    let mut otel_headers = HashMap::new();
3247                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3248                    for (k, v) in otel_headers {
3249                        match constructed_header(&k, &v) {
3250                            Ok((name, val)) => collected_headers.push((name, val)),
3251                            Err(drop) => debug!(
3252                                correlation_id = %exchange.correlation_id(),
3253                                header = %drop.name,
3254                                "outbound header dropped: {}",
3255                                drop.reason
3256                            ),
3257                        }
3258                    }
3259                }
3260
3261                let conn_tokens = header_policy::connection_tokens(
3262                    exchange
3263                        .input
3264                        .headers
3265                        .iter()
3266                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3267                        .filter_map(|(_, v)| v.as_str()),
3268                );
3269
3270                let outbound = select_outbound_headers(
3271                    &exchange.input.headers,
3272                    &config.skip_request_headers,
3273                    &conn_tokens,
3274                );
3275                for drop in &outbound.drops {
3276                    if let Some(value_kind) = drop.value_kind {
3277                        debug!(
3278                            correlation_id = %exchange.correlation_id(),
3279                            header = %drop.name,
3280                            value_kind = value_kind,
3281                            "outbound header dropped: {}",
3282                            drop.reason
3283                        );
3284                    } else {
3285                        debug!(
3286                            correlation_id = %exchange.correlation_id(),
3287                            header = %drop.name,
3288                            "outbound header dropped: {}",
3289                            drop.reason
3290                        );
3291                    }
3292                }
3293                collected_headers.extend(outbound.accepted);
3294
3295                // Auth headers
3296                if !config.bridge_endpoint {
3297                    match &config.auth {
3298                        HttpAuth::None => {}
3299                        HttpAuth::Basic { username, password } => {
3300                            use base64::Engine;
3301                            // allow-secret: credentials combined for base64 Basic auth header
3302                            let credentials = format!("{username}:{password}");
3303                            let encoded =
3304                                base64::engine::general_purpose::STANDARD.encode(credentials);
3305                            // Base64 output is always header-safe; the guard is kept
3306                            // for uniformity with Bearer.
3307                            match constructed_header("authorization", &format!("Basic {encoded}")) {
3308                                Ok((_, val)) => {
3309                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3310                                }
3311                                Err(drop) => debug!(
3312                                    correlation_id = %exchange.correlation_id(),
3313                                    header = %drop.name,
3314                                    "outbound header dropped: {}",
3315                                    drop.reason
3316                                ),
3317                            }
3318                        }
3319                        HttpAuth::Bearer { token } => {
3320                            // allow-secret: Bearer token in Authorization header
3321                            let bearer = format!("Bearer {token}");
3322                            match constructed_header("authorization", &bearer) {
3323                                Ok((_, val)) => {
3324                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3325                                }
3326                                Err(drop) => debug!(
3327                                    correlation_id = %exchange.correlation_id(),
3328                                    header = %drop.name,
3329                                    "outbound header dropped: {}",
3330                                    drop.reason
3331                                ),
3332                            }
3333                        }
3334                    }
3335
3336                    if config.connection_close {
3337                        collected_headers.push((
3338                            reqwest::header::CONNECTION,
3339                            reqwest::header::HeaderValue::from_static("close"),
3340                        ));
3341                    }
3342                }
3343
3344                // Materialize body
3345                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3346                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3347                    if suppress_body {
3348                        // A stream body dropped under a non-entity-enclosing
3349                        // method always warns (its emptiness is unknowable) and
3350                        // stays consumed (mem::take). The stream attach arm below
3351                        // still runs its outer flag check, but the inner `if let
3352                        // Body::Stream` re-match fails on the now-Empty body, so
3353                        // no stream is attached and no AlreadyConsumed error can
3354                        // fire.
3355                        std::mem::take(&mut exchange.input.body);
3356                        // log-policy: handler-owned
3357                        tracing::warn!(
3358                            correlation_id = %exchange.correlation_id(),
3359                            method = %method_str,
3360                            "dropping request body for non-entity-enclosing HTTP method"
3361                        );
3362                    }
3363                    None // Streams can't be replayed on redirect
3364                } else {
3365                    let body = std::mem::take(&mut exchange.input.body);
3366                    let bytes = body.into_bytes(config.max_body_size).await?;
3367                    if bytes.is_empty() {
3368                        // Empty body: nothing to send and nothing to warn about.
3369                        None
3370                    } else if suppress_body {
3371                        // log-policy: handler-owned
3372                        tracing::warn!(
3373                            correlation_id = %exchange.correlation_id(),
3374                            method = %method_str,
3375                            "dropping request body for non-entity-enclosing HTTP method"
3376                        );
3377                        None
3378                    } else {
3379                        Some(bytes.to_vec())
3380                    }
3381                };
3382
3383                let response = if config.follow_redirects && !is_stream_body {
3384                    // Use manual redirect loop with per-hop SSRF validation.
3385                    // `client` is the pinned-or-shared binding for the initial
3386                    // request (a hostname initial request keeps its DNS-pinned
3387                    // client); `shared_client` is the unpinned endpoint client
3388                    // reused by IP-literal redirect hops.
3389                    ssrf::send_with_ssrf_safe_redirects(
3390                        &client,
3391                        &shared_client,
3392                        &pinned_cache,
3393                        &http_config,
3394                        &config,
3395                        method,
3396                        &url,
3397                        collected_headers,
3398                        materialized_body,
3399                        config.max_redirects,
3400                        config.response_timeout,
3401                    )
3402                    .await?
3403                } else {
3404                    // Direct send (no redirect following, or streaming body)
3405                    let mut request = client.request(method, &url);
3406
3407                    if let Some(timeout) = config.response_timeout {
3408                        request = request.timeout(timeout);
3409                    }
3410
3411                    for (name, value) in &collected_headers {
3412                        request = request.header(name, value);
3413                    }
3414
3415                    if is_stream_body {
3416                        if let Body::Stream(ref s) = exchange.input.body {
3417                            let mut stream_lock = s.stream.lock().await;
3418                            if let Some(stream) = stream_lock.take() {
3419                                request = request.body(reqwest::Body::wrap_stream(stream));
3420                            } else {
3421                                return Err(CamelError::AlreadyConsumed);
3422                            }
3423                        }
3424                    } else if let Some(ref body_bytes) = materialized_body {
3425                        request = request.body(body_bytes.clone());
3426                    }
3427
3428                    request.send().await.map_err(|e| {
3429                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3430                    })?
3431                };
3432
3433                let status_code = response.status().as_u16();
3434                let status_text = response
3435                    .status()
3436                    .canonical_reason()
3437                    .unwrap_or("Unknown")
3438                    .to_string();
3439
3440                for (key, value) in response.headers() {
3441                    if config
3442                        .skip_response_headers
3443                        .iter()
3444                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3445                    {
3446                        continue;
3447                    }
3448                    if let Ok(val_str) = value.to_str() {
3449                        exchange.input.set_header(
3450                            title_case_header(key.as_str()),
3451                            serde_json::Value::String(val_str.to_string()),
3452                        );
3453                    }
3454                }
3455
3456                exchange.input.set_header(
3457                    "CamelHttpResponseCode",
3458                    serde_json::Value::Number(status_code.into()),
3459                );
3460                exchange.input.set_header(
3461                    "CamelHttpResponseText",
3462                    serde_json::Value::String(status_text.clone()),
3463                );
3464
3465                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
3466                let read_timeout = Duration::from_millis(config.read_timeout_ms);
3467                let response_body = tokio::time::timeout(read_timeout, async {
3468                    // Check Content-Length header before allocating
3469                    if let Some(content_len) = response.content_length()
3470                        && content_len > config.max_response_bytes as u64
3471                    {
3472                        return Err(CamelError::ProcessorError(format!(
3473                            "Response body too large: {} bytes exceeds limit of {} bytes",
3474                            content_len, config.max_response_bytes
3475                        )));
3476                    }
3477                    // Use bytes_stream() for lazy streaming with size guard
3478                    use futures::TryStreamExt;
3479                    let mut stream = response.bytes_stream();
3480                    let mut total: usize = 0;
3481                    let mut collected = Vec::new();
3482                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
3483                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3484                    })? {
3485                        total += chunk.len();
3486                        if total > config.max_response_bytes {
3487                            return Err(CamelError::ProcessorError(format!(
3488                                "Response body too large: {} bytes exceeds limit of {} bytes",
3489                                total, config.max_response_bytes
3490                            )));
3491                        }
3492                        collected.push(chunk);
3493                    }
3494                    let mut result = bytes::BytesMut::with_capacity(total);
3495                    for chunk in collected {
3496                        result.extend_from_slice(&chunk);
3497                    }
3498                    Ok::<bytes::Bytes, CamelError>(result.freeze())
3499                })
3500                .await
3501                .map_err(|_| {
3502                    CamelError::ProcessorError(format!(
3503                        "Read timeout after {}ms",
3504                        config.read_timeout_ms
3505                    ))
3506                })??;
3507
3508                if config.throw_exception_on_failure
3509                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3510                {
3511                    return Err(CamelError::HttpOperationFailed {
3512                        method: method_str,
3513                        // ADR-0051 redact-by-construction: never embed
3514                        // userinfo/query credentials in the error value.
3515                        url: redact_url_for_diagnostics(&url),
3516                        status_code,
3517                        status_text,
3518                        response_body: Some(truncate_error_body(&response_body)),
3519                    });
3520                }
3521
3522                if !response_body.is_empty() {
3523                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3524                }
3525
3526                debug!(
3527                    correlation_id = %exchange.correlation_id(),
3528                    status = status_code,
3529                    url = %redact_url_for_diagnostics(&url),
3530                    "HTTP response"
3531                );
3532                Ok(exchange)
3533            }
3534            .await;
3535            // ("http","request") facade (dashboard-observability 4.3): the
3536            // request boundary is the full client round-trip — SSRF checks,
3537            // send, response read, and (with throwExceptionOnFailure) the
3538            // status gate. http runs no retry_async and the producer
3539            // previously emitted nothing, so no label collides with
3540            // e:http:request.
3541            component_metrics.observe("http", "request", outcome.is_err());
3542            outcome
3543        })
3544    }
3545}
3546
3547/// Serializes tests that mutate or depend on the global `ServerRegistry`.
3548///
3549/// `ServerRegistry::global()` is a process-wide singleton that persists
3550/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
3551/// with another test that has a live server on a fixed port (e.g. 9991),
3552/// the registry entry is removed while the OS socket is still bound, so
3553/// the next `get_or_spawn` call on that port fails with "Address already
3554/// in use". This mutex does not give blanket protection by itself. It
3555/// helps only where every participant follows the mutex law: the
3556/// consumer-test readiness helper holds it from `stage_listener` until
3557/// readiness-complete (http-test-harness spec, requirement
3558/// "Registry-mutation serialization during setup"), and each `reset()`
3559/// caller takes it before the reset.
3560#[cfg(test)]
3561pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3562
3563/// Poison-recovering acquire of REGISTRY_TEST_MUTEX (httpflake).
3564///
3565/// The mutex guards test SERIALIZATION only - the registry own data is
3566/// protected by its inner lock - so a sibling test that panics while
3567/// holding the guard must not poison the mutex and cascade failures
3568/// into every other holder. Recovery via into_inner is therefore safe
3569/// and keeps one failing test failing as ONE test.
3570#[cfg(test)]
3571pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3572    REGISTRY_TEST_MUTEX
3573        .lock()
3574        .unwrap_or_else(|poisoned| poisoned.into_inner())
3575}
3576
3577/// Map a pipeline error to an HTTP reply.
3578///
3579/// Extracted from the inline `match` in `dispatch_handler` for unit
3580/// testability (rc-1dk4). Client-fault errors map to their 4xx codes
3581/// with a structured JSON error body: `TypeConversionFailed`/
3582/// `ValidationError` → 400, `UnsupportedMediaType` → 415 (media
3583/// negotiation gate, REST lowering), `NotAcceptable` → 406 (same
3584/// gate); `Unauthenticated`/`Unauthorized` keep their `401`/`403`
3585/// mappings; all other errors map to `500 Internal Server Error`.
3586fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3587    match e {
3588        CamelError::Unauthenticated(msg) => {
3589            tracing::warn!(error = %msg, path = %path, "Authentication failed");
3590            HttpReply {
3591                status: 401,
3592                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3593                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3594            }
3595        }
3596        CamelError::Unauthorized(msg) => {
3597            tracing::warn!(error = %msg, path = %path, "Authorization failed");
3598            HttpReply {
3599                status: 403,
3600                headers: vec![],
3601                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3602            }
3603        }
3604        CamelError::TypeConversionFailed(msg) => {
3605            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3606            json_error_reply(400, "bad_request", msg)
3607        }
3608        CamelError::ValidationError(msg) => {
3609            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3610            json_error_reply(400, "validation_error", msg)
3611        }
3612        CamelError::ConsumerStopping => {
3613            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3614            HttpReply {
3615                status: 503,
3616                headers: vec![],
3617                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3618            }
3619        }
3620        CamelError::UnsupportedMediaType { consumed, declared } => {
3621            tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3622            json_error_reply(
3623                415,
3624                "unsupported_media_type",
3625                format!("consumed {consumed}, declared {declared}"),
3626            )
3627        }
3628        CamelError::NotAcceptable { accept, produced } => {
3629            tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3630            json_error_reply(
3631                406,
3632                "not_acceptable",
3633                format!("accept {accept}, produced {produced}"),
3634            )
3635        }
3636        e => {
3637            // log-policy: handler-owned
3638            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3639            HttpReply {
3640                status: 500,
3641                headers: vec![],
3642                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3643            }
3644        }
3645    }
3646}
3647
3648/// Build a JSON error reply with the given status, error code, and message.
3649///
3650/// Shared by the `TypeConversionFailed`/`ValidationError` (400),
3651/// `UnsupportedMediaType` (415), and `NotAcceptable` (406) arms of
3652/// `pipeline_error_to_reply` so the four replies cannot drift apart. The
3653/// `unwrap_or_else(|_| "{}".to_string())` fallback keeps the reply valid
3654/// JSON even if serialization fails.
3655fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3656    let body = serde_json::to_string(&serde_json::json!({
3657        "error": code,
3658        "message": message,
3659    }))
3660    .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3661    HttpReply {
3662        status,
3663        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3664        body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3665    }
3666}
3667
3668/// Lowercase kind name for a JSON value, used in drop diagnostics so log
3669/// readers see *why* a header had no scalar string form without the value
3670/// itself ever entering diagnostics.
3671const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3672    match v {
3673        serde_json::Value::Null => "null",
3674        serde_json::Value::Bool(_) => "bool",
3675        serde_json::Value::Number(_) => "number",
3676        serde_json::Value::String(_) => "string",
3677        serde_json::Value::Array(_) => "array",
3678        serde_json::Value::Object(_) => "object",
3679    }
3680}
3681
3682/// Scalar string form of a JSON value: strings pass through, `Number` and
3683/// `Bool` are stringified, everything else has no single-value form.
3684/// Shared by the consumer reply finaliser and the producer outbound filter
3685/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
3686fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3687    match v {
3688        serde_json::Value::String(s) => Some(s.clone()),
3689        serde_json::Value::Number(n) => Some(n.to_string()),
3690        serde_json::Value::Bool(b) => Some(b.to_string()),
3691        _ => None,
3692    }
3693}
3694
3695/// Select the HTTP response headers emitted by the consumer reply finaliser
3696/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
3697/// `dispatch_handler` for unit testability.
3698///
3699/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
3700/// server-owned headers, plus `content-length`/`content-type` (re-derived),
3701/// and any header named by a `Connection` token. Scalar non-string values
3702/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3703/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
3704/// and arrays have no single-value form and are dropped. Every drop is
3705/// logged at DEBUG with the header name and reason — names only, never
3706/// values, so credentials cannot leak into diagnostics (ADR-0051).
3707/// Appends a single `Content-Type` from `user_content_type` falling back to
3708/// `inferred_content_type` when either is present.
3709fn select_response_headers(
3710    headers: &HashMap<String, serde_json::Value>,
3711    user_content_type: Option<String>,
3712    inferred_content_type: Option<String>,
3713) -> Vec<(String, String)> {
3714    let conn_tokens = header_policy::connection_tokens(
3715        headers
3716            .iter()
3717            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3718            .filter_map(|(_, v)| v.as_str()),
3719    );
3720    let mut selected: Vec<(String, String)> = Vec::new();
3721    for (k, v) in headers {
3722        if k.starts_with("Camel") {
3723            debug!(header = %k, "reply header dropped: Camel namespace");
3724            continue;
3725        }
3726        if header_policy::excluded_response(k, &conn_tokens) {
3727            debug!(header = %k, "reply header dropped: emission policy");
3728            continue;
3729        }
3730        match scalar_string_form(v) {
3731            Some(s) => selected.push((k.clone(), s)),
3732            None => debug!(
3733                header = %k,
3734                value_kind = json_value_kind(v),
3735                "reply header dropped: no scalar string form"
3736            ),
3737        }
3738    }
3739    if let Some(ct) = user_content_type.or(inferred_content_type) {
3740        selected.push(("Content-Type".to_string(), ct));
3741    }
3742    selected
3743}
3744
3745/// One outbound header drop: the exchange header name, a stable reason
3746/// string, and — when the drop was caused by the value having no scalar
3747/// string form — the JSON value kind. Names and kinds only, never values
3748/// (ADR-0051).
3749#[derive(Debug)]
3750struct OutboundHeaderDrop<'a> {
3751    name: &'a str,
3752    reason: &'static str,
3753    value_kind: Option<&'static str>,
3754}
3755
3756/// Outbound exchange-header selection result: headers accepted for the
3757/// wire plus drop records for call-site DEBUG logging.
3758struct OutboundHeaderSelection<'a> {
3759    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3760    drops: Vec<OutboundHeaderDrop<'a>>,
3761}
3762
3763/// Select the exchange headers the HTTP producer forwards on the outbound
3764/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
3765/// `HttpProducer::call` for unit testability.
3766///
3767/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
3768/// hop-by-hop/framing and connection-token-named headers excluded by the
3769/// outbound emission policy, and headers whose name or stringified value
3770/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
3771/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3772/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
3773/// and arrays have no single-value form and are dropped. Drops are returned
3774/// rather than logged so the call site can attach the correlation id; log
3775/// consumers see names and kinds only, never values (ADR-0051).
3776fn select_outbound_headers<'a>(
3777    headers: &'a HashMap<String, serde_json::Value>,
3778    skip_request_headers: &[String],
3779    conn_tokens: &[String],
3780) -> OutboundHeaderSelection<'a> {
3781    let mut accepted = Vec::new();
3782    let mut drops = Vec::new();
3783    for (key, value) in headers {
3784        if key.starts_with("Camel") {
3785            drops.push(OutboundHeaderDrop {
3786                name: key,
3787                reason: "Camel namespace",
3788                value_kind: None,
3789            });
3790            continue;
3791        }
3792        if skip_request_headers
3793            .iter()
3794            .any(|h| h.eq_ignore_ascii_case(key))
3795        {
3796            drops.push(OutboundHeaderDrop {
3797                name: key,
3798                reason: "skip_request_headers",
3799                value_kind: None,
3800            });
3801            continue;
3802        }
3803        if header_policy::excluded_outbound(key, conn_tokens) {
3804            drops.push(OutboundHeaderDrop {
3805                name: key,
3806                reason: "outbound emission policy",
3807                value_kind: None,
3808            });
3809            continue;
3810        }
3811        let Some(val_str) = scalar_string_form(value) else {
3812            drops.push(OutboundHeaderDrop {
3813                name: key,
3814                reason: "no scalar string form",
3815                value_kind: Some(json_value_kind(value)),
3816            });
3817            continue;
3818        };
3819        match constructed_header(key, &val_str) {
3820            Ok((name, val)) => accepted.push((name, val)),
3821            Err(drop) => drops.push(drop),
3822        }
3823    }
3824    OutboundHeaderSelection { accepted, drops }
3825}
3826
3827/// Construct a wire-ready `(HeaderName, HeaderValue)` pair for one outbound
3828/// header, or a drop record when the name or value fails construction
3829/// (rc-jbs1v). Drop records carry name and reason only, never values
3830/// (ADR-0051).
3831fn constructed_header<'a>(
3832    name: &'a str,
3833    value: &str,
3834) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3835    let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3836        Ok(header_name) => header_name,
3837        Err(_) => {
3838            return Err(OutboundHeaderDrop {
3839                name,
3840                reason: "invalid header name",
3841                value_kind: None,
3842            });
3843        }
3844    };
3845    let header_value = match reqwest::header::HeaderValue::from_str(value) {
3846        Ok(header_value) => header_value,
3847        Err(_) => {
3848            return Err(OutboundHeaderDrop {
3849                name,
3850                reason: "invalid header value",
3851                value_kind: None,
3852            });
3853        }
3854    };
3855    Ok((header_name, header_value))
3856}
3857
3858#[cfg(test)]
3859mod tests {
3860    use camel_component_api::test_support::NoopRuntimeObservability;
3861
3862    // Producer/consumer tests drive the component-ops facade on every
3863    // call (dashboard-observability 4.3), so even non-observability tests
3864    // must supply a collector-returning runtime — Noop everywhere.
3865    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3866        std::sync::Arc::new(NoopRuntimeObservability)
3867    }
3868    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3869        std::sync::Arc::new(NoopRuntimeObservability)
3870    }
3871    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3872        std::sync::Arc::new(NoopRuntimeObservability)
3873    }
3874
3875    use super::*;
3876    use crate::config::TlsConfig;
3877    use crate::rest_match::PathSegment;
3878    use camel_component_api::{Message, NoOpComponentContext};
3879    use std::sync::Arc;
3880    use std::time::Duration;
3881
3882    fn test_producer_ctx() -> ProducerContext {
3883        ProducerContext::new()
3884    }
3885
3886    // -----------------------------------------------------------------------
3887    // Security: credential redaction (audit 2026-08-31, finding F3-1)
3888    // -----------------------------------------------------------------------
3889
3890    /// ADR-0076: bare `host` log fields route through the canonical
3891    /// [`camel_api::redact::redact_host`] (bd rc-8bxeo promoted the
3892    /// crate-local twin — `redact_url_for_diagnostics` never opens an
3893    /// authority window on a base-less string). Thin local pin — the
3894    /// full matrix lives in camel-api's
3895    /// `redact_host_masks_userinfo_keeps_clean_hosts`.
3896    #[test]
3897    fn canonical_redact_host_pinned() {
3898        assert_eq!(
3899            camel_api::redact::redact_host("host.example:8080"),
3900            "host.example:8080"
3901        );
3902        assert_eq!(camel_api::redact::redact_host("a@b@c"), "***@c");
3903    }
3904
3905    #[test]
3906    fn redact_url_drops_oauth2_fragment_access_token() {
3907        let redacted =
3908            redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
3909        assert!(
3910            !redacted.contains("SECRET"),
3911            "fragment access token leaked: {redacted}"
3912        );
3913        assert!(
3914            !redacted.contains("access_token"),
3915            "fragment key leaked: {redacted}"
3916        );
3917        assert!(
3918            redacted.ends_with("#[redacted]"),
3919            "fragment must be replaced with the sentinel: {redacted}"
3920        );
3921    }
3922
3923    #[test]
3924    fn redact_url_drops_oauth2_fragment_id_token() {
3925        let redacted =
3926            redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
3927        assert!(
3928            !redacted.contains("eyJhbG"),
3929            "id token payload leaked: {redacted}"
3930        );
3931        assert!(
3932            !redacted.contains("id_token"),
3933            "id token key leaked: {redacted}"
3934        );
3935        assert!(
3936            !redacted.contains("SECRET"),
3937            "id token signature leaked: {redacted}"
3938        );
3939        assert!(
3940            redacted.ends_with("#[redacted]"),
3941            "fragment must be replaced with the sentinel: {redacted}"
3942        );
3943    }
3944
3945    #[test]
3946    fn redact_url_drops_generic_fragment_kv() {
3947        let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
3948        assert!(
3949            !redacted.contains("abc123"),
3950            "fragment value leaked: {redacted}"
3951        );
3952        assert!(
3953            !redacted.contains("session="),
3954            "fragment key leaked: {redacted}"
3955        );
3956        assert!(
3957            redacted.contains("#[redacted]"),
3958            "fragment must be replaced with the sentinel: {redacted}"
3959        );
3960    }
3961
3962    #[test]
3963    fn redact_url_query_and_fragment_sentinels_compose() {
3964        let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
3965        assert_eq!(
3966            redacted, "https://h.example/p?[redacted]#[redacted]",
3967            "query and fragment sentinels must compose: {redacted}"
3968        );
3969    }
3970
3971    #[test]
3972    fn redact_url_drops_benign_fragment_too() {
3973        // Fragments never reach the wire, so nothing in them is diagnostic:
3974        // strictest-wins drops benign fragments too.
3975        let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
3976        assert_eq!(
3977            redacted, "https://h.example/docs#[redacted]",
3978            "benign fragment must still be dropped: {redacted}"
3979        );
3980    }
3981
3982    #[test]
3983    fn redact_url_unparseable_fragment_credentials_dropped() {
3984        let raw = "ht tps://app.example/cb#access_token=SECRET";
3985        assert!(
3986            url::Url::parse(raw).is_err(),
3987            "fixture must be unparseable: {raw}"
3988        );
3989        let redacted = redact_url_for_diagnostics(raw);
3990        assert!(
3991            !redacted.contains("SECRET"),
3992            "unparseable fragment token leaked: {redacted}"
3993        );
3994        assert!(
3995            !redacted.contains("access_token"),
3996            "unparseable fragment bytes leaked: {redacted}"
3997        );
3998        assert!(
3999            redacted.contains("#[redacted]"),
4000            "unparseable fragment must end in the sentinel: {redacted}"
4001        );
4002    }
4003
4004    #[test]
4005    fn redact_url_double_slash_evader_sentinel() {
4006        // url::Url::parse accepts this (empty host allowed for non-special
4007        // schemes), parking userinfo-shaped bytes in the opaque path.
4008        let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
4009        assert_eq!(
4010            redacted, "[redacted]",
4011            "double-slash evader must fail closed: {redacted}"
4012        );
4013    }
4014
4015    #[test]
4016    fn redact_url_triple_slash_evader_sentinel() {
4017        let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
4018        assert_eq!(
4019            redacted, "[redacted]",
4020            "triple-slash evader must fail closed: {redacted}"
4021        );
4022    }
4023
4024    #[test]
4025    fn redact_url_bare_protocol_relative_userinfo_sentinel() {
4026        let redacted = redact_url_for_diagnostics("//user:pass@evil");
4027        assert_eq!(
4028            redacted, "[redacted]",
4029            "protocol-relative userinfo must fail closed: {redacted}"
4030        );
4031    }
4032
4033    #[test]
4034    fn redact_url_empty_host_userinfo_sentinel() {
4035        // url::Url::parse rejects this with EmptyHost; the failure arm must
4036        // fail closed without panicking on the empty host.
4037        let redacted = redact_url_for_diagnostics("scheme://user@");
4038        assert_eq!(
4039            redacted, "[redacted]",
4040            "empty-host userinfo must fail closed: {redacted}"
4041        );
4042    }
4043
4044    #[test]
4045    fn redact_url_unparseable_slash_run_evader_sentinel() {
4046        // Unlike `scheme:////user:pass@evil/` (parses Ok, host=None, and
4047        // hits the parsed-arm guard), the space in the scheme forces the
4048        // parse to fail, driving the failure arm's slash-run skip directly.
4049        let raw = "schem e:////user:pass@evil/";
4050        assert!(
4051            url::Url::parse(raw).is_err(),
4052            "fixture must be unparseable: {raw}"
4053        );
4054        let redacted = redact_url_for_diagnostics(raw);
4055        assert_eq!(
4056            redacted, "[redacted]",
4057            "unparseable slash-run evader must fail closed: {redacted}"
4058        );
4059    }
4060
4061    #[test]
4062    fn redact_url_unparseable_later_window_userinfo_sentinel() {
4063        // The first `//` window ("ho st") carries no `@`, but a later
4064        // `//user:pass@evil/` window does. The scan must consider every
4065        // `//` window, not just the first, or the credentials echo.
4066        let raw = "http://ho st/a//user:pass@evil/";
4067        assert!(
4068            url::Url::parse(raw).is_err(),
4069            "fixture must be unparseable: {raw}"
4070        );
4071        let redacted = redact_url_for_diagnostics(raw);
4072        assert_eq!(
4073            redacted, "[redacted]",
4074            "userinfo in a later // window must fail closed: {redacted}"
4075        );
4076    }
4077
4078    #[test]
4079    fn redact_url_parsed_later_window_userinfo_masked() {
4080        // rust-url accepts this with host `h` and parks the userinfo bytes
4081        // in the path, so the accessor mask never fires. The parsed arm
4082        // must apply the same window-masking surgery as the string-based
4083        // redactors or the later window renders verbatim.
4084        let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4085        assert!(
4086            !redacted.contains("user:pass"),
4087            "parsed later-window userinfo leaked: {redacted}"
4088        );
4089        assert!(
4090            redacted.contains("h//***@evil/"),
4091            "later window must be masked in place: {redacted}"
4092        );
4093    }
4094
4095    #[test]
4096    fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4097        // Real userinfo is masked by the accessor step; the window surgery
4098        // on the rendered string must not double-mask it (`***@h` stays),
4099        // and the later `x@y` path window must still be masked.
4100        let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4101        assert!(
4102            redacted.contains("***@h"),
4103            "accessor mask must survive the window surgery: {redacted}"
4104        );
4105        assert!(
4106            !redacted.contains("user:pass"),
4107            "real userinfo leaked: {redacted}"
4108        );
4109        assert!(
4110            !redacted.contains("x@y"),
4111            "later path window leaked: {redacted}"
4112        );
4113    }
4114
4115    #[test]
4116    fn redact_url_backslash_authority_ruling() {
4117        // Probe outcome: url::Url::parse accepts this input. http is a
4118        // special scheme, so backslashes normalize to slashes and the
4119        // credentials land in real userinfo
4120        // (`http://user:pass@evil/path`). The parsed arm must mask them
4121        // like any other userinfo.
4122        let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4123        assert!(
4124            redacted.contains("***@"),
4125            "backslash authority must be userinfo-masked: {redacted}"
4126        );
4127        assert!(
4128            !redacted.contains("user:pass"),
4129            "backslash authority must not leak credentials: {redacted}"
4130        );
4131    }
4132
4133    #[test]
4134    fn non_special_backslash_authority_masked() {
4135        // Non-special scheme: the url crate does not normalize the
4136        // backslashes, so the string carries no `//` run — the
4137        // scheme-prefixed backslash window must still suppress the
4138        // credentials.
4139        let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4140        assert!(
4141            !redacted.contains("user:pass"),
4142            "non-special backslash authority leaked: {redacted}"
4143        );
4144        assert!(
4145            !redacted.contains("pass"),
4146            "non-special backslash authority leaked a credential byte: {redacted}"
4147        );
4148        // Clean sibling stays visible (spec scenario's second given).
4149        assert_eq!(
4150            redact_url_for_diagnostics("foo:\\clean/path"),
4151            "foo:\\clean/path"
4152        );
4153    }
4154
4155    #[test]
4156    fn one_char_scheme_credential_content_masked() {
4157        // Single backslash after the one-character scheme `x:` with
4158        // credential-shaped window content (`:` before the last `@`).
4159        let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4160        assert!(
4161            !redacted.contains("user:pass"),
4162            "one-char-scheme backslash authority leaked: {redacted}"
4163        );
4164        assert!(
4165            !redacted.contains("pass"),
4166            "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4167        );
4168    }
4169
4170    #[test]
4171    fn drive_and_unc_inputs_stay_visible() {
4172        // Drive path: single backslash after a one-character scheme, no
4173        // `:` in the candidate window — no qualifying backslash window.
4174        // The parse-success arm lowercases the scheme (`C:` → `c:`); the
4175        // diagnostic content must stay visible with no sentinel and no
4176        // mask (spec scenario: query-redaction/cap rules only).
4177        let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4178        assert!(
4179            !drive.contains("[redacted]"),
4180            "drive path must not be sentineled: {drive}"
4181        );
4182        assert!(
4183            !drive.contains("***"),
4184            "drive path must not be masked: {drive}"
4185        );
4186        assert!(
4187            drive.contains("x@corp"),
4188            "drive path keeps its at-sign content visible: {drive}"
4189        );
4190        // UNC path: no scheme prefix before the backslash run; the
4191        // unparseable arm renders it byte-identically.
4192        let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4193        assert_eq!(unc, "\\\\server\\x@y");
4194        assert!(
4195            !unc.contains("[redacted]"),
4196            "UNC path must not be sentineled: {unc}"
4197        );
4198    }
4199
4200    #[test]
4201    fn redact_url_masks_userinfo_and_query() {
4202        let redacted =
4203            redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4204        assert!(
4205            !redacted.contains("secretpass"),
4206            "password must be masked: {redacted}"
4207        );
4208        assert!(
4209            !redacted.contains("token=abc123"),
4210            "query must be masked: {redacted}"
4211        );
4212        assert!(
4213            !redacted.contains("user@"),
4214            "username must be masked: {redacted}"
4215        );
4216        assert!(
4217            redacted.contains("internal.example"),
4218            "host stays visible: {redacted}"
4219        );
4220        assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4221    }
4222
4223    #[test]
4224    fn redact_url_keeps_clean_urls_visible() {
4225        let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4226        assert_eq!(redacted, "https://api.example.com/v1/items");
4227    }
4228
4229    #[test]
4230    fn redact_url_masks_password_only_userinfo() {
4231        let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4232        assert!(
4233            !redacted.contains("pwsecret"),
4234            "password-only userinfo leaked: {redacted}"
4235        );
4236        assert_eq!(redacted, "http://***@host.example/");
4237
4238        let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4239        assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4240        assert_eq!(redacted, "http://***@host.example/api");
4241
4242        let redacted = redact_url_for_diagnostics("http://host.example/api");
4243        assert_eq!(redacted, "http://host.example/api");
4244    }
4245
4246    #[test]
4247    fn redact_url_truncates_unparseable() {
4248        let long = "x".repeat(1000);
4249        let redacted = redact_url_for_diagnostics(&long);
4250        assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4251    }
4252
4253    /// e_gpt stage-4: the 256-byte cap must not split an appended sentinel.
4254    /// The base is truncated at `256 - sentinel_len` BEFORE the sentinel is
4255    /// appended, so the sentinel always renders intact and the total stays
4256    /// ≤ 256. Both arms (parsed and unparseable) are exercised.
4257    #[test]
4258    fn redact_url_keeps_sentinels_intact_under_256_cap() {
4259        // Parsed arm: base (scheme+host+path) is 250 bytes, so byte 256
4260        // lands inside the appended `?[redacted]` (starts at 250) pre-fix.
4261        let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4262        assert!(
4263            url::Url::parse(&parsed).is_ok(),
4264            "fixture must parse: {parsed}"
4265        );
4266        let redacted = redact_url_for_diagnostics(&parsed);
4267        assert!(redacted.len() <= 256, "len={}", redacted.len());
4268        assert!(
4269            redacted.ends_with("?[redacted]"),
4270            "parsed-arm sentinel must render intact: {redacted}"
4271        );
4272
4273        // Unparseable arm: base is 249 bytes, so byte 256 lands inside the
4274        // appended `?[redacted]` (starts at 249) pre-fix.
4275        let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4276        assert!(
4277            url::Url::parse(&unparseable).is_err(),
4278            "fixture must not parse: {unparseable}"
4279        );
4280        let redacted = redact_url_for_diagnostics(&unparseable);
4281        assert!(redacted.len() <= 256, "len={}", redacted.len());
4282        assert!(
4283            redacted.ends_with("?[redacted]"),
4284            "unparseable-arm sentinel must render intact: {redacted}"
4285        );
4286    }
4287
4288    #[test]
4289    fn redact_url_suppresses_unparseable_authority_credentials() {
4290        let fixtures = [
4291            "http://u:secretpw@/x",
4292            "http://u:secretpw@host:99999/x",
4293            "http://u:secretpw@host:99999",
4294            "//u:secretpw@h/x",
4295        ];
4296        for fixture in fixtures {
4297            assert!(
4298                url::Url::parse(fixture).is_err(),
4299                "fixture must be unparseable: {fixture}"
4300            );
4301            let redacted = redact_url_for_diagnostics(fixture);
4302            assert_eq!(
4303                redacted, "[redacted]",
4304                "credential-bearing authority must be suppressed: {fixture}"
4305            );
4306        }
4307    }
4308
4309    #[test]
4310    fn redact_url_bd_repro_never_leaks_credentials() {
4311        let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4312        assert!(
4313            !redacted.contains("user:pa%ss"),
4314            "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4315        );
4316        assert!(
4317            !redacted.contains("pa%ss"),
4318            "bd rc-2i5c5 repro leaked password: {redacted}"
4319        );
4320    }
4321
4322    #[test]
4323    fn redact_url_unparseable_query_redacted_short_and_long() {
4324        let short = "http://host:99999/path?token=shortsecret";
4325        assert!(
4326            url::Url::parse(short).is_err(),
4327            "fixture must be unparseable: {short}"
4328        );
4329        let redacted = redact_url_for_diagnostics(short);
4330        assert_eq!(
4331            redacted, "http://host:99999/path?[redacted]",
4332            "short unparseable query must end with the suffix: {redacted}"
4333        );
4334
4335        let mut long = String::from("http://host:99999/");
4336        long.push_str(&"a".repeat(300));
4337        long.push_str("?token=longsecret");
4338        assert!(
4339            url::Url::parse(&long).is_err(),
4340            "fixture must be unparseable: {long}"
4341        );
4342        let redacted = redact_url_for_diagnostics(&long);
4343        assert!(
4344            !redacted.contains("longsecret"),
4345            "long unparseable query leaked a query byte: {redacted}"
4346        );
4347        assert!(
4348            redacted.len() <= 256,
4349            "long unparseable query must be capped: {} bytes",
4350            redacted.len()
4351        );
4352    }
4353
4354    #[test]
4355    fn redact_url_unparseable_sentinels_compose_both() {
4356        // Compose-both rule: one sentinel per distinct introducer found in
4357        // the raw string, in first-occurrence order.
4358        let raw = "ht tp://h.example/p?a=1#tok=x";
4359        assert!(
4360            url::Url::parse(raw).is_err(),
4361            "fixture must be unparseable: {raw}"
4362        );
4363        assert_eq!(
4364            redact_url_for_diagnostics(raw),
4365            "ht tp://h.example/p?[redacted]#[redacted]",
4366            "query and fragment sentinels must compose: {raw}"
4367        );
4368    }
4369
4370    #[test]
4371    fn redact_url_unparseable_sentinels_compose_fragment_first() {
4372        let raw = "ht tp://h.example/p#tok=x?a=1";
4373        assert!(
4374            url::Url::parse(raw).is_err(),
4375            "fixture must be unparseable: {raw}"
4376        );
4377        assert_eq!(
4378            redact_url_for_diagnostics(raw),
4379            "ht tp://h.example/p#[redacted]?[redacted]",
4380            "sentinels must follow the introducers' first-occurrence order: {raw}"
4381        );
4382    }
4383
4384    #[test]
4385    fn redact_url_unparseable_utf8_straddle_no_panic() {
4386        let fixture = format!("a{}", "é".repeat(200));
4387        let redacted = redact_url_for_diagnostics(&fixture);
4388        assert!(
4389            redacted.len() <= 256,
4390            "straddle fixture must be capped: {} bytes",
4391            redacted.len()
4392        );
4393        assert!(
4394            redacted.len() >= 253,
4395            "straddle fixture must not over-truncate: {} bytes",
4396            redacted.len()
4397        );
4398        assert!(
4399            fixture.is_char_boundary(redacted.len()),
4400            "cut must land on a UTF-8 char boundary: {} bytes",
4401            redacted.len()
4402        );
4403    }
4404
4405    #[test]
4406    fn redact_url_at_sign_outside_authority_window_visible() {
4407        let at_sign_in_path = "http://host:99999/x@y";
4408        assert!(
4409            url::Url::parse(at_sign_in_path).is_err(),
4410            "fixture must be unparseable: {at_sign_in_path}"
4411        );
4412        assert_eq!(
4413            redact_url_for_diagnostics(at_sign_in_path),
4414            at_sign_in_path,
4415            "at-sign in path must not be suppressed"
4416        );
4417        // mailto parses as a cannot-be-a-base URL (no is_err precondition).
4418        assert_eq!(
4419            redact_url_for_diagnostics("mailto:user@example.com"),
4420            "mailto:user@example.com",
4421            "at-sign in mailto must round-trip byte-identically"
4422        );
4423    }
4424
4425    #[test]
4426    fn parse_success_fragment_composes() {
4427        // Parsed arm: the fragment stays on the rendered URL and the
4428        // canonical redactor drops it and appends the sentinel.
4429        assert_eq!(
4430            redact_url_for_diagnostics("https://h/p#access_token=x"),
4431            "https://h/p#[redacted]"
4432        );
4433        // A `?` inside the fragment composes both sentinels, in
4434        // first-occurrence order (# before ?).
4435        assert_eq!(
4436            redact_url_for_diagnostics("https://h/cb#f?state=x"),
4437            "https://h/cb#[redacted]?[redacted]"
4438        );
4439    }
4440
4441    #[test]
4442    fn err_arm_delegation_pin() {
4443        // Unparseable (port 99999) with userinfo in the authority window:
4444        // the Err arm delegates wholesale to the fail-closed canonical
4445        // redactor — nothing of the URL is rendered.
4446        assert_eq!(
4447            redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
4448            "[redacted]"
4449        );
4450        // Cross-surface fixture: same unparseable port without userinfo —
4451        // drop at `?`, append the query sentinel.
4452        assert_eq!(
4453            redact_url_for_diagnostics("http://h:99999/p?token=secret"),
4454            "http://h:99999/p?[redacted]"
4455        );
4456    }
4457
4458    #[test]
4459    fn truncate_error_body_caps_attacker_body() {
4460        let big = vec![b'A'; 10 * 1024 * 1024];
4461        let truncated = truncate_error_body(&big);
4462        assert!(
4463            truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
4464            "body must be capped near {} bytes, got {}",
4465            MAX_ERROR_RESPONSE_BODY_BYTES,
4466            truncated.len()
4467        );
4468        assert!(truncated.ends_with("...[truncated]"));
4469    }
4470
4471    #[test]
4472    fn truncate_error_body_keeps_small_body() {
4473        assert_eq!(truncate_error_body(b"boom"), "boom");
4474    }
4475
4476    #[test]
4477    fn test_http_config_defaults() {
4478        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
4479        assert_eq!(config.base_url, "http://localhost:8080/api");
4480        assert!(config.http_method.is_none());
4481        assert!(config.throw_exception_on_failure);
4482        assert_eq!(config.ok_status_code_range, (200, 299));
4483        assert!(config.response_timeout.is_none());
4484        assert!(matches!(config.auth, HttpAuth::None));
4485        assert!(!config.bridge_endpoint);
4486        assert!(!config.connection_close);
4487    }
4488
4489    #[test]
4490    fn test_http_config_scheme() {
4491        // UriConfig trait method returns "http" as primary scheme
4492        assert_eq!(HttpEndpointConfig::scheme(), "http");
4493    }
4494
4495    #[test]
4496    fn test_http_config_from_components() {
4497        // Test from_components directly (trait method)
4498        let components = camel_component_api::UriComponents {
4499            scheme: "https".to_string(),
4500            path: "//api.example.com/v1".to_string(),
4501            params: std::collections::HashMap::from([(
4502                "httpMethod".to_string(),
4503                "POST".to_string(),
4504            )]),
4505            raw_query: None,
4506        };
4507        let config = HttpEndpointConfig::from_components(components).unwrap();
4508        assert_eq!(config.base_url, "https://api.example.com/v1");
4509        assert_eq!(config.http_method, Some("POST".to_string()));
4510    }
4511
4512    #[test]
4513    fn test_http_config_with_options() {
4514        let config = HttpEndpointConfig::from_uri(
4515            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
4516        ).unwrap();
4517        assert_eq!(config.base_url, "https://api.example.com/v1");
4518        assert_eq!(config.http_method, Some("PUT".to_string()));
4519        assert!(!config.throw_exception_on_failure);
4520        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
4521    }
4522
4523    #[test]
4524    fn test_http_endpoint_config_auth_and_headers_options() {
4525        let config = HttpEndpointConfig::from_uri(
4526            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
4527        )
4528        .unwrap();
4529
4530        assert!(matches!(
4531            config.auth,
4532            HttpAuth::Basic { username, password } if username == "u" && password == "p"
4533        ));
4534        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
4535        assert!(config.bridge_endpoint);
4536        assert!(config.connection_close);
4537        assert_eq!(
4538            config.skip_request_headers,
4539            vec!["authorization".to_string(), "x-secret".to_string()]
4540        );
4541        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
4542    }
4543
4544    #[test]
4545    fn test_http_endpoint_config_bearer_auth() {
4546        let config = HttpEndpointConfig::from_uri(
4547            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
4548        )
4549        .unwrap();
4550        assert!(matches!(
4551            config.auth,
4552            HttpAuth::Bearer { token } if token == "t"
4553        ));
4554    }
4555
4556    #[test]
4557    fn rejects_cookie_handling_inmemory() {
4558        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
4559        match result {
4560            Err(CamelError::InvalidUri(msg)) => {
4561                assert!(
4562                    msg.contains("cookieHandling is not supported"),
4563                    "expected rejection message, got: {msg}"
4564                );
4565            }
4566            other => panic!("expected InvalidUri error, got: {other:?}"),
4567        }
4568    }
4569
4570    #[test]
4571    fn rejects_cookie_handling_disabled() {
4572        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
4573        match result {
4574            Err(CamelError::InvalidUri(msg)) => {
4575                assert!(
4576                    msg.contains("cookieHandling is not supported"),
4577                    "expected rejection message, got: {msg}"
4578                );
4579            }
4580            other => panic!("expected InvalidUri error, got: {other:?}"),
4581        }
4582    }
4583
4584    #[test]
4585    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
4586        let config = HttpConfig::default()
4587            .with_response_timeout_ms(999)
4588            .with_allow_internal(true)
4589            .with_blocked_hosts(vec!["evil.com".to_string()])
4590            .with_max_body_size(12345);
4591        let endpoint =
4592            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4593        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4594        assert!(endpoint.allow_internal);
4595        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4596        assert_eq!(endpoint.max_body_size, 12345);
4597    }
4598
4599    #[test]
4600    fn test_from_uri_with_defaults_uri_overrides_config() {
4601        let config = HttpConfig::default()
4602            .with_response_timeout_ms(999)
4603            .with_allow_internal(true)
4604            .with_blocked_hosts(vec!["evil.com".to_string()])
4605            .with_max_body_size(12345);
4606        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4607            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4608            &config,
4609        )
4610        .unwrap();
4611        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4612        assert!(!endpoint.allow_internal);
4613        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4614        assert_eq!(endpoint.max_body_size, 99);
4615    }
4616
4617    #[test]
4618    fn test_http_config_ok_status_range() {
4619        let config =
4620            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4621        assert_eq!(config.ok_status_code_range, (200, 204));
4622    }
4623
4624    #[test]
4625    fn test_http_config_wrong_scheme() {
4626        let result = HttpEndpointConfig::from_uri("file:/tmp");
4627        assert!(result.is_err());
4628    }
4629
4630    #[test]
4631    fn test_http_component_scheme() {
4632        let component = HttpComponent::new();
4633        assert_eq!(component.scheme(), "http");
4634    }
4635
4636    // -----------------------------------------------------------------------
4637    // tls.strict — fail-closed knob (audit 2026-08-31 R3 / rc-ayrwk).
4638    // Default stays permissive (F2-7 warns); strict fails endpoint creation
4639    // on any CA/mTLS load failure.
4640    // -----------------------------------------------------------------------
4641
4642    #[test]
4643    fn tls_strict_defaults_false_on_deserialize() {
4644        let tls: TlsConfig = serde_json::from_value(serde_json::json!({
4645            "enabled": true
4646        }))
4647        .unwrap();
4648        assert!(!tls.strict, "absent strict must default to false");
4649    }
4650
4651    fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
4652        HttpConfig {
4653            tls: Some(TlsConfig {
4654                enabled: true,
4655                strict,
4656                ca_cert_path: ca_path.map(|p| p.to_string()),
4657                ..TlsConfig::default()
4658            }),
4659            ..HttpConfig::default()
4660        }
4661    }
4662
4663    #[test]
4664    fn strict_tls_missing_ca_fails_endpoint_creation() {
4665        let component =
4666            HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
4667        let err = component
4668            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4669            .err()
4670            .expect("strict + missing CA must fail endpoint creation");
4671        assert!(
4672            err.to_string().contains("tls.strict"),
4673            "must name the strict knob: {err}"
4674        );
4675        assert!(
4676            err.to_string().contains("unreadable"),
4677            "must name the failure class: {err}"
4678        );
4679    }
4680
4681    #[test]
4682    fn strict_tls_unparseable_ca_fails_endpoint_creation() {
4683        let path = camel_component_api::test_support::tls::write_pem_tmp(
4684            "strict-bad-ca.pem",
4685            "not a certificate",
4686        );
4687        let component =
4688            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4689        let err = component
4690            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4691            .err()
4692            .expect("strict + unparseable CA must fail endpoint creation");
4693        assert!(
4694            err.to_string()
4695                .contains("no parseable PEM CERTIFICATE section"),
4696            "must name the failure class: {err}"
4697        );
4698    }
4699
4700    #[test]
4701    fn strict_tls_der_file_rejected_not_certified() {
4702        // e_glm stage-4 finding 1: a DER-looking file (first byte 0x30 =
4703        // ASCII '0') must NOT pass strict — the rustls backend never
4704        // enforces lone-DER bundles, so certifying one would certify an
4705        // unenforced config.
4706        let path = camel_component_api::test_support::tls::write_pem_tmp(
4707            "strict-der-ca.pem",
4708            "00garbage-bytes",
4709        );
4710        let component =
4711            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4712        let err = component
4713            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4714            .err()
4715            .expect("strict + DER file must fail endpoint creation");
4716        assert!(
4717            err.to_string().contains("convert to PEM"),
4718            "must tell the operator to convert: {err}"
4719        );
4720    }
4721
4722    #[test]
4723    fn strict_tls_half_mtls_pair_rejected() {
4724        // e_glm stage-4 finding 2: cert XOR key must fail under strict,
4725        // not silently degrade to non-mTLS.
4726        let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
4727        let component = HttpComponent::with_config(cfg);
4728        let err = component
4729            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4730            .err()
4731            .expect("strict + half mTLS pair must fail endpoint creation");
4732        assert!(
4733            err.to_string().contains("BOTH"),
4734            "must name the pair requirement: {err}"
4735        );
4736    }
4737
4738    #[test]
4739    fn strict_tls_valid_material_allows_endpoint_creation() {
4740        let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
4741        let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
4742        let component =
4743            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4744        assert!(
4745            component
4746                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4747                .is_ok(),
4748            "valid CA under strict must create the endpoint"
4749        );
4750    }
4751
4752    #[test]
4753    fn permissive_missing_ca_keeps_back_compat() {
4754        // strict absent (false): the F2-7 warn-and-fallback behavior stays;
4755        // endpoint creation succeeds.
4756        let component =
4757            HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
4758        assert!(
4759            component
4760                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4761                .is_ok(),
4762            "permissive mode must keep the back-compat fallback"
4763        );
4764    }
4765
4766    fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
4767        HttpConfig {
4768            tls: Some(TlsConfig {
4769                enabled: true,
4770                strict: true,
4771                client_cert_path: cert_path.map(|p| p.to_string()),
4772                client_key_path: key_path.map(|p| p.to_string()),
4773                ..TlsConfig::default()
4774            }),
4775            ..HttpConfig::default()
4776        }
4777    }
4778
4779    #[test]
4780    fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
4781        // Key present, cert file missing: a half-readable mTLS pair must
4782        // fail creation under strict, not silently drop the identity.
4783        let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4784        let key_path =
4785            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
4786        let component = HttpComponent::with_config(strict_mtls_config(
4787            Some("/nonexistent/cert.pem"),
4788            Some(key_path.to_str().unwrap()),
4789        ));
4790        let err = component
4791            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4792            .err()
4793            .expect("strict + unreadable mTLS pair must fail endpoint creation");
4794        assert!(
4795            err.to_string().contains("tls.strict"),
4796            "must name the strict knob: {err}"
4797        );
4798        assert!(
4799            err.to_string().contains("unreadable"),
4800            "must name the failure class: {err}"
4801        );
4802    }
4803
4804    #[test]
4805    fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
4806        let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4807        let cert_path =
4808            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
4809        let key_path =
4810            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
4811        let component = HttpComponent::with_config(strict_mtls_config(
4812            Some(cert_path.to_str().unwrap()),
4813            Some(key_path.to_str().unwrap()),
4814        ));
4815        assert!(
4816            component
4817                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4818                .is_ok(),
4819            "valid mTLS pair under strict must create the endpoint"
4820        );
4821    }
4822
4823    #[test]
4824    fn test_https_component_scheme() {
4825        let component = HttpsComponent::new();
4826        assert_eq!(component.scheme(), "https");
4827    }
4828
4829    #[test]
4830    fn test_http_endpoint_creates_consumer() {
4831        let component = HttpComponent::new();
4832        let ctx = NoOpComponentContext;
4833        let endpoint = component
4834            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
4835            .unwrap();
4836        assert!(endpoint.create_consumer(rt()).is_ok());
4837    }
4838
4839    #[test]
4840    fn test_https_endpoint_creates_consumer_errors_without_tls() {
4841        let component = HttpsComponent::new();
4842        let ctx = NoOpComponentContext;
4843        let endpoint = component
4844            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
4845            .unwrap();
4846        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
4847        assert!(endpoint.create_consumer(rt()).is_err());
4848    }
4849
4850    #[test]
4851    fn test_http_endpoint_creates_producer() {
4852        let ctx = test_producer_ctx();
4853        let component = HttpComponent::new();
4854        let endpoint_ctx = NoOpComponentContext;
4855        let endpoint = component
4856            .create_endpoint("http://localhost/api", &endpoint_ctx)
4857            .unwrap();
4858        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
4859    }
4860
4861    // -----------------------------------------------------------------------
4862    // Producer tests
4863    // -----------------------------------------------------------------------
4864
4865    #[tokio::test]
4866    async fn test_producer_with_token_provider() {
4867        use camel_auth::oauth2::TokenProvider;
4868        use tower::ServiceExt;
4869
4870        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
4871            Arc::new(std::sync::Mutex::new(None));
4872        let captured_clone = Arc::clone(&captured_auth);
4873
4874        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4875        let port = listener.local_addr().unwrap().port();
4876
4877        let _handle = tokio::spawn(async move {
4878            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4879            if let Ok((mut stream, _)) = listener.accept().await {
4880                let mut buf = vec![0u8; 8192];
4881                let n = stream.read(&mut buf).await.unwrap_or(0);
4882                let request = String::from_utf8_lossy(&buf[..n]).to_string();
4883                let auth = request
4884                    .lines()
4885                    .find(|l| l.to_lowercase().starts_with("authorization:"))
4886                    .map(|l| {
4887                        l.split(':')
4888                            .nth(1)
4889                            .map(|s| s.trim().to_string())
4890                            .unwrap_or_default()
4891                    });
4892                *captured_clone.lock().unwrap() = auth;
4893                let body = r#"{"echo":"ok"}"#;
4894                let resp = format!(
4895                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4896                    body.len(),
4897                    body
4898                );
4899                let _ = stream.write_all(resp.as_bytes()).await;
4900            }
4901        });
4902
4903        #[derive(Debug)]
4904        struct StaticProvider;
4905        #[async_trait::async_trait]
4906        impl TokenProvider for StaticProvider {
4907            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
4908                Ok("injected-token".into())
4909            }
4910        }
4911
4912        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
4913        let ctx = test_producer_ctx();
4914        let component = HttpComponent::new();
4915        let endpoint_ctx = NoOpComponentContext;
4916        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
4917        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4918
4919        let exchange = Exchange::new(Message::new("hello"));
4920
4921        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
4922        let mut layered = layer.layer(producer);
4923        let result = layered.ready().await.unwrap().call(exchange).await;
4924        assert!(result.is_ok(), "producer call failed: {:?}", result);
4925
4926        tokio::time::sleep(Duration::from_millis(100)).await;
4927        let auth = captured_auth.lock().unwrap().take();
4928        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4929    }
4930
4931    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4932        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4933        let addr = listener.local_addr().unwrap();
4934        let url = format!("http://127.0.0.1:{}", addr.port());
4935
4936        let handle = tokio::spawn(async move {
4937            loop {
4938                if let Ok((mut stream, _)) = listener.accept().await {
4939                    tokio::spawn(async move {
4940                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4941                        let mut buf = vec![0u8; 4096];
4942                        let n = stream.read(&mut buf).await.unwrap_or(0);
4943                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
4944
4945                        let method = request.split_whitespace().next().unwrap_or("GET");
4946
4947                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4948                        let response = format!(
4949                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4950                            body.len(),
4951                            body
4952                        );
4953                        let _ = stream.write_all(response.as_bytes()).await;
4954                    });
4955                }
4956            }
4957        });
4958
4959        (url, handle)
4960    }
4961
4962    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4963        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4964        let addr = listener.local_addr().unwrap();
4965        let url = format!("http://127.0.0.1:{}", addr.port());
4966
4967        let handle = tokio::spawn(async move {
4968            loop {
4969                if let Ok((mut stream, _)) = listener.accept().await {
4970                    let status = status;
4971                    tokio::spawn(async move {
4972                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4973                        let mut buf = vec![0u8; 4096];
4974                        let _ = stream.read(&mut buf).await;
4975
4976                        let status_text = match status {
4977                            404 => "Not Found",
4978                            500 => "Internal Server Error",
4979                            _ => "Error",
4980                        };
4981                        let body = "error body";
4982                        let response = format!(
4983                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4984                            status,
4985                            status_text,
4986                            body.len(),
4987                            body
4988                        );
4989                        let _ = stream.write_all(response.as_bytes()).await;
4990                    });
4991                }
4992            }
4993        });
4994
4995        (url, handle)
4996    }
4997
4998    async fn start_request_capturing_server() -> (
4999        String,
5000        Arc<std::sync::Mutex<Option<String>>>,
5001        tokio::task::JoinHandle<()>,
5002    ) {
5003        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5004        let port = listener.local_addr().unwrap().port();
5005        let url = format!("http://127.0.0.1:{port}");
5006        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
5007        let captured_clone = Arc::clone(&captured);
5008        let handle = tokio::spawn(async move {
5009            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5010            if let Ok((mut stream, _)) = listener.accept().await {
5011                let mut buf = vec![0u8; 16384];
5012                let n = stream.read(&mut buf).await.unwrap_or(0);
5013                let request = String::from_utf8_lossy(&buf[..n]).to_string();
5014                if request.contains("\r\n\r\n") {
5015                    *captured_clone.lock().unwrap() = Some(request);
5016                }
5017                let body = r#"{"echo":"ok"}"#;
5018                let resp = format!(
5019                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5020                    body.len(),
5021                    body
5022                );
5023                let _ = stream.write_all(resp.as_bytes()).await;
5024            }
5025        });
5026        (url, captured, handle)
5027    }
5028
5029    #[tokio::test]
5030    async fn test_http_producer_get_request() {
5031        use tower::ServiceExt;
5032
5033        let (url, _handle) = start_test_server().await;
5034        let ctx = test_producer_ctx();
5035
5036        let component = HttpComponent::new();
5037        let endpoint_ctx = NoOpComponentContext;
5038        let endpoint = component
5039            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5040            .unwrap();
5041        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5042
5043        let exchange = Exchange::new(Message::default());
5044        let result = producer.oneshot(exchange).await.unwrap();
5045
5046        let status = result
5047            .input
5048            .header("CamelHttpResponseCode")
5049            .and_then(|v| v.as_u64())
5050            .unwrap();
5051        assert_eq!(status, 200);
5052
5053        assert!(!result.input.body.is_empty());
5054    }
5055
5056    #[tokio::test]
5057    async fn producer_excludes_host_and_framing() {
5058        use tower::ServiceExt;
5059
5060        let (url, captured, _handle) = start_request_capturing_server().await;
5061        let ctx = test_producer_ctx();
5062        let component = HttpComponent::new();
5063        let endpoint_ctx = NoOpComponentContext;
5064        let endpoint = component
5065            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5066            .unwrap();
5067        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5068
5069        let mut exchange = Exchange::new(Message::default());
5070        exchange.input.set_header("Host", "localhost");
5071        exchange.input.set_header("Content-Length", "42");
5072        exchange.input.set_header("Connection", "keep-alive");
5073        exchange.input.set_header("Upgrade", "h2c");
5074
5075        let result = producer.oneshot(exchange).await;
5076        assert!(result.is_ok(), "producer call failed: {:?}", result);
5077
5078        tokio::time::sleep(Duration::from_millis(100)).await;
5079        let request = captured
5080            .lock()
5081            .unwrap()
5082            .take()
5083            .expect("no outbound request captured");
5084        let lower = request.to_ascii_lowercase();
5085        assert!(
5086            !lower.contains("\r\nhost: localhost"),
5087            "forwarded Host: localhost must be stripped\n{request}"
5088        );
5089        assert!(
5090            !lower.contains("content-length: 42"),
5091            "exchange Content-Length must not be copied\n{request}"
5092        );
5093        assert!(
5094            !lower.lines().any(|l| l.starts_with("connection:")),
5095            "Connection header must not be forwarded\n{request}"
5096        );
5097        assert!(
5098            !lower.lines().any(|l| l.starts_with("upgrade:")),
5099            "Upgrade header must not be forwarded\n{request}"
5100        );
5101        let host_header = lower
5102            .lines()
5103            .find(|l| l.starts_with("host:"))
5104            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5105            .expect("outbound Host header must be set by reqwest");
5106        assert!(
5107            host_header.starts_with("127.0.0.1:"),
5108            "outbound Host '{host_header}' must match the capture-server address"
5109        );
5110    }
5111
5112    #[tokio::test]
5113    async fn producer_forwards_request_only_headers() {
5114        use tower::ServiceExt;
5115
5116        let (url, captured, _handle) = start_request_capturing_server().await;
5117        let ctx = test_producer_ctx();
5118        let component = HttpComponent::new();
5119        let endpoint_ctx = NoOpComponentContext;
5120        let endpoint = component
5121            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5122            .unwrap();
5123        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5124
5125        let mut exchange = Exchange::new(Message::default());
5126        exchange.input.set_header("Accept", "application/json");
5127        exchange.input.set_header("User-Agent", "myclient/1.0");
5128
5129        let result = producer.oneshot(exchange).await;
5130        assert!(result.is_ok(), "producer call failed: {:?}", result);
5131
5132        tokio::time::sleep(Duration::from_millis(100)).await;
5133        let request = captured
5134            .lock()
5135            .unwrap()
5136            .take()
5137            .expect("no outbound request captured");
5138        let lower = request.to_ascii_lowercase();
5139        assert!(
5140            lower.contains("accept: application/json"),
5141            "request-only Accept header must be forwarded\n{request}"
5142        );
5143        assert!(
5144            lower.contains("user-agent: myclient/1.0"),
5145            "request-only User-Agent header must be forwarded\n{request}"
5146        );
5147    }
5148
5149    // -----------------------------------------------------------------------
5150    // Configured-header construction failures are surfaced, never silent
5151    // (rc-jbs1v)
5152    // -----------------------------------------------------------------------
5153
5154    /// Build an endpoint whose URI parses normally but whose `user_agent`
5155    /// and `auth` are then overridden programmatically, so CRLF-bearing
5156    /// test values never pass through URI parsing.
5157    fn endpoint_with_config_overrides(
5158        base_url: &str,
5159        user_agent: Option<String>,
5160        auth: HttpAuth,
5161    ) -> HttpEndpoint {
5162        let uri = format!("{base_url}/api/test?allowInternal=true");
5163        let mut config =
5164            HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5165        config.user_agent = user_agent;
5166        config.auth = auth;
5167        HttpEndpoint {
5168            uri: uri.clone(),
5169            config,
5170            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5171            client: reqwest::Client::new(),
5172            pinned_cache: Arc::new(PinnedClientCache::new(
5173                PINNED_CLIENT_TTL,
5174                PINNED_CLIENT_MAX_ENTRIES,
5175            )),
5176            http_config: HttpConfig::default(),
5177        }
5178    }
5179
5180    /// A configured user-agent / bearer token that fails `HeaderValue`
5181    /// construction must be dropped with a DEBUG record (name + reason
5182    /// only, never the value — ADR-0051) and reach the wire absent, while
5183    /// a valid config passes through unchanged.
5184    #[tracing_test::traced_test]
5185    #[tokio::test]
5186    async fn producer_invalid_configured_headers_surfaced() {
5187        use tower::ServiceExt;
5188
5189        let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5190        let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5191        let ctx = test_producer_ctx();
5192
5193        let bad_producer = endpoint_with_config_overrides(
5194            &bad_url,
5195            Some("bad\r\nua".to_string()),
5196            HttpAuth::Bearer {
5197                token: "tok\r\nen".to_string(),
5198            },
5199        )
5200        .create_producer(rt(), &ctx)
5201        .unwrap();
5202        let ok_producer = endpoint_with_config_overrides(
5203            &ok_url,
5204            Some("httpsweep-ok/1".to_string()),
5205            HttpAuth::Bearer {
5206                token: "valid-token".to_string(),
5207            },
5208        )
5209        .create_producer(rt(), &ctx)
5210        .unwrap();
5211
5212        let bad_exchange = Exchange::new(Message::default());
5213        let ok_exchange = Exchange::new(Message::default());
5214        let bad_cid = bad_exchange.correlation_id().to_string();
5215        let ok_cid = ok_exchange.correlation_id().to_string();
5216
5217        let bad_result = bad_producer.oneshot(bad_exchange).await;
5218        assert!(
5219            bad_result.is_ok(),
5220            "invalid-config producer call failed: {bad_result:?}"
5221        );
5222        let ok_result = ok_producer.oneshot(ok_exchange).await;
5223        assert!(
5224            ok_result.is_ok(),
5225            "valid-config producer call failed: {ok_result:?}"
5226        );
5227
5228        tokio::time::sleep(Duration::from_millis(100)).await;
5229        let bad_request = bad_captured
5230            .lock()
5231            .unwrap()
5232            .take()
5233            .expect("no outbound request captured");
5234        let ok_request = ok_captured
5235            .lock()
5236            .unwrap()
5237            .take()
5238            .expect("no outbound request captured");
5239
5240        // Invalid config: neither header reaches the wire. Value-absence,
5241        // not "any UA" — reqwest may inject a default user-agent.
5242        let bad_lower = bad_request.to_ascii_lowercase();
5243        assert!(
5244            !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5245            "invalid Bearer token must not reach the wire\n{bad_request}"
5246        );
5247        assert!(
5248            !bad_request.contains("bad\r\nua"),
5249            "invalid configured user-agent must not reach the wire\n{bad_request}"
5250        );
5251
5252        logs_assert(|lines: &[&str]| {
5253            let drops: Vec<&&str> = lines
5254                .iter()
5255                .filter(|l| {
5256                    l.contains("outbound header dropped")
5257                        && l.contains(&format!("correlation_id={bad_cid}"))
5258                })
5259                .collect();
5260            if drops.len() != 2 {
5261                return Err(format!(
5262                    "expected exactly 2 drop records for {bad_cid}, found {}",
5263                    drops.len()
5264                ));
5265            }
5266            let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5267            let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5268            let reason_ok = drops
5269                .iter()
5270                .all(|l| l.contains("outbound header dropped: invalid header value"));
5271            match (has_ua, has_auth, reason_ok) {
5272                (true, true, true) => Ok(()),
5273                _ => Err(format!(
5274                    "drop records mismatched: user-agent={has_ua} \
5275                     authorization={has_auth} reason-ok={reason_ok}"
5276                )),
5277            }
5278        });
5279        logs_assert(|lines: &[&str]| {
5280            if lines
5281                .iter()
5282                .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5283            {
5284                Err("sentinel CRLF values leaked into logs".to_string())
5285            } else {
5286                Ok(())
5287            }
5288        });
5289
5290        // Valid config: both headers reach the wire exactly as configured,
5291        // with zero drop records.
5292        let ok_lower = ok_request.to_ascii_lowercase();
5293        assert!(
5294            ok_lower.contains("user-agent: httpsweep-ok/1"),
5295            "valid configured user-agent must reach the wire\n{ok_request}"
5296        );
5297        assert!(
5298            ok_lower.contains("authorization: bearer valid-token"),
5299            "valid Bearer token must reach the wire\n{ok_request}"
5300        );
5301        logs_assert(|lines: &[&str]| {
5302            let hits = lines
5303                .iter()
5304                .filter(|l| {
5305                    l.contains("outbound header dropped")
5306                        && l.contains(&format!("correlation_id={ok_cid}"))
5307                })
5308                .count();
5309            match hits {
5310                0 => Ok(()),
5311                n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5312            }
5313        });
5314    }
5315
5316    #[tokio::test]
5317    async fn producer_honours_skip_request_headers() {
5318        use tower::ServiceExt;
5319
5320        let (url, captured, _handle) = start_request_capturing_server().await;
5321        let ctx = test_producer_ctx();
5322        let component = HttpComponent::new();
5323        let endpoint_ctx = NoOpComponentContext;
5324        let endpoint = component
5325            .create_endpoint(
5326                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5327                &endpoint_ctx,
5328            )
5329            .unwrap();
5330        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5331
5332        let mut exchange = Exchange::new(Message::default());
5333        exchange.input.set_header("Authorization", "Bearer x");
5334
5335        let result = producer.oneshot(exchange).await;
5336        assert!(result.is_ok(), "producer call failed: {:?}", result);
5337
5338        tokio::time::sleep(Duration::from_millis(100)).await;
5339        let request = captured
5340            .lock()
5341            .unwrap()
5342            .take()
5343            .expect("no outbound request captured");
5344        assert!(
5345            !request.to_ascii_lowercase().contains("authorization"),
5346            "Authorization must be stripped by skipRequestHeaders\n{request}"
5347        );
5348    }
5349
5350    #[tokio::test]
5351    async fn producer_stringifies_scalar_header_values_on_wire() {
5352        use tower::ServiceExt;
5353
5354        let (url, captured, _handle) = start_request_capturing_server().await;
5355        let ctx = test_producer_ctx();
5356        let component = HttpComponent::new();
5357        let endpoint_ctx = NoOpComponentContext;
5358        let endpoint = component
5359            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5360            .unwrap();
5361        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5362
5363        let mut exchange = Exchange::new(Message::default());
5364        exchange.input.set_header("X-Retries", serde_json::json!(3));
5365        exchange
5366            .input
5367            .set_header("X-Enabled", serde_json::json!(true));
5368        exchange
5369            .input
5370            .set_header("X-Obj", serde_json::json!({"a": 1}));
5371
5372        let result = producer.oneshot(exchange).await;
5373        assert!(result.is_ok(), "producer call failed: {:?}", result);
5374
5375        tokio::time::sleep(Duration::from_millis(100)).await;
5376        let request = captured
5377            .lock()
5378            .unwrap()
5379            .take()
5380            .expect("no outbound request captured");
5381        let lower = request.to_ascii_lowercase();
5382        assert!(
5383            lower.contains("x-retries: 3"),
5384            "numeric header must reach the wire stringified\n{request}"
5385        );
5386        assert!(
5387            lower.contains("x-enabled: true"),
5388            "bool header must reach the wire stringified\n{request}"
5389        );
5390        assert!(
5391            !lower.contains("x-obj:"),
5392            "object header has no single-value form and must not reach the wire\n{request}"
5393        );
5394    }
5395
5396    #[tokio::test]
5397    async fn test_http_producer_post_with_body() {
5398        use tower::ServiceExt;
5399
5400        let (url, _handle) = start_test_server().await;
5401        let ctx = test_producer_ctx();
5402
5403        let component = HttpComponent::new();
5404        let endpoint_ctx = NoOpComponentContext;
5405        let endpoint = component
5406            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
5407            .unwrap();
5408        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5409
5410        let exchange = Exchange::new(Message::new("request body"));
5411        let result = producer.oneshot(exchange).await.unwrap();
5412
5413        let status = result
5414            .input
5415            .header("CamelHttpResponseCode")
5416            .and_then(|v| v.as_u64())
5417            .unwrap();
5418        assert_eq!(status, 200);
5419    }
5420
5421    #[tokio::test]
5422    async fn test_http_producer_method_from_header() {
5423        use tower::ServiceExt;
5424
5425        let (url, _handle) = start_test_server().await;
5426        let ctx = test_producer_ctx();
5427
5428        let component = HttpComponent::new();
5429        let endpoint_ctx = NoOpComponentContext;
5430        let endpoint = component
5431            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5432            .unwrap();
5433        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5434
5435        let mut exchange = Exchange::new(Message::default());
5436        exchange.input.set_header(
5437            "CamelHttpMethod",
5438            serde_json::Value::String("DELETE".to_string()),
5439        );
5440
5441        let result = producer.oneshot(exchange).await.unwrap();
5442        let status = result
5443            .input
5444            .header("CamelHttpResponseCode")
5445            .and_then(|v| v.as_u64())
5446            .unwrap();
5447        assert_eq!(status, 200);
5448    }
5449
5450    #[tokio::test]
5451    async fn test_http_producer_forced_method() {
5452        use tower::ServiceExt;
5453
5454        let (url, _handle) = start_test_server().await;
5455        let ctx = test_producer_ctx();
5456
5457        let component = HttpComponent::new();
5458        let endpoint_ctx = NoOpComponentContext;
5459        let endpoint = component
5460            .create_endpoint(
5461                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
5462                &endpoint_ctx,
5463            )
5464            .unwrap();
5465        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5466
5467        let exchange = Exchange::new(Message::default());
5468        let result = producer.oneshot(exchange).await.unwrap();
5469
5470        let status = result
5471            .input
5472            .header("CamelHttpResponseCode")
5473            .and_then(|v| v.as_u64())
5474            .unwrap();
5475        assert_eq!(status, 200);
5476    }
5477
5478    #[tokio::test]
5479    async fn test_http_producer_throw_exception_on_failure() {
5480        use tower::ServiceExt;
5481
5482        let (url, _handle) = start_status_server(404).await;
5483        let ctx = test_producer_ctx();
5484
5485        let component = HttpComponent::new();
5486        let endpoint_ctx = NoOpComponentContext;
5487        let endpoint = component
5488            .create_endpoint(
5489                &format!("{url}/not-found?allowInternal=true"),
5490                &endpoint_ctx,
5491            )
5492            .unwrap();
5493        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5494
5495        let exchange = Exchange::new(Message::default());
5496        let result = producer.oneshot(exchange).await;
5497        assert!(result.is_err());
5498
5499        match result.unwrap_err() {
5500            CamelError::HttpOperationFailed { status_code, .. } => {
5501                assert_eq!(status_code, 404);
5502            }
5503            e => panic!("Expected HttpOperationFailed, got: {e}"),
5504        }
5505    }
5506
5507    #[tokio::test]
5508    async fn test_http_producer_no_throw_on_failure() {
5509        use tower::ServiceExt;
5510
5511        let (url, _handle) = start_status_server(500).await;
5512        let ctx = test_producer_ctx();
5513
5514        let component = HttpComponent::new();
5515        let endpoint_ctx = NoOpComponentContext;
5516        let endpoint = component
5517            .create_endpoint(
5518                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
5519                &endpoint_ctx,
5520            )
5521            .unwrap();
5522        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5523
5524        let exchange = Exchange::new(Message::default());
5525        let result = producer.oneshot(exchange).await.unwrap();
5526
5527        let status = result
5528            .input
5529            .header("CamelHttpResponseCode")
5530            .and_then(|v| v.as_u64())
5531            .unwrap();
5532        assert_eq!(status, 500);
5533    }
5534
5535    #[tokio::test]
5536    async fn test_http_producer_uri_override() {
5537        use tower::ServiceExt;
5538
5539        let (url, _handle) = start_test_server().await;
5540        let ctx = test_producer_ctx();
5541
5542        let component = HttpComponent::new();
5543        let endpoint_ctx = NoOpComponentContext;
5544        let endpoint = component
5545            .create_endpoint(
5546                "http://localhost:1/does-not-exist?allowInternal=true",
5547                &endpoint_ctx,
5548            )
5549            .unwrap();
5550        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5551
5552        let mut exchange = Exchange::new(Message::default());
5553        exchange.input.set_header(
5554            "CamelHttpUri",
5555            serde_json::Value::String(format!("{url}/api")),
5556        );
5557
5558        let result = producer.oneshot(exchange).await.unwrap();
5559        let status = result
5560            .input
5561            .header("CamelHttpResponseCode")
5562            .and_then(|v| v.as_u64())
5563            .unwrap();
5564        assert_eq!(status, 200);
5565    }
5566
5567    #[tokio::test]
5568    async fn test_http_producer_response_headers_mapped() {
5569        use tower::ServiceExt;
5570
5571        let (url, _handle) = start_test_server().await;
5572        let ctx = test_producer_ctx();
5573
5574        let component = HttpComponent::new();
5575        let endpoint_ctx = NoOpComponentContext;
5576        let endpoint = component
5577            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5578            .unwrap();
5579        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5580
5581        let exchange = Exchange::new(Message::default());
5582        let result = producer.oneshot(exchange).await.unwrap();
5583
5584        assert!(
5585            result.input.header("Content-Type").is_some(),
5586            "Response should have Content-Type header"
5587        );
5588        assert!(result.input.header("CamelHttpResponseText").is_some());
5589    }
5590
5591    // -----------------------------------------------------------------------
5592    // Bug fix tests: Client configuration per-endpoint
5593    // -----------------------------------------------------------------------
5594
5595    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
5596        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5597        let addr = listener.local_addr().unwrap();
5598        let url = format!("http://127.0.0.1:{}", addr.port());
5599
5600        let handle = tokio::spawn(async move {
5601            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5602            loop {
5603                if let Ok((mut stream, _)) = listener.accept().await {
5604                    tokio::spawn(async move {
5605                        let mut buf = vec![0u8; 4096];
5606                        let n = stream.read(&mut buf).await.unwrap_or(0);
5607                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
5608
5609                        // Check if this is a request to /final
5610                        if request.contains("GET /final") {
5611                            let body = r#"{"status":"final"}"#;
5612                            let response = format!(
5613                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5614                                body.len(),
5615                                body
5616                            );
5617                            let _ = stream.write_all(response.as_bytes()).await;
5618                        } else {
5619                            // Redirect to /final
5620                            // Connection: close stops the client pooling the
5621                            // connection the server drops right after this
5622                            // response (pooled-race, rc-u3aw class).
5623                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5624                            let _ = stream.write_all(response.as_bytes()).await;
5625                        }
5626                    });
5627                }
5628            }
5629        });
5630
5631        (url, handle)
5632    }
5633
5634    struct CapturedRequest {
5635        method: String,
5636        path: String,
5637        body: Vec<u8>,
5638        content_length: Option<String>,
5639        transfer_encoding: Option<String>,
5640    }
5641
5642    /// Parse a request head plus its Content-Length-driven body from a freshly
5643    /// accepted connection. Returns `None` if the client closes before sending
5644    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
5645    /// keep-alive connections and never sends FIN) and does NOT rely on a
5646    /// single fixed-size read (a segmented small body would flake).
5647    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
5648        use tokio::io::AsyncReadExt;
5649
5650        // Read the request head (up to and including the terminating CRLF CRLF).
5651        let mut buf: Vec<u8> = Vec::new();
5652        let mut chunk = [0u8; 4096];
5653        let head_end: usize;
5654        loop {
5655            let n = stream.read(&mut chunk).await.unwrap_or(0);
5656            if n == 0 {
5657                return None;
5658            }
5659            buf.extend_from_slice(&chunk[..n]);
5660            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
5661                head_end = pos + 4;
5662                break;
5663            }
5664        }
5665
5666        // Parse the request head.
5667        let head = String::from_utf8_lossy(&buf[..head_end]);
5668        let mut lines = head.split("\r\n");
5669        let request_line = lines.next().unwrap_or("");
5670        let mut parts = request_line.split_whitespace();
5671        let method = parts.next().unwrap_or("").to_string();
5672        let path = parts.next().unwrap_or("").to_string();
5673
5674        let mut content_length: Option<String> = None;
5675        let mut transfer_encoding: Option<String> = None;
5676        for line in lines {
5677            if let Some((name, value)) = line.split_once(':') {
5678                let name = name.trim().to_ascii_lowercase();
5679                let value = value.trim().to_string();
5680                if name == "content-length" {
5681                    content_length = Some(value);
5682                } else if name == "transfer-encoding" {
5683                    transfer_encoding = Some(value);
5684                }
5685            }
5686        }
5687
5688        // Content-Length-driven exact read. A missing header means a 0-length body.
5689        let body_len: usize = content_length
5690            .as_deref()
5691            .and_then(|v| v.parse::<usize>().ok())
5692            .unwrap_or(0);
5693
5694        let mut body: Vec<u8> = buf[head_end..].to_vec();
5695        while body.len() < body_len {
5696            let n = stream.read(&mut chunk).await.unwrap_or(0);
5697            if n == 0 {
5698                break;
5699            }
5700            body.extend_from_slice(&chunk[..n]);
5701        }
5702        body.truncate(body_len);
5703
5704        Some(CapturedRequest {
5705            method,
5706            path,
5707            body,
5708            content_length,
5709            transfer_encoding,
5710        })
5711    }
5712
5713    /// A raw-TCP capture server. Each connection parses the request head, then
5714    /// performs a Content-Length-driven exact read of the body (see
5715    /// [`capture_request`]). Each connection is dropped after the response so
5716    /// every hop opens a fresh connection.
5717    async fn start_capture_server() -> (
5718        String,
5719        tokio::task::JoinHandle<()>,
5720        Arc<Mutex<Vec<CapturedRequest>>>,
5721    ) {
5722        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5723        let addr = listener.local_addr().unwrap();
5724        let url = format!("http://127.0.0.1:{}", addr.port());
5725
5726        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5727        let captured_for_return = Arc::clone(&captured);
5728
5729        let handle = tokio::spawn(async move {
5730            use tokio::io::AsyncWriteExt;
5731            loop {
5732                if let Ok((mut stream, _)) = listener.accept().await {
5733                    let captured = Arc::clone(&captured);
5734                    tokio::spawn(async move {
5735                        let Some(req) = capture_request(&mut stream).await else {
5736                            return;
5737                        };
5738                        captured.lock().unwrap().push(req);
5739
5740                        // 200 OK with Content-Length: 0 and no body, then drop
5741                        // the stream so the client opens a fresh connection.
5742                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
5743                        let _ = stream.write_all(response.as_bytes()).await;
5744                    });
5745                }
5746            }
5747        });
5748
5749        (url, handle, captured_for_return)
5750    }
5751
5752    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
5753    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
5754    /// whose `/final` path answers `200 OK` with an empty body. Every hop
5755    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
5756    /// the connection after responding so each hop is a fresh connection.
5757    async fn start_redirect_capture_server() -> (
5758        String,
5759        tokio::task::JoinHandle<()>,
5760        Arc<Mutex<Vec<CapturedRequest>>>,
5761    ) {
5762        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5763        let addr = listener.local_addr().unwrap();
5764        let url = format!("http://127.0.0.1:{}", addr.port());
5765
5766        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5767        let captured_for_return = Arc::clone(&captured);
5768
5769        let handle = tokio::spawn(async move {
5770            use tokio::io::AsyncWriteExt;
5771            loop {
5772                if let Ok((mut stream, _)) = listener.accept().await {
5773                    let captured = Arc::clone(&captured);
5774                    tokio::spawn(async move {
5775                        let Some(req) = capture_request(&mut stream).await else {
5776                            return;
5777                        };
5778                        let path = req.path.clone();
5779                        captured.lock().unwrap().push(req);
5780
5781                        let (status_line, location) = match path.as_str() {
5782                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5783                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5784                            "/final" => ("HTTP/1.1 200 OK", None),
5785                            _ => ("HTTP/1.1 404 Not Found", None),
5786                        };
5787
5788                        let response = match location {
5789                            // Connection: close stops the client pooling the
5790                            // connection this handler drops right after the
5791                            // response (pooled-race, rc-u3aw class).
5792                            Some(loc) => format!(
5793                                "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5794                            ),
5795                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5796                        };
5797                        let _ = stream.write_all(response.as_bytes()).await;
5798                    });
5799                }
5800            }
5801        });
5802
5803        (url, handle, captured_for_return)
5804    }
5805
5806    #[tokio::test]
5807    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5808        use tower::ServiceExt;
5809
5810        let (url, _handle, captured) = start_capture_server().await;
5811        let ctx = test_producer_ctx();
5812
5813        let component = HttpComponent::with_config(HttpConfig::default());
5814        let endpoint_ctx = NoOpComponentContext;
5815        let endpoint = component
5816            .create_endpoint(
5817                &format!("{url}?httpMethod=GET&allowInternal=true"),
5818                &endpoint_ctx,
5819            )
5820            .unwrap();
5821        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5822
5823        let mut exchange = Exchange::new(Message::default());
5824        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5825
5826        let result = producer.oneshot(exchange).await.unwrap();
5827
5828        let status = result
5829            .input
5830            .header("CamelHttpResponseCode")
5831            .and_then(|v| v.as_u64())
5832            .unwrap();
5833        assert_eq!(status, 200);
5834
5835        let captured = captured.lock().unwrap();
5836        assert_eq!(captured.len(), 1, "expected exactly one captured request");
5837        let req = &captured[0];
5838        assert_eq!(req.method, "GET");
5839        // `httpMethod`/`allowInternal` are URI options, not request-target
5840        // query params, so the origin-form target is just "/".
5841        assert_eq!(req.path, "/");
5842        assert!(req.body.is_empty(), "GET must not carry a body");
5843        assert!(
5844            req.content_length.is_none(),
5845            "suppressed request must not carry Content-Length"
5846        );
5847        assert!(
5848            req.transfer_encoding.is_none(),
5849            "suppressed request must not carry Transfer-Encoding"
5850        );
5851
5852        // The exchange body is consumed by the producer (std::mem::take).
5853        assert!(
5854            result.input.body.is_empty(),
5855            "exchange body must be consumed"
5856        );
5857    }
5858
5859    #[tokio::test]
5860    async fn test_head_with_body_suppressed_via_header() {
5861        use tower::ServiceExt;
5862
5863        let (url, _handle, captured) = start_capture_server().await;
5864        let ctx = test_producer_ctx();
5865
5866        let component = HttpComponent::with_config(HttpConfig::default());
5867        let endpoint_ctx = NoOpComponentContext;
5868        let endpoint = component
5869            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5870            .unwrap();
5871        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5872
5873        let mut exchange = Exchange::new(Message::default());
5874        exchange.input.set_header(
5875            "CamelHttpMethod",
5876            serde_json::Value::String("HEAD".to_string()),
5877        );
5878        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5879
5880        let result = producer.oneshot(exchange).await.unwrap();
5881        let status = result
5882            .input
5883            .header("CamelHttpResponseCode")
5884            .and_then(|v| v.as_u64())
5885            .unwrap();
5886        assert_eq!(status, 200);
5887
5888        let captured = captured.lock().unwrap();
5889        assert_eq!(captured.len(), 1);
5890        let req = &captured[0];
5891        assert_eq!(req.method, "HEAD");
5892        assert!(req.body.is_empty(), "HEAD must not carry a body");
5893    }
5894
5895    #[tokio::test]
5896    async fn test_delete_options_trace_with_body_suppressed() {
5897        use tower::ServiceExt;
5898
5899        let (url, _handle, captured) = start_capture_server().await;
5900        let ctx = test_producer_ctx();
5901        let component = HttpComponent::with_config(HttpConfig::default());
5902        let endpoint_ctx = NoOpComponentContext;
5903
5904        for method in ["DELETE", "OPTIONS", "TRACE"] {
5905            let endpoint = component
5906                .create_endpoint(
5907                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5908                    &endpoint_ctx,
5909                )
5910                .unwrap();
5911            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5912
5913            let mut exchange = Exchange::new(Message::default());
5914            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5915
5916            let result = producer.oneshot(exchange).await.unwrap();
5917            let status = result
5918                .input
5919                .header("CamelHttpResponseCode")
5920                .and_then(|v| v.as_u64())
5921                .unwrap();
5922            assert_eq!(status, 200, "method {method} should succeed");
5923        }
5924
5925        let captured = captured.lock().unwrap();
5926        assert_eq!(captured.len(), 3, "expected three captured requests");
5927        for method in ["DELETE", "OPTIONS", "TRACE"] {
5928            let req = captured
5929                .iter()
5930                .find(|r| r.method == method)
5931                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5932            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5933        }
5934    }
5935
5936    #[tokio::test]
5937    async fn test_post_put_patch_with_body_still_sent() {
5938        use tower::ServiceExt;
5939
5940        let (url, _handle, captured) = start_capture_server().await;
5941        let ctx = test_producer_ctx();
5942        let component = HttpComponent::with_config(HttpConfig::default());
5943        let endpoint_ctx = NoOpComponentContext;
5944
5945        for method in ["POST", "PUT", "PATCH"] {
5946            let endpoint = component
5947                .create_endpoint(
5948                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5949                    &endpoint_ctx,
5950                )
5951                .unwrap();
5952            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5953
5954            let payload = format!("body-for-{method}");
5955            let mut exchange = Exchange::new(Message::default());
5956            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5957
5958            let result = producer.oneshot(exchange).await.unwrap();
5959            let status = result
5960                .input
5961                .header("CamelHttpResponseCode")
5962                .and_then(|v| v.as_u64())
5963                .unwrap();
5964            assert_eq!(status, 200, "method {method} should succeed");
5965        }
5966
5967        let captured = captured.lock().unwrap();
5968        assert_eq!(captured.len(), 3, "expected three captured requests");
5969        for method in ["POST", "PUT", "PATCH"] {
5970            let req = captured
5971                .iter()
5972                .find(|r| r.method == method)
5973                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5974            let expected = format!("body-for-{method}");
5975            assert!(!req.body.is_empty(), "{method} must still carry its body");
5976            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5977        }
5978    }
5979
5980    /// A GET with a stream body must not attach the stream: the entity-enclosing
5981    /// gate drops the stream (mem::take) before the request is built, leaving
5982    /// the exchange body Empty instead of a partially-consumed Body::Stream.
5983    #[tokio::test]
5984    async fn test_stream_body_under_get_not_attached() {
5985        use tower::ServiceExt;
5986
5987        let (url, _handle, captured) = start_capture_server().await;
5988        let ctx = test_producer_ctx();
5989
5990        let component = HttpComponent::with_config(HttpConfig::default());
5991        let endpoint_ctx = NoOpComponentContext;
5992        let endpoint = component
5993            .create_endpoint(
5994                &format!("{url}?httpMethod=GET&allowInternal=true"),
5995                &endpoint_ctx,
5996            )
5997            .unwrap();
5998        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5999
6000        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6001            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
6002        let stream = Box::pin(futures::stream::iter(chunks));
6003        let mut exchange = Exchange::new(Message::default());
6004        exchange.input.body = Body::Stream(StreamBody {
6005            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6006            metadata: StreamMetadata::default(),
6007        });
6008
6009        let result = producer.oneshot(exchange).await.unwrap();
6010
6011        let status = result
6012            .input
6013            .header("CamelHttpResponseCode")
6014            .and_then(|v| v.as_u64())
6015            .unwrap();
6016        assert_eq!(status, 200);
6017
6018        let captured = captured.lock().unwrap();
6019        assert_eq!(captured.len(), 1, "expected exactly one captured request");
6020        assert!(
6021            captured[0].body.is_empty(),
6022            "GET must not carry a stream body"
6023        );
6024        assert!(
6025            captured[0].transfer_encoding.is_none(),
6026            "suppressed request must not carry Transfer-Encoding"
6027        );
6028        assert!(
6029            captured[0].content_length.is_none(),
6030            "suppressed request must not carry Content-Length"
6031        );
6032        assert!(
6033            result.input.body.is_empty(),
6034            "exchange body must be consumed to Empty, not left as a stream"
6035        );
6036    }
6037
6038    /// A suppressed body must never be replayed across 307/308 redirect hops:
6039    /// the gate empties `materialized_body` before the redirect loop runs, so
6040    /// neither the first hop nor the final hop carries the body.
6041    #[tokio::test]
6042    async fn test_redirect_hops_never_replay_suppressed_body() {
6043        use tower::ServiceExt;
6044
6045        let (url, _handle, captured) = start_redirect_capture_server().await;
6046        let ctx = test_producer_ctx();
6047
6048        let component =
6049            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6050        let endpoint_ctx = NoOpComponentContext;
6051
6052        for path in ["/hop307", "/hop308"] {
6053            let endpoint = component
6054                .create_endpoint(
6055                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
6056                    &endpoint_ctx,
6057                )
6058                .unwrap();
6059            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6060
6061            let mut exchange = Exchange::new(Message::default());
6062            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6063
6064            let result = producer.oneshot(exchange).await.unwrap();
6065            let status = result
6066                .input
6067                .header("CamelHttpResponseCode")
6068                .and_then(|v| v.as_u64())
6069                .unwrap();
6070            assert_eq!(
6071                status, 200,
6072                "redirect chain for {path} should end at /final"
6073            );
6074        }
6075
6076        // Two chains (307 and 308), each with two hops (redirect + final).
6077        let captured = captured.lock().unwrap();
6078        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6079        for req in captured.iter() {
6080            assert!(
6081                req.body.is_empty(),
6082                "hop {} {} must not carry a body",
6083                req.method,
6084                req.path
6085            );
6086        }
6087    }
6088
6089    /// The warn! emitted on a suppressed body renders three distinguishable
6090    /// substrings in the log line (tracing-subscriber default field format):
6091    ///   - the message:       "dropping request body ..."
6092    ///   - `method = %method_str`            → `method=GET`
6093    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
6094    /// The closure matches all three so exactly one warn per suppressed
6095    /// request is required (the "HTTP request" debug! also carries
6096    /// `method=GET` and the same `correlation_id=`, but not the message).
6097    #[tracing_test::traced_test]
6098    #[tokio::test]
6099    async fn test_suppressed_body_logs_exactly_one_warn() {
6100        use tower::ServiceExt;
6101
6102        let (url, _handle, _captured) = start_capture_server().await;
6103        let ctx = test_producer_ctx();
6104
6105        let component = HttpComponent::with_config(HttpConfig::default());
6106        let endpoint_ctx = NoOpComponentContext;
6107        let endpoint = component
6108            .create_endpoint(
6109                &format!("{url}?httpMethod=GET&allowInternal=true"),
6110                &endpoint_ctx,
6111            )
6112            .unwrap();
6113        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6114
6115        let mut exchange = Exchange::new(Message::default());
6116        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6117        let correlation_id = exchange.correlation_id().to_string();
6118
6119        let result = producer.oneshot(exchange).await.unwrap();
6120        let status = result
6121            .input
6122            .header("CamelHttpResponseCode")
6123            .and_then(|v| v.as_u64())
6124            .unwrap();
6125        assert_eq!(status, 200);
6126
6127        logs_assert(|lines: &[&str]| {
6128            let hits = lines
6129                .iter()
6130                .filter(|l| {
6131                    l.contains("dropping request body")
6132                        && l.contains("method=GET")
6133                        && l.contains(&format!("correlation_id={correlation_id}"))
6134                })
6135                .count();
6136            match hits {
6137                1 => Ok(()),
6138                n => Err(format!("expected exactly one body-drop warn, found {n}")),
6139            }
6140        });
6141    }
6142
6143    #[tracing_test::traced_test]
6144    #[tokio::test]
6145    async fn test_empty_body_get_emits_no_warn() {
6146        use tower::ServiceExt;
6147
6148        let (url, _handle, _captured) = start_capture_server().await;
6149        let ctx = test_producer_ctx();
6150
6151        let component = HttpComponent::with_config(HttpConfig::default());
6152        let endpoint_ctx = NoOpComponentContext;
6153        let endpoint = component
6154            .create_endpoint(
6155                &format!("{url}?httpMethod=GET&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.unwrap();
6163        let status = result
6164            .input
6165            .header("CamelHttpResponseCode")
6166            .and_then(|v| v.as_u64())
6167            .unwrap();
6168        assert_eq!(status, 200);
6169
6170        logs_assert(|lines: &[&str]| {
6171            let hits = lines
6172                .iter()
6173                .filter(|l| l.contains("dropping request body"))
6174                .count();
6175            match hits {
6176                0 => Ok(()),
6177                n => Err(format!("expected no body-drop warn, found {n}")),
6178            }
6179        });
6180    }
6181
6182    #[tokio::test]
6183    async fn test_follow_redirects_false_does_not_follow() {
6184        use tower::ServiceExt;
6185
6186        let (url, _handle) = start_redirect_server().await;
6187        let ctx = test_producer_ctx();
6188
6189        let component =
6190            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6191        let endpoint_ctx = NoOpComponentContext;
6192        let endpoint = component
6193            .create_endpoint(
6194                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6195                &endpoint_ctx,
6196            )
6197            .unwrap();
6198        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6199
6200        let exchange = Exchange::new(Message::default());
6201        let result = producer.oneshot(exchange).await.unwrap();
6202
6203        // Should get 302, NOT follow redirect to 200
6204        let status = result
6205            .input
6206            .header("CamelHttpResponseCode")
6207            .and_then(|v| v.as_u64())
6208            .unwrap();
6209        assert_eq!(
6210            status, 302,
6211            "Should NOT follow redirect when followRedirects=false"
6212        );
6213    }
6214
6215    #[tokio::test]
6216    async fn test_follow_redirects_true_follows_redirect() {
6217        use tower::ServiceExt;
6218
6219        let (url, _handle) = start_redirect_server().await;
6220        let ctx = test_producer_ctx();
6221
6222        let component =
6223            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6224        let endpoint_ctx = NoOpComponentContext;
6225        let endpoint = component
6226            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6227            .unwrap();
6228        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6229
6230        let exchange = Exchange::new(Message::default());
6231        let result = producer.oneshot(exchange).await.unwrap();
6232
6233        // Should follow redirect and get 200
6234        let status = result
6235            .input
6236            .header("CamelHttpResponseCode")
6237            .and_then(|v| v.as_u64())
6238            .unwrap();
6239        assert_eq!(
6240            status, 200,
6241            "Should follow redirect when followRedirects=true"
6242        );
6243    }
6244
6245    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
6246    /// This verifies the manual redirect loop executes correctly.
6247    #[tokio::test]
6248    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6249        use tower::ServiceExt;
6250
6251        // Use the existing redirect server which redirects to /final on the same server
6252        let (url, _handle) = start_redirect_server().await;
6253        let ctx = test_producer_ctx();
6254
6255        let component =
6256            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6257        let endpoint_ctx = NoOpComponentContext;
6258        let endpoint = component
6259            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6260            .unwrap();
6261        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6262
6263        let exchange = Exchange::new(Message::default());
6264        let result = producer.oneshot(exchange).await;
6265
6266        // With allowInternal=true, the redirect should succeed
6267        assert!(
6268            result.is_ok(),
6269            "Redirect should succeed with allowInternal=true, got: {:?}",
6270            result
6271        );
6272        let exchange = result.unwrap();
6273        let status = exchange
6274            .input
6275            .header("CamelHttpResponseCode")
6276            .and_then(|v| v.as_u64())
6277            .unwrap();
6278        assert_eq!(status, 200, "Should follow redirect to /final");
6279    }
6280
6281    /// With allowInternal=true, redirects to private IPs should be followed.
6282    #[tokio::test]
6283    async fn test_redirect_to_private_ip_allowed_when_configured() {
6284        use tower::ServiceExt;
6285
6286        // Start a server that redirects to /final on the same server (127.0.0.1)
6287        let (url, _handle) = start_redirect_server().await;
6288        let ctx = test_producer_ctx();
6289
6290        let component =
6291            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6292        let endpoint_ctx = NoOpComponentContext;
6293        let endpoint = component
6294            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6295            .unwrap();
6296        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6297
6298        let exchange = Exchange::new(Message::default());
6299        let result = producer.oneshot(exchange).await.unwrap();
6300
6301        let status = result
6302            .input
6303            .header("CamelHttpResponseCode")
6304            .and_then(|v| v.as_u64())
6305            .unwrap();
6306        assert_eq!(
6307            status, 200,
6308            "Should follow redirect to private IP when allowInternal=true"
6309        );
6310    }
6311
6312    /// Integration test: with allowInternal=false (default), a redirect to a
6313    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
6314    #[tokio::test]
6315    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
6316        use tower::ServiceExt;
6317
6318        // Server that redirects to the AWS metadata endpoint (link-local private IP)
6319        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6320        let addr = listener.local_addr().unwrap();
6321        let url = format!("http://127.0.0.1:{}", addr.port());
6322
6323        let handle = tokio::spawn(async move {
6324            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6325            loop {
6326                if let Ok((mut stream, _)) = listener.accept().await {
6327                    tokio::spawn(async move {
6328                        let mut buf = vec![0u8; 4096];
6329                        let _ = stream.read(&mut buf).await;
6330                        // Always redirect to the metadata endpoint
6331                        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";
6332                        let _ = stream.write_all(response.as_bytes()).await;
6333                    });
6334                }
6335            }
6336        });
6337
6338        let ctx = test_producer_ctx();
6339        let component =
6340            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6341        let endpoint_ctx = NoOpComponentContext;
6342        // allowInternal=false is the default — do NOT set it
6343        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
6344        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6345
6346        let exchange = Exchange::new(Message::default());
6347        let result = producer.oneshot(exchange).await;
6348
6349        // Must be an error — SSRF guard blocks the redirect target
6350        assert!(
6351            result.is_err(),
6352            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
6353        );
6354        let err = result.unwrap_err().to_string();
6355        assert!(
6356            err.contains("blocked IP")
6357                || err.contains("private IP")
6358                || err.contains("SSRF")
6359                || err.contains("not allowed"),
6360            "Error should mention SSRF/IP blocking, got: {err}"
6361        );
6362
6363        handle.abort();
6364    }
6365
6366    /// Integration test: exceeding maxRedirects produces a clear error.
6367    #[tokio::test]
6368    async fn test_too_many_redirects_returns_error() {
6369        use tower::ServiceExt;
6370
6371        // Server that always redirects to itself (infinite loop)
6372        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6373        let addr = listener.local_addr().unwrap();
6374        let url = format!("http://127.0.0.1:{}", addr.port());
6375
6376        let handle = tokio::spawn(async move {
6377            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6378            loop {
6379                if let Ok((mut stream, _)) = listener.accept().await {
6380                    tokio::spawn(async move {
6381                        let mut buf = vec![0u8; 4096];
6382                        let _ = stream.read(&mut buf).await;
6383                        // Always redirect to /loop
6384                        // Connection: close stops the client pooling the
6385                        // connection the server drops right after this
6386                        // response (pooled-race, rc-u3aw).
6387                        let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6388                        let _ = stream.write_all(response.as_bytes()).await;
6389                    });
6390                }
6391            }
6392        });
6393
6394        let ctx = test_producer_ctx();
6395        let component =
6396            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6397        let endpoint_ctx = NoOpComponentContext;
6398        let endpoint = component
6399            .create_endpoint(
6400                &format!("{url}?allowInternal=true&maxRedirects=2"),
6401                &endpoint_ctx,
6402            )
6403            .unwrap();
6404        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6405
6406        let exchange = Exchange::new(Message::default());
6407        let result = producer.oneshot(exchange).await;
6408
6409        // With the fix, exceeding max redirects returns the redirect response
6410        // as-is instead of erroring. The 302 redirect response is returned
6411        // after followRedirects exhausts the allowed redirect count (2).
6412        // Disable throwExceptionOnFailure to inspect the raw response status.
6413        //
6414        // Old behavior: Err("Too many redirects (max 2)")
6415        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
6416        match result {
6417            Err(e) => {
6418                // If throw_exception_on_failure is on, we get HttpOperationFailed
6419                let msg = e.to_string();
6420                assert!(
6421                    msg.contains("HTTP operation failed") || msg.contains("302"),
6422                    "expected redirect-after-exhaustion error, got: {msg}"
6423                );
6424            }
6425            Ok(ex) => {
6426                let response_code = ex
6427                    .input
6428                    .header("CamelHttpResponseCode")
6429                    .and_then(|v| v.as_u64());
6430                assert_eq!(
6431                    response_code,
6432                    Some(302),
6433                    "expected 302 after exhausting redirects"
6434                );
6435            }
6436        }
6437
6438        handle.abort();
6439    }
6440
6441    #[tokio::test]
6442    async fn test_query_params_forwarded_to_http_request() {
6443        use tower::ServiceExt;
6444
6445        let (url, _handle) = start_test_server().await;
6446        let ctx = test_producer_ctx();
6447
6448        let component = HttpComponent::new();
6449        let endpoint_ctx = NoOpComponentContext;
6450        // apiKey is NOT a Camel option, should be forwarded as query param
6451        let endpoint = component
6452            .create_endpoint(
6453                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
6454                &endpoint_ctx,
6455            )
6456            .unwrap();
6457        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6458
6459        let exchange = Exchange::new(Message::default());
6460        let result = producer.oneshot(exchange).await.unwrap();
6461
6462        // The test server returns the request info in response
6463        // We just verify it succeeds (the query param was sent)
6464        let status = result
6465            .input
6466            .header("CamelHttpResponseCode")
6467            .and_then(|v| v.as_u64())
6468            .unwrap();
6469        assert_eq!(status, 200);
6470    }
6471
6472    #[test]
6473    fn test_non_camel_query_params_are_forwarded() {
6474        // Authored pairs ride raw_query (the sole carrier); query_params is
6475        // programmatic-only (http-query-wire-fidelity).
6476        let config = HttpEndpointConfig::from_uri(
6477            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
6478        )
6479        .unwrap();
6480
6481        // apiKey and token are NOT camel-http options: the authored bytes
6482        // (including the interleaved httpMethod) ride raw_query verbatim.
6483        assert_eq!(
6484            config.raw_query.as_deref(),
6485            Some("apiKey=secret123&httpMethod=GET&token=abc456")
6486        );
6487        assert!(config.query_params.is_empty());
6488    }
6489
6490    #[test]
6491    fn test_authored_query_bytes_survive_resolve_url() {
6492        let config =
6493            HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
6494        let exchange = Exchange::new(Message::default());
6495
6496        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
6497
6498        // Authored bytes ride verbatim: `%20` stays `%20` (never re-encoded
6499        // to `+` or double-encoded) and `+` stays `+`.
6500        assert!(url.contains("q=hello%20world"), "url was: {url}");
6501        assert!(url.contains("tag=a+b"), "url was: {url}");
6502    }
6503
6504    // -----------------------------------------------------------------------
6505    // Timeout tests (HTTP-004)
6506    // -----------------------------------------------------------------------
6507
6508    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
6509        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6510        let addr = listener.local_addr().unwrap();
6511        let url = format!("http://127.0.0.1:{}", addr.port());
6512
6513        let handle = tokio::spawn(async move {
6514            loop {
6515                if let Ok((mut stream, _)) = listener.accept().await {
6516                    let delay = delay_ms;
6517                    tokio::spawn(async move {
6518                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6519                        let mut buf = vec![0u8; 4096];
6520                        let _ = stream.read(&mut buf).await;
6521                        // Send headers immediately (no Content-Length → chunked)
6522                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
6523                        let _ = stream.write_all(headers.as_bytes()).await;
6524                        // Delay before sending body chunk
6525                        tokio::time::sleep(Duration::from_millis(delay)).await;
6526                        let body = r#"{"status":"slow"}"#;
6527                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
6528                        let _ = stream.write_all(chunk.as_bytes()).await;
6529                    });
6530                }
6531            }
6532        });
6533
6534        (url, handle)
6535    }
6536
6537    #[tokio::test]
6538    async fn test_http_producer_timeout() {
6539        use tower::ServiceExt;
6540
6541        // Server delays 500ms, client timeout is 100ms → should timeout
6542        let (url, _handle) = start_slow_server(500).await;
6543        let ctx = test_producer_ctx();
6544
6545        let component = HttpComponent::with_config(
6546            HttpConfig::default()
6547                .with_read_timeout_ms(100)
6548                .with_response_timeout_ms(30_000), // generous response timeout
6549        );
6550        let endpoint_ctx = NoOpComponentContext;
6551        let endpoint = component
6552            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
6553            .unwrap();
6554        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6555
6556        let exchange = Exchange::new(Message::default());
6557        let result = producer.oneshot(exchange).await;
6558
6559        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
6560        let err = result.unwrap_err().to_string();
6561        assert!(
6562            err.contains("Read timeout") || err.contains("timeout"),
6563            "Error should mention timeout, got: {}",
6564            err
6565        );
6566    }
6567
6568    #[tokio::test]
6569    async fn test_http_producer_no_timeout_when_fast() {
6570        use tower::ServiceExt;
6571
6572        let (url, _handle) = start_test_server().await;
6573        let ctx = test_producer_ctx();
6574
6575        let component =
6576            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
6577        let endpoint_ctx = NoOpComponentContext;
6578        let endpoint = component
6579            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6580            .unwrap();
6581        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6582
6583        let exchange = Exchange::new(Message::default());
6584        let result = producer.oneshot(exchange).await.unwrap();
6585
6586        let status = result
6587            .input
6588            .header("CamelHttpResponseCode")
6589            .and_then(|v| v.as_u64())
6590            .unwrap();
6591        assert_eq!(status, 200);
6592    }
6593
6594    // -----------------------------------------------------------------------
6595    // SSRF Protection tests
6596    // -----------------------------------------------------------------------
6597
6598    #[tokio::test]
6599    async fn test_http_producer_blocks_metadata_endpoint() {
6600        use tower::ServiceExt;
6601
6602        let ctx = test_producer_ctx();
6603        let component = HttpComponent::new();
6604        let endpoint_ctx = NoOpComponentContext;
6605        let endpoint = component
6606            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
6607            .unwrap();
6608        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6609
6610        let mut exchange = Exchange::new(Message::default());
6611        exchange.input.set_header(
6612            "CamelHttpUri",
6613            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
6614        );
6615
6616        let result = producer.oneshot(exchange).await;
6617        assert!(result.is_err(), "Should block AWS metadata endpoint");
6618
6619        let err = result.unwrap_err();
6620        assert!(
6621            err.to_string().contains("Private IP"),
6622            "Error should mention private IP blocking, got: {}",
6623            err
6624        );
6625    }
6626
6627    #[test]
6628    fn test_ssrf_config_defaults() {
6629        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
6630        assert!(
6631            !config.allow_internal,
6632            "Private IPs should be blocked by default"
6633        );
6634        assert!(
6635            config.blocked_hosts.is_empty(),
6636            "Blocked hosts should be empty by default"
6637        );
6638    }
6639
6640    #[test]
6641    fn test_ssrf_config_allow_internal() {
6642        let config =
6643            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
6644        assert!(
6645            config.allow_internal,
6646            "Private IPs should be allowed when explicitly set"
6647        );
6648    }
6649
6650    #[test]
6651    fn test_uri_option_allow_cleartext_parses() {
6652        let config =
6653            HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=true").unwrap();
6654        assert!(
6655            config.allow_cleartext,
6656            "allowCleartext=true must parse into the endpoint config"
6657        );
6658
6659        let plain = HttpEndpointConfig::from_uri("http://example.com/").unwrap();
6660        assert!(
6661            !plain.allow_cleartext,
6662            "cleartext consent must default to false"
6663        );
6664
6665        let err =
6666            HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=banana").unwrap_err();
6667        assert!(
6668            matches!(&err, CamelError::InvalidUri(msg) if msg.contains("allowCleartext")),
6669            "bad allowCleartext value must yield InvalidUri naming the option, got: {err:?}"
6670        );
6671    }
6672
6673    /// ADR-0081: a CamelHttpUri override to a public cleartext target is
6674    /// gated by the endpoint's `allowCleartext` consent — override URLs go
6675    /// through the same `validate_url_for_ssrf` as the base URL.
6676    #[test]
6677    fn test_camel_http_uri_override_public_cleartext_follows_endpoint_flags() {
6678        let endpoint =
6679            HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=false").unwrap();
6680        let err = crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint)
6681            .expect_err("public cleartext override must be rejected without consent");
6682        assert!(
6683            err.to_string().contains("allowCleartext"),
6684            "error must name the remedy, got: {err}"
6685        );
6686
6687        let endpoint =
6688            HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=true").unwrap();
6689        assert!(
6690            crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint).is_ok(),
6691            "endpoint consent must admit a public cleartext override"
6692        );
6693    }
6694
6695    #[test]
6696    fn test_ssrf_config_blocked_hosts() {
6697        let config = HttpEndpointConfig::from_uri(
6698            "http://example.com/api?blockedHosts=evil.com,malware.net",
6699        )
6700        .unwrap();
6701        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
6702    }
6703
6704    #[tokio::test]
6705    async fn test_http_producer_blocks_localhost() {
6706        use tower::ServiceExt;
6707
6708        let ctx = test_producer_ctx();
6709        let component = HttpComponent::new();
6710        let endpoint_ctx = NoOpComponentContext;
6711        let endpoint = component
6712            .create_endpoint("http://example.com/api", &endpoint_ctx)
6713            .unwrap();
6714        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6715
6716        let mut exchange = Exchange::new(Message::default());
6717        exchange.input.set_header(
6718            "CamelHttpUri",
6719            serde_json::Value::String("http://localhost:8080/internal".to_string()),
6720        );
6721
6722        let result = producer.oneshot(exchange).await;
6723        assert!(result.is_err(), "Should block localhost");
6724    }
6725
6726    #[tokio::test]
6727    async fn test_http_producer_blocks_loopback_ip() {
6728        use tower::ServiceExt;
6729
6730        let ctx = test_producer_ctx();
6731        let component = HttpComponent::new();
6732        let endpoint_ctx = NoOpComponentContext;
6733        let endpoint = component
6734            .create_endpoint("http://example.com/api", &endpoint_ctx)
6735            .unwrap();
6736        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6737
6738        let mut exchange = Exchange::new(Message::default());
6739        exchange.input.set_header(
6740            "CamelHttpUri",
6741            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
6742        );
6743
6744        let result = producer.oneshot(exchange).await;
6745        assert!(result.is_err(), "Should block loopback IP");
6746    }
6747
6748    #[tokio::test]
6749    async fn test_http_producer_allows_private_ip_when_enabled() {
6750        use tower::ServiceExt;
6751
6752        let ctx = test_producer_ctx();
6753        let component = HttpComponent::new();
6754        let endpoint_ctx = NoOpComponentContext;
6755        // With allowInternal=true, the validation should pass
6756        // (actual connection will fail, but that's expected)
6757        let endpoint = component
6758            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
6759            .unwrap();
6760        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6761
6762        let exchange = Exchange::new(Message::default());
6763
6764        // The request will fail because we can't connect, but it should NOT fail
6765        // due to SSRF protection
6766        let result = producer.oneshot(exchange).await;
6767        // We expect connection error, not SSRF error
6768        if let Err(ref e) = result {
6769            let err_str = e.to_string();
6770            assert!(
6771                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
6772                "Should not be SSRF error, got: {}",
6773                err_str
6774            );
6775        }
6776    }
6777
6778    // -----------------------------------------------------------------------
6779    // HttpServerConfig tests
6780    // -----------------------------------------------------------------------
6781
6782    #[test]
6783    fn test_http_server_config_parse() {
6784        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
6785        assert_eq!(cfg.host, "0.0.0.0");
6786        assert_eq!(cfg.port, 8080);
6787        assert_eq!(cfg.path, "/orders");
6788        assert_eq!(cfg.max_inflight_requests, 1024);
6789    }
6790
6791    #[test]
6792    fn test_http_server_config_scheme() {
6793        // UriConfig trait method returns "http" as primary scheme
6794        assert_eq!(HttpServerConfig::scheme(), "http");
6795    }
6796
6797    #[test]
6798    fn test_http_server_config_from_components() {
6799        // Test from_components directly (trait method)
6800        let components = camel_component_api::UriComponents {
6801            scheme: "https".to_string(),
6802            path: "//0.0.0.0:8443/api".to_string(),
6803            params: std::collections::HashMap::from([
6804                ("maxRequestBody".to_string(), "5242880".to_string()),
6805                ("maxInflightRequests".to_string(), "7".to_string()),
6806            ]),
6807            raw_query: None,
6808        };
6809        let cfg = HttpServerConfig::from_components(components).unwrap();
6810        assert_eq!(cfg.host, "0.0.0.0");
6811        assert_eq!(cfg.port, 8443);
6812        assert_eq!(cfg.path, "/api");
6813        assert_eq!(cfg.max_request_body, 5242880);
6814        assert_eq!(cfg.max_inflight_requests, 7);
6815    }
6816
6817    #[test]
6818    fn test_http_server_config_default_path() {
6819        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
6820        assert_eq!(cfg.path, "/");
6821    }
6822
6823    #[test]
6824    fn test_http_server_config_wrong_scheme() {
6825        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6826    }
6827
6828    #[test]
6829    fn test_http_server_config_invalid_port() {
6830        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6831    }
6832
6833    #[test]
6834    fn test_http_server_config_default_port_by_scheme() {
6835        // HTTP without explicit port should default to 80
6836        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
6837        assert_eq!(cfg_http.port, 80);
6838
6839        // HTTPS without explicit port should default to 443
6840        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
6841        assert_eq!(cfg_https.port, 443);
6842    }
6843
6844    #[test]
6845    fn test_request_envelope_and_reply_are_send() {
6846        fn assert_send<T: Send>() {}
6847        assert_send::<RequestEnvelope>();
6848        assert_send::<HttpReply>();
6849    }
6850
6851    // -----------------------------------------------------------------------
6852    // ServerRegistry tests
6853    // -----------------------------------------------------------------------
6854
6855    #[test]
6856    fn test_server_registry_global_is_singleton() {
6857        let r1 = ServerRegistry::global();
6858        let r2 = ServerRegistry::global();
6859        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
6860    }
6861
6862    #[allow(clippy::await_holding_lock)]
6863    #[tokio::test]
6864    async fn test_concurrent_get_or_spawn_returns_same_registry() {
6865        let _guard = lock_registry_test_mutex();
6866        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6867        let port = listener.local_addr().unwrap().port();
6868        drop(listener);
6869
6870        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
6871            Arc::new(std::sync::Mutex::new(Vec::new()));
6872
6873        let mut handles = Vec::new();
6874        for _ in 0..4 {
6875            let results = results.clone();
6876            handles.push(tokio::spawn(async move {
6877                let registry = ServerRegistry::global()
6878                    .get_or_spawn(
6879                        "127.0.0.1",
6880                        port,
6881                        2 * 1024 * 1024,
6882                        10 * 1024 * 1024,
6883                        1024,
6884                        test_rt(),
6885                        "test-route".into(),
6886                        None,
6887                    )
6888                    .await
6889                    .unwrap();
6890                results.lock().unwrap().push(registry);
6891            }));
6892        }
6893
6894        for h in handles {
6895            h.await.unwrap();
6896        }
6897
6898        let registries = results.lock().unwrap();
6899        assert_eq!(registries.len(), 4);
6900        for i in 1..registries.len() {
6901            assert!(
6902                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
6903                "all concurrent callers should get same route registry"
6904            );
6905        }
6906    }
6907
6908    #[test]
6909    fn test_server_registry_distinguishes_host_and_port() {
6910        let _guard = lock_registry_test_mutex();
6911        let rt = tokio::runtime::Runtime::new().expect("runtime");
6912        rt.block_on(async {
6913            let registry = ServerRegistry::global();
6914            // Use two distinct host values with same configured port key.
6915            // Port 0 is acceptable here because the registry key uses the configured
6916            // tuple, not the OS-assigned ephemeral port.
6917            let d1 = registry
6918                .get_or_spawn(
6919                    "127.0.0.1",
6920                    0,
6921                    1024 * 1024,
6922                    10 * 1024 * 1024,
6923                    1024,
6924                    test_rt(),
6925                    "test-route-1".into(),
6926                    None,
6927                )
6928                .await;
6929            let d2 = registry
6930                .get_or_spawn(
6931                    "0.0.0.0",
6932                    0,
6933                    1024 * 1024,
6934                    10 * 1024 * 1024,
6935                    1024,
6936                    test_rt(),
6937                    "test-route-2".into(),
6938                    None,
6939                )
6940                .await;
6941            assert!(d1.is_ok());
6942            assert!(d2.is_ok());
6943            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
6944        });
6945    }
6946
6947    #[allow(clippy::await_holding_lock)]
6948    #[tokio::test]
6949    async fn test_shared_server_max_request_body_policy_is_deterministic() {
6950        let _guard = lock_registry_test_mutex();
6951        let registry = ServerRegistry::global();
6952        // First registration: maxRequestBody = 1 MB
6953        let d1 = registry
6954            .get_or_spawn(
6955                "127.0.0.1",
6956                9991,
6957                1024 * 1024,
6958                10 * 1024 * 1024,
6959                1024,
6960                test_rt(),
6961                "test-route".into(),
6962                None,
6963            )
6964            .await;
6965        assert!(d1.is_ok());
6966
6967        // Second registration on same (host,port): maxRequestBody = 2 MB
6968        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
6969        let d2 = registry
6970            .get_or_spawn(
6971                "127.0.0.1",
6972                9991,
6973                2 * 1024 * 1024,
6974                10 * 1024 * 1024,
6975                1024,
6976                test_rt(),
6977                "test-route-2".into(),
6978                None,
6979            )
6980            .await;
6981        assert!(d2.is_err());
6982        let err = d2.unwrap_err();
6983        assert!(
6984            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6985            "Expected incompatible maxRequestBody error, got: {}",
6986            err
6987        );
6988    }
6989
6990    #[test]
6991    fn test_server_registry_reset_clears_entries() {
6992        let _guard = lock_registry_test_mutex();
6993        let rt = tokio::runtime::Runtime::new().expect("runtime");
6994        rt.block_on(async {
6995            // Register something on a unique port
6996            let d1 = ServerRegistry::global()
6997                .get_or_spawn(
6998                    "127.0.0.1",
6999                    9992,
7000                    1024 * 1024,
7001                    10 * 1024 * 1024,
7002                    1024,
7003                    test_rt(),
7004                    "test-route".into(),
7005                    None,
7006                )
7007                .await;
7008            assert!(d1.is_ok());
7009
7010            // Verify entry exists
7011            let guard = ServerRegistry::global().inner.lock().expect("lock");
7012            assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
7013            drop(guard);
7014
7015            // Reset
7016            ServerRegistry::reset();
7017
7018            // Verify cleared
7019            let guard = ServerRegistry::global().inner.lock().expect("lock");
7020            assert!(
7021                guard.entries.is_empty(),
7022                "registry should be empty after reset, has {} entries",
7023                guard.entries.len()
7024            );
7025        });
7026    }
7027
7028    #[allow(clippy::await_holding_lock)]
7029    #[tokio::test]
7030    async fn registry_rejects_tls_on_plain_port() {
7031        // httpflake: this reset previously ran WITHOUT the registry test
7032        // mutex, so it could wipe another test's freshly staged entry
7033        // mid-window (traced 2026-09-14) — spec law: every reset caller
7034        // holds REGISTRY_TEST_MUTEX.
7035        let _guard = lock_registry_test_mutex();
7036        ServerRegistry::reset();
7037        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7038
7039        // First route: plain HTTP
7040        let _r1 = ServerRegistry::global()
7041            .get_or_spawn(
7042                "127.0.0.1",
7043                0,
7044                1024,
7045                1024,
7046                16,
7047                Arc::clone(&rt),
7048                "route-1".into(),
7049                None, // plain
7050            )
7051            .await;
7052
7053        // Second route: TLS on same port → must fail
7054        let result = ServerRegistry::global()
7055            .get_or_spawn(
7056                "127.0.0.1",
7057                0,
7058                1024,
7059                1024,
7060                16,
7061                Arc::clone(&rt),
7062                "route-2".into(),
7063                Some(crate::config::ServerTlsConfig {
7064                    cert_path: "/x.pem".into(),
7065                    key_path: "/y.pem".into(),
7066                }),
7067            )
7068            .await;
7069        assert!(result.is_err(), "must reject TLS on plain port");
7070    }
7071
7072    // -----------------------------------------------------------------------
7073    // D-L10: HTTP server is process-lifetime — it survives consumer
7074    // unregister (no refcount; dead servers are evicted on next spawn)
7075    // -----------------------------------------------------------------------
7076
7077    #[allow(clippy::await_holding_lock)]
7078    #[tokio::test]
7079    async fn test_unregister_last_http_route_keeps_server_alive() {
7080        let _guard = lock_registry_test_mutex();
7081        ServerRegistry::reset();
7082        let registry = ServerRegistry::global();
7083
7084        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7085        let port = listener.local_addr().unwrap().port();
7086        drop(listener); // Release — ServerRegistry will rebind
7087        let rt = test_rt();
7088
7089        // Register 2 routes on the same (host, port) — OnceCell returns the
7090        // same ServerHandle.
7091        let _r1 = registry
7092            .get_or_spawn(
7093                "127.0.0.1",
7094                port,
7095                1024 * 1024,
7096                10 * 1024 * 1024,
7097                16,
7098                rt.clone(),
7099                "test-route-1".into(),
7100                None,
7101            )
7102            .await
7103            .unwrap();
7104        let _r2 = registry
7105            .get_or_spawn(
7106                "127.0.0.1",
7107                port,
7108                1024 * 1024,
7109                10 * 1024 * 1024,
7110                16,
7111                rt,
7112                "test-route-2".into(),
7113                None,
7114            )
7115            .await
7116            .unwrap();
7117
7118        let key = ("127.0.0.1".to_string(), port);
7119        let cell = {
7120            let guard = registry.inner.lock().expect("lock");
7121            guard.entries.get(&key).expect("entry should exist").clone()
7122        };
7123
7124        // Unregister first route -> monitor still alive (count = 1).
7125        registry.unregister("127.0.0.1", port).await;
7126        {
7127            let handle = cell
7128                .get()
7129                .expect("handle should still exist after first unregister");
7130            assert!(
7131                !handle.monitor_task.is_finished(),
7132                "monitor task should still be alive after first unregister"
7133            );
7134        }
7135
7136        // Unregister second route -> server stays alive (process-lifetime).
7137        registry.unregister("127.0.0.1", port).await;
7138        tokio::time::sleep(Duration::from_millis(20)).await;
7139        {
7140            let handle = cell
7141                .get()
7142                .expect("handle should still exist after last unregister");
7143            assert!(
7144                !handle.monitor_task.is_finished(),
7145                "monitor task should still be alive — server is process-lifetime"
7146            );
7147        }
7148
7149        // Entry stays in registry for potential restart.
7150        {
7151            let guard = registry.inner.lock().expect("lock");
7152            assert!(
7153                guard.entries.contains_key(&key),
7154                "entry should remain in registry — server kept alive for restart"
7155            );
7156        }
7157    }
7158
7159    // -----------------------------------------------------------------------
7160    // Staged listeners (itest-bound-ports Task 1)
7161    // -----------------------------------------------------------------------
7162
7163    /// CLONE-FIXTURE: bind a std listener on `127.0.0.1:0`, retain a blocking
7164    /// std clone (`probe`) so the port stays reserved, and hand the original
7165    /// socket to tokio as a non-blocking listener. `tokio::net::TcpListener`
7166    /// has no `try_clone`, so clones come from the std handle.
7167    async fn clone_fixture_listener() -> (
7168        tokio::net::TcpListener,
7169        std::net::TcpListener,
7170        std::net::SocketAddr,
7171    ) {
7172        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7173        let probe = l.try_clone().expect("clone probe");
7174        l.set_nonblocking(true).expect("set_nonblocking");
7175        let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7176        let addr = listener.local_addr().expect("local_addr");
7177        (listener, probe, addr)
7178    }
7179
7180    /// Default-limit constants the existing registry tests in this file use.
7181    fn staged_limits() -> (usize, usize, usize) {
7182        (1024 * 1024, 10 * 1024 * 1024, 1024)
7183    }
7184
7185    #[allow(clippy::await_holding_lock)]
7186    #[tokio::test]
7187    async fn staged_listener_first_spawn_serves_without_second_bind() {
7188        let _guard = lock_registry_test_mutex();
7189        ServerRegistry::reset();
7190        let registry = ServerRegistry::global();
7191        let (listener, _probe, addr) = clone_fixture_listener().await;
7192        let port = addr.port();
7193        registry
7194            .stage_listener(listener)
7195            .await
7196            .expect("stage listener");
7197
7198        let (max_req, max_res, max_inflight) = staged_limits();
7199        let routes = registry
7200            .get_or_spawn(
7201                "127.0.0.1",
7202                port,
7203                max_req,
7204                max_res,
7205                max_inflight,
7206                test_rt(),
7207                "staged-first-spawn".into(),
7208                None,
7209            )
7210            .await
7211            .expect("spawn from staged listener must succeed");
7212
7213        assert_eq!(
7214            registry.bound_addr("127.0.0.1", port),
7215            Some(addr),
7216            "served socket must be the staged listener's addr"
7217        );
7218        // The probe clone shares the socket, so service is proven by an HTTP
7219        // response, not by accepting on the probe.
7220        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7221            .await
7222            .expect("http request against staged listener must connect");
7223        assert!(
7224            resp.status().as_u16() >= 200,
7225            "any status proves the staged socket serves"
7226        );
7227        drop(routes);
7228    }
7229
7230    #[allow(clippy::await_holding_lock)]
7231    #[tokio::test]
7232    async fn staged_entry_reused_by_second_caller() {
7233        let _guard = lock_registry_test_mutex();
7234        ServerRegistry::reset();
7235        let registry = ServerRegistry::global();
7236        let (listener, _probe, addr) = clone_fixture_listener().await;
7237        let port = addr.port();
7238        registry
7239            .stage_listener(listener)
7240            .await
7241            .expect("stage listener");
7242
7243        let (max_req, max_res, max_inflight) = staged_limits();
7244        let first = registry
7245            .get_or_spawn(
7246                "127.0.0.1",
7247                port,
7248                max_req,
7249                max_res,
7250                max_inflight,
7251                test_rt(),
7252                "staged-reuse-1".into(),
7253                None,
7254            )
7255            .await
7256            .expect("first spawn from staged listener");
7257        let second = registry
7258            .get_or_spawn(
7259                "127.0.0.1",
7260                port,
7261                max_req,
7262                max_res,
7263                max_inflight,
7264                test_rt(),
7265                "staged-reuse-2".into(),
7266                None,
7267            )
7268            .await
7269            .expect("second caller must reuse the entry");
7270        assert_eq!(
7271            registry.bound_addr("127.0.0.1", port),
7272            Some(addr),
7273            "entry reused — bound addr unchanged, no second bind"
7274        );
7275        drop(first);
7276        drop(second);
7277    }
7278
7279    #[allow(clippy::await_holding_lock)]
7280    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7281    async fn staged_race_two_callers_single_resolver() {
7282        let _guard = lock_registry_test_mutex();
7283        ServerRegistry::reset();
7284        let registry = ServerRegistry::global();
7285        let (listener, _probe, addr) = clone_fixture_listener().await;
7286        let port = addr.port();
7287        registry
7288            .stage_listener(listener)
7289            .await
7290            .expect("stage listener");
7291
7292        // Two racing callers for the exact staged key: the staged listener
7293        // must be consumed by the single cell-init winner and served to
7294        // both — never leave the winner binding a port the loser still
7295        // holds (EADDRINUSE).
7296        let (max_req, max_res, max_inflight) = staged_limits();
7297        let (first, second) = tokio::join!(
7298            registry.get_or_spawn(
7299                "127.0.0.1",
7300                port,
7301                max_req,
7302                max_res,
7303                max_inflight,
7304                test_rt(),
7305                "staged-race-1".into(),
7306                None,
7307            ),
7308            registry.get_or_spawn(
7309                "127.0.0.1",
7310                port,
7311                max_req,
7312                max_res,
7313                max_inflight,
7314                test_rt(),
7315                "staged-race-2".into(),
7316                None,
7317            ),
7318        );
7319        let first = first.expect("first racing caller must succeed");
7320        let second = second.expect("second racing caller must succeed");
7321        assert_eq!(
7322            registry.bound_addr("127.0.0.1", port),
7323            Some(addr),
7324            "single entry must be served from the staged socket — no EADDRINUSE path"
7325        );
7326        drop(first);
7327        drop(second);
7328    }
7329
7330    #[allow(clippy::await_holding_lock)]
7331    #[tokio::test]
7332    async fn unstaged_spawn_binds_legacy() {
7333        let _guard = lock_registry_test_mutex();
7334        ServerRegistry::reset();
7335        let registry = ServerRegistry::global();
7336        // Fresh port P2: reserve then release — the legacy path rebinds.
7337        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
7338        let port = probe.local_addr().expect("local addr").port();
7339        drop(probe);
7340
7341        let (max_req, max_res, max_inflight) = staged_limits();
7342        registry
7343            .get_or_spawn(
7344                "127.0.0.1",
7345                port,
7346                max_req,
7347                max_res,
7348                max_inflight,
7349                test_rt(),
7350                "legacy-bind".into(),
7351                None,
7352            )
7353            .await
7354            .expect("legacy bind spawn");
7355        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
7356            .await
7357            .expect("connect to freshly bound port must succeed");
7358        assert!(resp.status().as_u16() >= 200);
7359        assert_eq!(
7360            registry.bound_addr("127.0.0.1", port),
7361            Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
7362            "bound addr must be the legacy bound (host, port)"
7363        );
7364    }
7365
7366    #[allow(clippy::await_holding_lock)]
7367    #[tokio::test]
7368    async fn wrong_host_staged_port_fails_deterministically() {
7369        let _guard = lock_registry_test_mutex();
7370        ServerRegistry::reset();
7371        let registry = ServerRegistry::global();
7372        let (listener, _probe, addr) = clone_fixture_listener().await;
7373        let port = addr.port();
7374        registry
7375            .stage_listener(listener)
7376            .await
7377            .expect("stage listener under 127.0.0.1");
7378
7379        let (max_req, max_res, max_inflight) = staged_limits();
7380        let err = registry
7381            .get_or_spawn(
7382                "localhost",
7383                port,
7384                max_req,
7385                max_res,
7386                max_inflight,
7387                test_rt(),
7388                "conflict-probe".into(),
7389                None,
7390            )
7391            .await
7392            .expect_err("wrong host on staged port must fail deterministically");
7393        assert!(
7394            err.to_string().contains("staged listener conflict on port"),
7395            "unexpected error: {err}"
7396        );
7397
7398        // Slot untouched by the failed call: the correct host now consumes it.
7399        registry
7400            .get_or_spawn(
7401                "127.0.0.1",
7402                port,
7403                max_req,
7404                max_res,
7405                max_inflight,
7406                test_rt(),
7407                "conflict-after".into(),
7408                None,
7409            )
7410            .await
7411            .expect("correct host must serve the staged listener");
7412        assert_eq!(
7413            registry.bound_addr("127.0.0.1", port),
7414            Some(addr),
7415            "staged slot must be untouched by the conflicting call"
7416        );
7417    }
7418
7419    #[allow(clippy::await_holding_lock)]
7420    #[tokio::test]
7421    async fn duplicate_stage_same_key_rejected() {
7422        let _guard = lock_registry_test_mutex();
7423        ServerRegistry::reset();
7424        let registry = ServerRegistry::global();
7425        let (listener, probe, addr) = clone_fixture_listener().await;
7426        registry
7427            .stage_listener(listener)
7428            .await
7429            .expect("stage listener A");
7430
7431        // Second tokio handle to the SAME socket: clone the std probe handle.
7432        let dup = probe.try_clone().expect("clone2");
7433        dup.set_nonblocking(true).expect("set_nonblocking2");
7434        let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
7435
7436        let err = registry
7437            .stage_listener(b)
7438            .await
7439            .expect_err("duplicate stage must be rejected");
7440        assert!(
7441            err.to_string().contains("listener already staged"),
7442            "unexpected error: {err}"
7443        );
7444
7445        let (max_req, max_res, max_inflight) = staged_limits();
7446        registry
7447            .get_or_spawn(
7448                "127.0.0.1",
7449                addr.port(),
7450                max_req,
7451                max_res,
7452                max_inflight,
7453                test_rt(),
7454                "dup-stage-after".into(),
7455                None,
7456            )
7457            .await
7458            .expect("spawn from first staged listener");
7459        assert_eq!(
7460            registry.bound_addr("127.0.0.1", addr.port()),
7461            Some(addr),
7462            "first staged listener retained"
7463        );
7464    }
7465
7466    #[allow(clippy::await_holding_lock)]
7467    #[tokio::test]
7468    async fn distinct_keys_stage_independently() {
7469        let _guard = lock_registry_test_mutex();
7470        ServerRegistry::reset();
7471        let registry = ServerRegistry::global();
7472        let (l1, _p1, addr1) = clone_fixture_listener().await;
7473        let (l2, _p2, addr2) = clone_fixture_listener().await;
7474        registry.stage_listener(l1).await.expect("stage P1");
7475        registry.stage_listener(l2).await.expect("stage P2");
7476
7477        let (max_req, max_res, max_inflight) = staged_limits();
7478        registry
7479            .get_or_spawn(
7480                "127.0.0.1",
7481                addr1.port(),
7482                max_req,
7483                max_res,
7484                max_inflight,
7485                test_rt(),
7486                "distinct-1".into(),
7487                None,
7488            )
7489            .await
7490            .expect("spawn P1");
7491        registry
7492            .get_or_spawn(
7493                "127.0.0.1",
7494                addr2.port(),
7495                max_req,
7496                max_res,
7497                max_inflight,
7498                test_rt(),
7499                "distinct-2".into(),
7500                None,
7501            )
7502            .await
7503            .expect("spawn P2");
7504        assert_eq!(
7505            registry.bound_addr("127.0.0.1", addr1.port()),
7506            Some(addr1),
7507            "P1 bound addr must be its own listener"
7508        );
7509        assert_eq!(
7510            registry.bound_addr("127.0.0.1", addr2.port()),
7511            Some(addr2),
7512            "P2 bound addr must be its own listener"
7513        );
7514        let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
7515            .await
7516            .expect("connect P1");
7517        assert!(r1.status().as_u16() >= 200);
7518        let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
7519            .await
7520            .expect("connect P2");
7521        assert!(r2.status().as_u16() >= 200);
7522    }
7523
7524    #[allow(clippy::await_holding_lock)]
7525    #[tokio::test]
7526    async fn tls_prebound_listener_served() {
7527        use camel_component_api::test_support::tls;
7528
7529        // Install rustls crypto provider (aws-lc-rs — matches the existing
7530        // TLS registry tests).
7531        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7532
7533        let _guard = lock_registry_test_mutex();
7534        ServerRegistry::reset();
7535        let registry = ServerRegistry::global();
7536        let (listener, _probe, addr) = clone_fixture_listener().await;
7537        let port = addr.port();
7538
7539        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7540        let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
7541        let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
7542        let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
7543
7544        let (max_req, max_res, max_inflight) = staged_limits();
7545        let routes = registry
7546            .get_or_spawn_with_listener(
7547                listener,
7548                max_req,
7549                max_res,
7550                max_inflight,
7551                test_rt(),
7552                "staged-tls".into(),
7553                Some(crate::config::ServerTlsConfig {
7554                    cert_path: cert_path.to_string_lossy().into_owned(),
7555                    key_path: key_path.to_string_lossy().into_owned(),
7556                }),
7557            )
7558            .await
7559            .expect("spawn TLS server from pre-bound listener");
7560
7561        // Client with CA cert — REAL verification (no danger_accept_invalid),
7562        // same helper pattern as the existing TLS registry tests.
7563        let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
7564        let client = reqwest::Client::builder()
7565            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
7566            .build()
7567            .expect("build tls client");
7568
7569        let resp = client
7570            .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
7571            .send()
7572            .await
7573            .expect("TLS handshake + request must succeed");
7574        assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
7575        assert_eq!(
7576            registry.bound_addr("127.0.0.1", port),
7577            Some(addr),
7578            "bound addr equals the pre-bound listener addr"
7579        );
7580        drop(routes);
7581    }
7582
7583    #[allow(clippy::await_holding_lock)]
7584    #[tokio::test]
7585    async fn with_listener_direct_spawn_keyed_by_actual_addr() {
7586        let _guard = lock_registry_test_mutex();
7587        ServerRegistry::reset();
7588        let registry = ServerRegistry::global();
7589        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7590            .await
7591            .expect("bind un-staged listener");
7592        let addr = listener.local_addr().expect("local addr");
7593        let port = addr.port();
7594
7595        let (max_req, max_res, max_inflight) = staged_limits();
7596        registry
7597            .get_or_spawn_with_listener(
7598                listener,
7599                max_req,
7600                max_res,
7601                max_inflight,
7602                test_rt(),
7603                "with-listener".into(),
7604                None,
7605            )
7606            .await
7607            .expect("direct spawn from un-staged listener");
7608        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
7609            .await
7610            .expect("connect on actual port");
7611        assert!(resp.status().as_u16() >= 200);
7612        assert_eq!(
7613            registry.bound_addr("127.0.0.1", port),
7614            Some(addr),
7615            "registry key is the listener's actual port"
7616        );
7617
7618        registry
7619            .get_or_spawn(
7620                "127.0.0.1",
7621                port,
7622                max_req,
7623                max_res,
7624                max_inflight,
7625                test_rt(),
7626                "with-listener-reuse".into(),
7627                None,
7628            )
7629            .await
7630            .expect("legacy caller must reuse the entry");
7631        assert_eq!(
7632            registry.bound_addr("127.0.0.1", port),
7633            Some(addr),
7634            "entry reused — no second bind"
7635        );
7636    }
7637
7638    // -----------------------------------------------------------------------
7639    // Axum dispatch handler tests
7640    // -----------------------------------------------------------------------
7641
7642    #[tokio::test]
7643    async fn test_dispatch_handler_returns_404_for_unknown_path() {
7644        let registry = HttpRouteRegistry::new();
7645        // Nothing registered in route registry
7646        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7647        let port = listener.local_addr().unwrap().port();
7648        tokio::spawn(run_axum_server(
7649            listener,
7650            registry,
7651            2 * 1024 * 1024,
7652            10 * 1024 * 1024,
7653            Arc::new(tokio::sync::Semaphore::new(1024)),
7654            test_rt(),
7655            "test-route".into(),
7656        ));
7657
7658        // Wait for server to start
7659        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7660
7661        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
7662            .await
7663            .unwrap();
7664        assert_eq!(resp.status().as_u16(), 404);
7665    }
7666
7667    // -----------------------------------------------------------------------
7668    // HttpConsumer tests
7669    // -----------------------------------------------------------------------
7670
7671    #[tokio::test]
7672    async fn test_http_consumer_start_registers_path() {
7673        use camel_component_api::ConsumerContext;
7674
7675        // Get an OS-assigned free port
7676        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7677        let port = listener.local_addr().unwrap().port();
7678        drop(listener); // Release port — ServerRegistry will rebind it
7679
7680        let consumer_cfg = HttpServerConfig {
7681            scheme: "http".to_string(),
7682            host: "127.0.0.1".to_string(),
7683            port,
7684            path: "/ping".to_string(),
7685            max_request_body: 2 * 1024 * 1024,
7686            max_response_body: 10 * 1024 * 1024,
7687            max_inflight_requests: 1024,
7688            method: None,
7689            tls_config: None,
7690        };
7691        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7692
7693        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7694        let token = tokio_util::sync::CancellationToken::new();
7695        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7696
7697        tokio::spawn(async move {
7698            consumer.start(ctx).await.unwrap();
7699        });
7700
7701        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7702
7703        let client = reqwest::Client::new();
7704        let resp_future = client
7705            .post(format!("http://127.0.0.1:{port}/ping"))
7706            .body("hello world")
7707            .send();
7708
7709        let (http_result, _) = tokio::join!(resp_future, async {
7710            if let Some(mut envelope) = rx.recv().await {
7711                // Set a custom status code
7712                envelope.exchange.input.set_header(
7713                    "CamelHttpResponseCode",
7714                    serde_json::Value::Number(201.into()),
7715                );
7716                if let Some(reply_tx) = envelope.reply_tx {
7717                    let _ = reply_tx.send(Ok(envelope.exchange));
7718                }
7719            }
7720        });
7721
7722        let resp = http_result.unwrap();
7723        assert_eq!(resp.status().as_u16(), 201);
7724
7725        token.cancel();
7726    }
7727
7728    /// rc-nftni (drainclaim): the raw-sender dispatch path mints a claim at
7729    /// the acceptance dequeue and carries it on the envelope. Exact totals:
7730    /// 1 while the envelope is held, 0 after release. No wall-clock sleeps —
7731    /// readiness is the startup signal, the recv IS the barrier.
7732    #[tokio::test]
7733    async fn http_consumer_raw_dispatch_carries_in_flight_claim() {
7734        use std::sync::atomic::{AtomicU64, Ordering};
7735
7736        use camel_component_api::ConsumerContext;
7737        use camel_component_api::StartupSignal;
7738
7739        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7740        let port = listener.local_addr().unwrap().port();
7741        drop(listener);
7742
7743        let consumer_cfg = HttpServerConfig {
7744            scheme: "http".to_string(),
7745            host: "127.0.0.1".to_string(),
7746            port,
7747            path: "/claim".to_string(),
7748            max_request_body: 2 * 1024 * 1024,
7749            max_response_body: 10 * 1024 * 1024,
7750            max_inflight_requests: 1024,
7751            method: None,
7752            tls_config: None,
7753        };
7754        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7755
7756        let counter = std::sync::Arc::new(AtomicU64::new(0));
7757        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7758        let token = tokio_util::sync::CancellationToken::new();
7759        let (signal, startup_rx) = StartupSignal::pair();
7760        let ctx = ConsumerContext::new(tx, token.clone(), "http-claim-route".to_string())
7761            .with_startup(signal)
7762            .with_in_flight_counter(std::sync::Arc::clone(&counter));
7763
7764        tokio::spawn(async move {
7765            consumer.start(ctx).await.unwrap();
7766        });
7767
7768        // Deterministic readiness: the consumer marks ready only after the
7769        // listener is bound and the path registered.
7770        tokio::time::timeout(std::time::Duration::from_secs(5), startup_rx.await_ready())
7771            .await
7772            .expect("startup must resolve within 5s")
7773            .expect("startup must be Ok");
7774
7775        let client = reqwest::Client::new();
7776        let (http_result, claim) = tokio::join!(
7777            client
7778                .post(format!("http://127.0.0.1:{port}/claim"))
7779                .body("hello")
7780                .send(),
7781            async {
7782                let mut envelope = rx.recv().await.expect("envelope must arrive");
7783                let claim = envelope
7784                    .in_flight_claim
7785                    .take()
7786                    .expect("raw dispatch must carry an acceptance-minted claim");
7787                assert_eq!(
7788                    counter.load(Ordering::Acquire),
7789                    1,
7790                    "exact total: the acceptance mint is the only live claim"
7791                );
7792                // Materialized reply body: echoing the request's Stream body
7793                // back would tie the response to the request-body stream
7794                // (not what this test exercises).
7795                let reply_tx = envelope.reply_tx.take().expect("reply channel must be set");
7796                let reply_exchange = Exchange::new(camel_component_api::Message::new(
7797                    camel_component_api::Body::Text("done".to_string()),
7798                ));
7799                reply_tx
7800                    .send(Ok(reply_exchange))
7801                    .expect("reply must be taken");
7802                claim
7803            },
7804        );
7805
7806        let resp = http_result.expect("http roundtrip must complete");
7807        assert_eq!(resp.status().as_u16(), 200);
7808
7809        drop(claim);
7810        assert_eq!(
7811            counter.load(Ordering::Acquire),
7812            0,
7813            "release exactly once when the holder drops"
7814        );
7815        token.cancel();
7816    }
7817
7818    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
7819    /// dispatcher's inflight semaphore so the semaphore stays the single
7820    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
7821    #[test]
7822    fn test_envelope_channel_capacity_follows_max_inflight() {
7823        assert_eq!(envelope_channel_capacity(0), 1);
7824        assert_eq!(envelope_channel_capacity(1), 1);
7825        assert_eq!(envelope_channel_capacity(7), 7);
7826        assert_eq!(envelope_channel_capacity(64), 64);
7827        assert_eq!(envelope_channel_capacity(1024), 1024);
7828    }
7829
7830    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
7831    /// configuration. Consumer start must not panic on it (the channel guard)
7832    /// and every request must get 503 from the empty semaphore.
7833    #[tokio::test]
7834    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
7835        use camel_component_api::ConsumerContext;
7836
7837        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7838        let port = listener.local_addr().unwrap().port();
7839        drop(listener);
7840
7841        let consumer_cfg = HttpServerConfig {
7842            scheme: "http".to_string(),
7843            host: "127.0.0.1".to_string(),
7844            port,
7845            path: "/ping".to_string(),
7846            max_request_body: 2 * 1024 * 1024,
7847            max_response_body: 10 * 1024 * 1024,
7848            max_inflight_requests: 0,
7849            method: None,
7850            tls_config: None,
7851        };
7852        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7853
7854        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7855        let token = tokio_util::sync::CancellationToken::new();
7856        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7857
7858        let start_handle = tokio::spawn(async move {
7859            consumer.start(ctx).await.unwrap();
7860        });
7861
7862        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7863
7864        let client = reqwest::Client::new();
7865        let resp = client
7866            .post(format!("http://127.0.0.1:{port}/ping"))
7867            .body("hello world")
7868            .send()
7869            .await
7870            .unwrap();
7871        assert_eq!(resp.status().as_u16(), 503);
7872
7873        token.cancel();
7874        let _ = start_handle.await;
7875    }
7876
7877    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
7878    /// waits for the listener bind before publishing RouteStarted.
7879    #[test]
7880    fn test_http_consumer_startup_mode_is_explicit() {
7881        use camel_component_api::ConsumerStartupMode;
7882        let consumer_cfg = HttpServerConfig {
7883            scheme: "http".to_string(),
7884            host: "127.0.0.1".to_string(),
7885            port: 0,
7886            path: "/x".to_string(),
7887            max_request_body: 2 * 1024 * 1024,
7888            max_response_body: 10 * 1024 * 1024,
7889            max_inflight_requests: 1024,
7890            method: None,
7891            tls_config: None,
7892        };
7893        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
7894        assert_eq!(
7895            consumer.startup_mode(),
7896            ConsumerStartupMode::Explicit,
7897            "HttpConsumer must opt into Explicit startup"
7898        );
7899    }
7900
7901    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
7902    /// + route registration. The StartupSignal resolves Ok only when that
7903    /// happens. Verified here by injecting our own signal pair into the
7904    /// ConsumerContext and asserting the receiver resolves within a bounded
7905    /// window even before any HTTP request is made.
7906    #[allow(clippy::await_holding_lock)]
7907    #[tokio::test]
7908    async fn test_http_consumer_emits_mark_ready_after_bind() {
7909        use camel_component_api::{ConsumerContext, StartupSignal};
7910
7911        let _guard = lock_registry_test_mutex();
7912
7913        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7914        let port = listener.local_addr().unwrap().port();
7915        drop(listener);
7916
7917        let consumer_cfg = HttpServerConfig {
7918            scheme: "http".to_string(),
7919            host: "127.0.0.1".to_string(),
7920            port,
7921            path: "/ready-probe".to_string(),
7922            max_request_body: 2 * 1024 * 1024,
7923            max_response_body: 10 * 1024 * 1024,
7924            max_inflight_requests: 1024,
7925            method: None,
7926            tls_config: None,
7927        };
7928        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7929
7930        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7931        let token = tokio_util::sync::CancellationToken::new();
7932        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
7933
7934        // Inject our own startup signal so we can observe mark_ready.
7935        let (signal, startup_rx) = StartupSignal::pair();
7936        let ctx = ctx.with_startup(signal);
7937
7938        // Spawn start() — it MUST call mark_ready once the listener is bound
7939        // and the path is registered.
7940        tokio::spawn(async move {
7941            let _ = consumer.start(ctx).await;
7942        });
7943
7944        // The receiver MUST resolve Ok within a bounded window — proving
7945        // mark_ready was called by start(). A short timeout catches the
7946        // regression where mark_ready is never called (the old behaviour
7947        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
7948        let result =
7949            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
7950                .await
7951                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
7952        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
7953
7954        // Cancellation tears down the spawned start() loop.
7955        token.cancel();
7956    }
7957
7958    // -----------------------------------------------------------------------
7959    // Shared-server death supervision (rc-szmob / ADR-0007)
7960    // -----------------------------------------------------------------------
7961
7962    /// RuntimeObservability stub that records every `increment_errors`
7963    /// `(route_id, label)` pair so tests can assert error counters.
7964    #[derive(Default, Clone)]
7965    struct ErrorRecordingRuntime {
7966        errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
7967    }
7968
7969    impl camel_api::MetricsCollector for ErrorRecordingRuntime {
7970        fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
7971        fn increment_errors(&self, route_id: &str, error_type: &str) {
7972            self.errors
7973                .lock()
7974                .expect("error recorder lock")
7975                .push((route_id.to_string(), error_type.to_string()));
7976        }
7977        fn increment_exchanges(&self, _route_id: &str) {}
7978        fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
7979        fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
7980    }
7981
7982    impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
7983        fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
7984    }
7985
7986    impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
7987        fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
7988            std::sync::Arc::new(self.clone())
7989        }
7990        fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
7991            std::sync::Arc::new(self.clone())
7992        }
7993    }
7994
7995    /// rc-szmob (ADR-0007 parity): when the shared Axum server task for a
7996    /// host:port dies, EVERY HttpConsumer hosted on that port must fail its
7997    /// `start()` with an Err — that Err is the signal camel-core's consumer
7998    /// watcher turns into a per-route CrashNotification → FailRoute →
7999    /// supervision backoff restart. Before the fix the consumers hung in
8000    /// `Running` forever (zombie routes): neither `ctx.cancelled()` nor
8001    /// `env_rx.recv()` fires when the server task dies, because the envelope
8002    /// senders live in the (still-alive) registry, not in the dead task.
8003    ///
8004    /// Deterministic by construction: readiness is awaited via the injected
8005    /// StartupSignal (no sleeps), the server is killed via its AbortHandle
8006    /// (real JoinError → monitor's unexpected-exit branch), and consumer
8007    /// resolution is bounded by a timeout — on unmodified behavior the
8008    /// timeout trips, which is exactly the zombie this test pins down.
8009    #[allow(clippy::await_holding_lock)]
8010    #[tokio::test]
8011    async fn shared_server_death_fails_every_hosted_consumer() {
8012        use camel_component_api::{ConsumerContext, StartupSignal};
8013
8014        let _guard = lock_registry_test_mutex();
8015        ServerRegistry::reset();
8016
8017        // Reserve a port, release it, let get_or_spawn bind it.
8018        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8019        let port = listener.local_addr().unwrap().port();
8020        drop(listener);
8021
8022        let rt = ErrorRecordingRuntime::default();
8023
8024        let make_consumer = |path: &str| {
8025            HttpConsumer::new(
8026                HttpServerConfig {
8027                    scheme: "http".to_string(),
8028                    host: "127.0.0.1".to_string(),
8029                    port,
8030                    path: path.to_string(),
8031                    max_request_body: 2 * 1024 * 1024,
8032                    max_response_body: 10 * 1024 * 1024,
8033                    max_inflight_requests: 16,
8034                    method: None,
8035                    tls_config: None,
8036                },
8037                std::sync::Arc::new(rt.clone()),
8038            )
8039        };
8040
8041        let spawn_consumer = |path: &str, route_id: &str| {
8042            let mut consumer = make_consumer(path);
8043            let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8044            let token = tokio_util::sync::CancellationToken::new();
8045            let ctx = ConsumerContext::new(tx, token, route_id.to_string());
8046            let (signal, startup_rx) = StartupSignal::pair();
8047            let ctx = ctx.with_startup(signal);
8048            let task = tokio::spawn(async move { consumer.start(ctx).await });
8049            (task, startup_rx)
8050        };
8051
8052        // Two routes hosted on the SAME shared server (same host:port).
8053        let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
8054        let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
8055
8056        // Both consumers registered and the server is up (bounded, no sleeps).
8057        for (name, ready) in [("a", ready_a), ("b", ready_b)] {
8058            let result =
8059                tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
8060                    .await
8061                    .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
8062            assert!(
8063                result.is_ok(),
8064                "consumer {name} readiness must resolve Ok (bind + registration complete)"
8065            );
8066        }
8067
8068        // Kill the shared server task: abort → JoinError → the monitor's
8069        // unexpected-exit branch. This is the real crash path (no mock).
8070        {
8071            let registry = ServerRegistry::global();
8072            let guard = registry.inner.lock().expect("ServerRegistry lock");
8073            let cell = guard
8074                .entries
8075                .get(&("127.0.0.1".to_string(), port))
8076                .expect("shared server entry must exist");
8077            let handle = cell.get().expect("server handle must be initialized");
8078            handle.server_abort.abort();
8079        }
8080
8081        // THE assertion: both hosted consumers must fail (bounded). On the
8082        // zombie bug they never resolve and this timeout trips.
8083        let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
8084            .await
8085            .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
8086        let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
8087            .await
8088            .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
8089
8090        let err_a = outcome_a
8091            .expect("consumer-a task must join")
8092            .expect_err("consumer-a start() must return Err when the shared server dies");
8093        let err_b = outcome_b
8094            .expect("consumer-b task must join")
8095            .expect_err("consumer-b start() must return Err when the shared server dies");
8096
8097        // The error must identify the dead shared transport (it flows into the
8098        // CrashNotification message camel-core records against the route).
8099        for (name, err) in [("a", &err_a), ("b", &err_b)] {
8100            assert!(
8101                err.to_string().contains("127.0.0.1")
8102                    && err.to_string().contains(&port.to_string()),
8103                "consumer-{name} error must name the dead shared server, got: {err}"
8104            );
8105        }
8106
8107        // Error counter regression guard: the monitor still records
8108        // `e:http:server-task-exited` for the route that spawned the server.
8109        let recorded = rt.errors.lock().expect("error recorder lock").clone();
8110        assert!(
8111            recorded
8112                .iter()
8113                .any(|(route, label)| label == "e:http:server-task-exited"
8114                    && route == "zombie-route-a"),
8115            "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
8116        );
8117    }
8118
8119    #[tokio::test]
8120    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
8121        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8122
8123        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8124        let port = listener.local_addr().unwrap().port();
8125        drop(listener);
8126
8127        let consumer_cfg = HttpServerConfig {
8128            scheme: "http".to_string(),
8129            host: "127.0.0.1".to_string(),
8130            port,
8131            path: "/saturation".to_string(),
8132            max_request_body: 2 * 1024 * 1024,
8133            max_response_body: 10 * 1024 * 1024,
8134            max_inflight_requests: 1,
8135            method: None,
8136            tls_config: None,
8137        };
8138        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8139
8140        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8141        let token = tokio_util::sync::CancellationToken::new();
8142        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8143        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8144        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8145
8146        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
8147        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
8148
8149        tokio::spawn(async move {
8150            let mut first_seen_tx = Some(first_seen_tx);
8151            let mut unblock_first_rx = Some(unblock_first_rx);
8152
8153            while let Some(envelope) = rx.recv().await {
8154                if let Some(tx) = first_seen_tx.take() {
8155                    let _ = tx.send(());
8156                    if let Some(rx_unblock) = unblock_first_rx.take() {
8157                        let _ = rx_unblock.await;
8158                    }
8159                }
8160
8161                if let Some(reply_tx) = envelope.reply_tx {
8162                    let _ = reply_tx.send(Ok(envelope.exchange));
8163                }
8164            }
8165        });
8166
8167        let client = reqwest::Client::new();
8168        let first_req = {
8169            let client = client.clone();
8170            async move {
8171                client
8172                    .get(format!("http://127.0.0.1:{port}/saturation"))
8173                    .send()
8174                    .await
8175                    .unwrap()
8176            }
8177        };
8178
8179        let first_handle = tokio::spawn(first_req);
8180        first_seen_rx.await.unwrap();
8181
8182        let second_resp = client
8183            .get(format!("http://127.0.0.1:{port}/saturation"))
8184            .send()
8185            .await
8186            .unwrap();
8187
8188        assert_eq!(second_resp.status().as_u16(), 503);
8189
8190        let _ = unblock_first_tx.send(());
8191        let first_resp = first_handle.await.unwrap();
8192        assert_eq!(first_resp.status().as_u16(), 200);
8193
8194        token.cancel();
8195    }
8196
8197    /// Audit 2026-08-31, F2-1: a chunked (no Content-Length) request body must
8198    /// still be capped — the byte limit travels with the stream, so any
8199    /// downstream materialization fails closed past `max_request_body`.
8200    #[tokio::test]
8201    async fn test_http_consumer_chunked_body_is_capped() {
8202        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8203
8204        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8205        let port = listener.local_addr().unwrap().port();
8206        drop(listener);
8207
8208        let consumer_cfg = HttpServerConfig {
8209            scheme: "http".to_string(),
8210            host: "127.0.0.1".to_string(),
8211            port,
8212            path: "/chunked-cap".to_string(),
8213            max_request_body: 1024, // tiny cap for the test
8214            max_response_body: 10 * 1024 * 1024,
8215            max_inflight_requests: 16,
8216            method: None,
8217            tls_config: None,
8218        };
8219        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8220
8221        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8222        let token = tokio_util::sync::CancellationToken::new();
8223        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8224        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8225        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8226
8227        // Chunked body: reqwest streams it without Content-Length.
8228        let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
8229            .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
8230            .collect();
8231        let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
8232
8233        let client = reqwest::Client::new();
8234        let send_fut = client
8235            .post(format!("http://127.0.0.1:{port}/chunked-cap"))
8236            .body(stream_body)
8237            .send();
8238
8239        let (http_result, _) = tokio::join!(send_fut, async {
8240            if let Some(mut envelope) = rx.recv().await {
8241                // The route materializes the body — the cap must fire.
8242                let materialized = envelope
8243                    .exchange
8244                    .input
8245                    .body
8246                    .clone()
8247                    .into_bytes(64 * 1024)
8248                    .await;
8249                assert!(
8250                    materialized.is_err(),
8251                    "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
8252                );
8253                let err = materialized.unwrap_err().to_string();
8254                assert!(
8255                    err.contains("limit") || err.contains("exceeds"),
8256                    "error should mention the limit: {err}"
8257                );
8258                if let Some(reply_tx) = envelope.reply_tx {
8259                    envelope.exchange.input.body =
8260                        camel_component_api::Body::Text("handled".to_string());
8261                    let _ = reply_tx.send(Ok(envelope.exchange));
8262                }
8263            }
8264        });
8265
8266        let resp = http_result.unwrap();
8267        assert_eq!(resp.status().as_u16(), 200);
8268
8269        token.cancel();
8270    }
8271
8272    #[tokio::test]
8273    #[allow(clippy::await_holding_lock)]
8274    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
8275        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8276
8277        let _guard = lock_registry_test_mutex();
8278
8279        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8280        let port = listener.local_addr().unwrap().port();
8281        drop(listener);
8282
8283        let consumer_cfg = HttpServerConfig {
8284            scheme: "http".to_string(),
8285            host: "127.0.0.1".to_string(),
8286            port,
8287            path: "/limit-bytes".to_string(),
8288            max_request_body: 2 * 1024 * 1024,
8289            max_response_body: 16,
8290            max_inflight_requests: 1024,
8291            method: None,
8292            tls_config: None,
8293        };
8294        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8295
8296        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8297        let token = tokio_util::sync::CancellationToken::new();
8298        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8299        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8300        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8301
8302        let client = reqwest::Client::new();
8303        let send_fut = client
8304            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
8305            .send();
8306
8307        let (http_result, _) = tokio::join!(send_fut, async {
8308            if let Some(mut envelope) = rx.recv().await {
8309                envelope.exchange.input.body =
8310                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
8311                if let Some(reply_tx) = envelope.reply_tx {
8312                    let _ = reply_tx.send(Ok(envelope.exchange));
8313                }
8314            }
8315        });
8316
8317        let resp = http_result.unwrap();
8318        assert_eq!(resp.status().as_u16(), 500);
8319        let body = resp.text().await.unwrap();
8320        assert_eq!(body, "Response body exceeds configured limit");
8321        token.cancel();
8322    }
8323
8324    #[tokio::test]
8325    #[allow(clippy::await_holding_lock)]
8326    async fn test_http_consumer_enforces_max_response_body_for_json() {
8327        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8328
8329        let _guard = lock_registry_test_mutex();
8330
8331        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8332        let port = listener.local_addr().unwrap().port();
8333        drop(listener);
8334
8335        let consumer_cfg = HttpServerConfig {
8336            scheme: "http".to_string(),
8337            host: "127.0.0.1".to_string(),
8338            port,
8339            path: "/limit-json".to_string(),
8340            max_request_body: 2 * 1024 * 1024,
8341            max_response_body: 16,
8342            max_inflight_requests: 1024,
8343            method: None,
8344            tls_config: None,
8345        };
8346        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8347
8348        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8349        let token = tokio_util::sync::CancellationToken::new();
8350        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8351        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8352        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8353
8354        let client = reqwest::Client::new();
8355        let send_fut = client
8356            .get(format!("http://127.0.0.1:{port}/limit-json"))
8357            .send();
8358
8359        let (http_result, _) = tokio::join!(send_fut, async {
8360            if let Some(mut envelope) = rx.recv().await {
8361                envelope.exchange.input.body = camel_component_api::Body::Json(
8362                    serde_json::json!({"message":"this response is bigger than sixteen"}),
8363                );
8364                if let Some(reply_tx) = envelope.reply_tx {
8365                    let _ = reply_tx.send(Ok(envelope.exchange));
8366                }
8367            }
8368        });
8369
8370        let resp = http_result.unwrap();
8371        assert_eq!(resp.status().as_u16(), 500);
8372        let body = resp.text().await.unwrap();
8373        assert_eq!(body, "Response body exceeds configured limit");
8374        token.cancel();
8375    }
8376
8377    #[tokio::test]
8378    #[allow(clippy::await_holding_lock)]
8379    async fn test_http_consumer_enforces_max_response_body_for_xml() {
8380        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8381
8382        let _guard = lock_registry_test_mutex();
8383
8384        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8385        let port = listener.local_addr().unwrap().port();
8386        drop(listener);
8387
8388        let consumer_cfg = HttpServerConfig {
8389            scheme: "http".to_string(),
8390            host: "127.0.0.1".to_string(),
8391            port,
8392            path: "/limit-xml".to_string(),
8393            max_request_body: 2 * 1024 * 1024,
8394            max_response_body: 16,
8395            max_inflight_requests: 1024,
8396            method: None,
8397            tls_config: None,
8398        };
8399        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8400
8401        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8402        let token = tokio_util::sync::CancellationToken::new();
8403        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8404        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8405        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8406
8407        let client = reqwest::Client::new();
8408        let send_fut = client
8409            .get(format!("http://127.0.0.1:{port}/limit-xml"))
8410            .send();
8411
8412        let (http_result, _) = tokio::join!(send_fut, async {
8413            if let Some(mut envelope) = rx.recv().await {
8414                envelope.exchange.input.body = camel_component_api::Body::Xml(
8415                    "<root><value>way-too-large</value></root>".into(),
8416                );
8417                if let Some(reply_tx) = envelope.reply_tx {
8418                    let _ = reply_tx.send(Ok(envelope.exchange));
8419                }
8420            }
8421        });
8422
8423        let resp = http_result.unwrap();
8424        assert_eq!(resp.status().as_u16(), 500);
8425        let body = resp.text().await.unwrap();
8426        assert_eq!(body, "Response body exceeds configured limit");
8427        token.cancel();
8428    }
8429
8430    #[tokio::test]
8431    #[allow(clippy::await_holding_lock)]
8432    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
8433        use camel_component_api::{
8434            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
8435        };
8436        use futures::stream;
8437
8438        let _guard = lock_registry_test_mutex();
8439
8440        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
8441        let port = listener.local_addr().unwrap().port();
8442        drop(listener);
8443
8444        let consumer_cfg = HttpServerConfig {
8445            scheme: "http".to_string(),
8446            host: "0.0.0.0".to_string(),
8447            port,
8448            path: "/limit-stream".to_string(),
8449            max_request_body: 2 * 1024 * 1024,
8450            max_response_body: 16,
8451            max_inflight_requests: 1024,
8452            method: None,
8453            tls_config: None,
8454        };
8455        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8456
8457        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8458        let token = tokio_util::sync::CancellationToken::new();
8459        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8460        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8461        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8462
8463        let client = reqwest::Client::new();
8464        let send_fut = client
8465            .get(format!("http://127.0.0.1:{port}/limit-stream"))
8466            .send();
8467
8468        let (http_result, _) = tokio::join!(send_fut, async {
8469            if let Some(mut envelope) = rx.recv().await {
8470                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
8471                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
8472                let stream = Box::pin(stream::iter(chunks));
8473                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
8474                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
8475                    metadata: StreamMetadata {
8476                        size_hint: Some(32),
8477                        content_type: Some("application/octet-stream".into()),
8478                        origin: None,
8479                    },
8480                });
8481                if let Some(reply_tx) = envelope.reply_tx {
8482                    let _ = reply_tx.send(Ok(envelope.exchange));
8483                }
8484            }
8485        });
8486
8487        let resp = http_result.unwrap();
8488        assert_eq!(resp.status().as_u16(), 200);
8489        let body = resp.bytes().await.unwrap();
8490        assert_eq!(body.len(), 32);
8491        token.cancel();
8492    }
8493
8494    // -----------------------------------------------------------------------
8495    // Integration tests
8496    // -----------------------------------------------------------------------
8497
8498    #[tokio::test]
8499    #[allow(clippy::await_holding_lock)]
8500    async fn test_integration_single_consumer_round_trip() {
8501        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8502
8503        // Spawns an HTTP consumer on the global ServerRegistry
8504        // (HttpConsumer::start → get_or_spawn). Serialize against the other
8505        // registry tests so parallel runs do not race on shared global state.
8506        let _guard = lock_registry_test_mutex();
8507
8508        // Get an OS-assigned free port (ephemeral)
8509        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8510        let port = listener.local_addr().unwrap().port();
8511        drop(listener); // Release — ServerRegistry will rebind
8512
8513        let component = HttpComponent::new();
8514        let endpoint_ctx = NoOpComponentContext;
8515        let endpoint = component
8516            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
8517            .unwrap();
8518        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8519
8520        let (tx, mut rx) = tokio::sync::mpsc::channel::<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        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8525        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8526
8527        let client = reqwest::Client::new();
8528        let send_fut = client
8529            .post(format!("http://127.0.0.1:{port}/echo"))
8530            .header("Content-Type", "text/plain")
8531            .body("ping")
8532            .send();
8533
8534        let (http_result, _) = tokio::join!(send_fut, async {
8535            if let Some(mut envelope) = rx.recv().await {
8536                assert_eq!(
8537                    envelope.exchange.input.header("CamelHttpMethod"),
8538                    Some(&serde_json::Value::String("POST".into()))
8539                );
8540                assert_eq!(
8541                    envelope.exchange.input.header("CamelHttpPath"),
8542                    Some(&serde_json::Value::String("/echo".into()))
8543                );
8544                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
8545                if let Some(reply_tx) = envelope.reply_tx {
8546                    let _ = reply_tx.send(Ok(envelope.exchange));
8547                }
8548            }
8549        });
8550
8551        let resp = http_result.unwrap();
8552        assert_eq!(resp.status().as_u16(), 200);
8553        let body = resp.text().await.unwrap();
8554        assert_eq!(body, "pong");
8555
8556        token.cancel();
8557    }
8558
8559    #[tokio::test]
8560    #[allow(clippy::await_holding_lock)]
8561    async fn test_integration_two_consumers_shared_port() {
8562        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8563
8564        let _guard = lock_registry_test_mutex();
8565
8566        // Get an OS-assigned free port (ephemeral)
8567        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8568        let port = listener.local_addr().unwrap().port();
8569        drop(listener);
8570
8571        let component = HttpComponent::new();
8572        let endpoint_ctx = NoOpComponentContext;
8573
8574        // Consumer A: /hello
8575        let endpoint_a = component
8576            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
8577            .unwrap();
8578        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
8579
8580        // Consumer B: /world
8581        let endpoint_b = component
8582            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
8583            .unwrap();
8584        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
8585
8586        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8587        let token_a = tokio_util::sync::CancellationToken::new();
8588        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
8589
8590        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8591        let token_b = tokio_util::sync::CancellationToken::new();
8592        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
8593
8594        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
8595        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
8596        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8597
8598        let client = reqwest::Client::new();
8599
8600        // Request to /hello
8601        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
8602        let (resp_hello, _) = tokio::join!(fut_hello, async {
8603            if let Some(mut envelope) = rx_a.recv().await {
8604                envelope.exchange.input.body =
8605                    camel_component_api::Body::Text("hello-response".to_string());
8606                if let Some(reply_tx) = envelope.reply_tx {
8607                    let _ = reply_tx.send(Ok(envelope.exchange));
8608                }
8609            }
8610        });
8611
8612        // Request to /world
8613        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
8614        let (resp_world, _) = tokio::join!(fut_world, async {
8615            if let Some(mut envelope) = rx_b.recv().await {
8616                envelope.exchange.input.body =
8617                    camel_component_api::Body::Text("world-response".to_string());
8618                if let Some(reply_tx) = envelope.reply_tx {
8619                    let _ = reply_tx.send(Ok(envelope.exchange));
8620                }
8621            }
8622        });
8623
8624        let body_a = resp_hello.unwrap().text().await.unwrap();
8625        let body_b = resp_world.unwrap().text().await.unwrap();
8626
8627        assert_eq!(body_a, "hello-response");
8628        assert_eq!(body_b, "world-response");
8629
8630        token_a.cancel();
8631        token_b.cancel();
8632    }
8633
8634    #[tokio::test]
8635    #[allow(clippy::await_holding_lock)]
8636    async fn test_integration_unregistered_path_returns_404() {
8637        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8638
8639        let _guard = lock_registry_test_mutex();
8640
8641        // Get an OS-assigned free port (ephemeral)
8642        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8643        let port = listener.local_addr().unwrap().port();
8644        drop(listener);
8645
8646        let component = HttpComponent::new();
8647        let endpoint_ctx = NoOpComponentContext;
8648        let endpoint = component
8649            .create_endpoint(
8650                &format!("http://127.0.0.1:{port}/registered"),
8651                &endpoint_ctx,
8652            )
8653            .unwrap();
8654        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8655
8656        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8657        let token = tokio_util::sync::CancellationToken::new();
8658        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8659
8660        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8661
8662        // Wait until the server is actually accepting connections (CI runners can be slow).
8663        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8664        loop {
8665            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
8666                .await
8667                .is_ok()
8668            {
8669                break;
8670            }
8671            if std::time::Instant::now() >= deadline {
8672                panic!("HTTP server did not start within 5s on port {port}");
8673            }
8674            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8675        }
8676
8677        let client = reqwest::Client::new();
8678        let resp = client
8679            .get(format!("http://127.0.0.1:{port}/not-there"))
8680            .send()
8681            .await
8682            .unwrap();
8683        assert_eq!(resp.status().as_u16(), 404);
8684
8685        token.cancel();
8686    }
8687
8688    #[test]
8689    fn test_http_consumer_declares_concurrent() {
8690        use camel_component_api::ConcurrencyModel;
8691
8692        let config = HttpServerConfig {
8693            scheme: "http".to_string(),
8694            host: "127.0.0.1".to_string(),
8695            port: 19999,
8696            path: "/test".to_string(),
8697            max_request_body: 2 * 1024 * 1024,
8698            max_response_body: 10 * 1024 * 1024,
8699            max_inflight_requests: 1024,
8700            method: None,
8701            tls_config: None,
8702        };
8703        let consumer = HttpConsumer::new(config, test_rt());
8704        assert_eq!(
8705            consumer.concurrency_model(),
8706            ConcurrencyModel::Concurrent { max: None }
8707        );
8708    }
8709
8710    #[test]
8711    fn server_config_parses_tls_cert_and_key() {
8712        let cfg = HttpServerConfig::from_uri(
8713            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
8714        )
8715        .unwrap();
8716        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
8717        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
8718    }
8719
8720    #[test]
8721    fn server_config_no_tls_when_params_absent() {
8722        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
8723        assert!(cfg.tls_config.is_none());
8724    }
8725
8726    // -----------------------------------------------------------------------
8727    // HttpReplyBody streaming tests
8728    // -----------------------------------------------------------------------
8729
8730    #[tokio::test]
8731    async fn test_http_reply_body_stream_variant_exists() {
8732        use bytes::Bytes;
8733        use camel_component_api::CamelError;
8734        use futures::stream;
8735
8736        let chunks: Vec<Result<Bytes, CamelError>> =
8737            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
8738        let stream = Box::pin(stream::iter(chunks));
8739        let reply_body = HttpReplyBody::Stream(stream);
8740        // Si compila y el match funciona, el test pasa
8741        match reply_body {
8742            HttpReplyBody::Stream(_) => {}
8743            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
8744        }
8745    }
8746
8747    // -----------------------------------------------------------------------
8748    // OpenTelemetry propagation tests (only compiled with "otel" feature)
8749    // -----------------------------------------------------------------------
8750
8751    #[cfg(feature = "otel")]
8752    mod otel_tests {
8753        use super::*;
8754        use camel_component_api::Message;
8755        use tower::ServiceExt;
8756
8757        #[tokio::test]
8758        async fn test_producer_injects_traceparent_header() {
8759            let (url, _handle) = start_test_server_with_header_capture().await;
8760            let ctx = test_producer_ctx();
8761
8762            let component = HttpComponent::new();
8763            let endpoint_ctx = NoOpComponentContext;
8764            let endpoint = component
8765                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8766                .unwrap();
8767            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8768
8769            // Create exchange with an OTel context by extracting from a traceparent header
8770            let mut exchange = Exchange::new(Message::default());
8771            let mut headers = std::collections::HashMap::new();
8772            headers.insert(
8773                "traceparent".to_string(),
8774                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
8775            );
8776            camel_otel::extract_into_exchange(&mut exchange, &headers);
8777
8778            let result = producer.oneshot(exchange).await.unwrap();
8779
8780            // Verify request succeeded
8781            let status = result
8782                .input
8783                .header("CamelHttpResponseCode")
8784                .and_then(|v| v.as_u64())
8785                .unwrap();
8786            assert_eq!(status, 200);
8787
8788            // The test server echoes back the received traceparent header
8789            let traceparent = result.input.header("X-Received-Traceparent");
8790            assert!(
8791                traceparent.is_some(),
8792                "traceparent header should have been sent"
8793            );
8794
8795            let traceparent_str = traceparent.unwrap().as_str().unwrap();
8796            // Verify format: version-traceid-spanid-flags
8797            let parts: Vec<&str> = traceparent_str.split('-').collect();
8798            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8799            assert_eq!(parts[0], "00", "version should be 00");
8800            assert_eq!(
8801                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8802                "trace-id should match"
8803            );
8804            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
8805            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
8806        }
8807
8808        #[tokio::test]
8809        async fn test_consumer_extracts_traceparent_header() {
8810            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8811
8812            // Get an OS-assigned free port
8813            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8814            let port = listener.local_addr().unwrap().port();
8815            drop(listener);
8816
8817            let component = HttpComponent::new();
8818            let endpoint_ctx = NoOpComponentContext;
8819            let endpoint = component
8820                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8821                .unwrap();
8822            let mut consumer = endpoint.create_consumer(rt()).unwrap();
8823
8824            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8825            let token = tokio_util::sync::CancellationToken::new();
8826            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8827
8828            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8829            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8830
8831            // Send request with traceparent header
8832            let client = reqwest::Client::new();
8833            let send_fut = client
8834                .post(format!("http://127.0.0.1:{port}/trace"))
8835                .header(
8836                    "traceparent",
8837                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8838                )
8839                .body("test")
8840                .send();
8841
8842            let (http_result, _) = tokio::join!(send_fut, async {
8843                if let Some(envelope) = rx.recv().await {
8844                    // Verify the exchange has a valid OTel context by re-injecting it
8845                    // and checking the traceparent matches
8846                    let mut injected_headers = std::collections::HashMap::new();
8847                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8848
8849                    assert!(
8850                        injected_headers.contains_key("traceparent"),
8851                        "Exchange should have traceparent after extraction"
8852                    );
8853
8854                    let traceparent = injected_headers.get("traceparent").unwrap();
8855                    let parts: Vec<&str> = traceparent.split('-').collect();
8856                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8857                    assert_eq!(
8858                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8859                        "Trace ID should match the original traceparent header"
8860                    );
8861
8862                    if let Some(reply_tx) = envelope.reply_tx {
8863                        let _ = reply_tx.send(Ok(envelope.exchange));
8864                    }
8865                }
8866            });
8867
8868            let resp = http_result.unwrap();
8869            assert_eq!(resp.status().as_u16(), 200);
8870
8871            token.cancel();
8872        }
8873
8874        #[tokio::test]
8875        async fn test_consumer_extracts_mixed_case_traceparent_header() {
8876            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8877
8878            // Get an OS-assigned free port
8879            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8880            let port = listener.local_addr().unwrap().port();
8881            drop(listener);
8882
8883            let component = HttpComponent::new();
8884            let endpoint_ctx = NoOpComponentContext;
8885            let endpoint = component
8886                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8887                .unwrap();
8888            let mut consumer = endpoint.create_consumer(rt()).unwrap();
8889
8890            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8891            let token = tokio_util::sync::CancellationToken::new();
8892            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8893
8894            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8895            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8896
8897            // Send request with MIXED-CASE TraceParent header (not lowercase)
8898            let client = reqwest::Client::new();
8899            let send_fut = client
8900                .post(format!("http://127.0.0.1:{port}/trace"))
8901                .header(
8902                    "TraceParent",
8903                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8904                )
8905                .body("test")
8906                .send();
8907
8908            let (http_result, _) = tokio::join!(send_fut, async {
8909                if let Some(envelope) = rx.recv().await {
8910                    // Verify the exchange has a valid OTel context by re-injecting it
8911                    // and checking the traceparent matches
8912                    let mut injected_headers = HashMap::new();
8913                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8914
8915                    assert!(
8916                        injected_headers.contains_key("traceparent"),
8917                        "Exchange should have traceparent after extraction from mixed-case header"
8918                    );
8919
8920                    let traceparent = injected_headers.get("traceparent").unwrap();
8921                    let parts: Vec<&str> = traceparent.split('-').collect();
8922                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8923                    assert_eq!(
8924                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8925                        "Trace ID should match the original mixed-case TraceParent header"
8926                    );
8927
8928                    if let Some(reply_tx) = envelope.reply_tx {
8929                        let _ = reply_tx.send(Ok(envelope.exchange));
8930                    }
8931                }
8932            });
8933
8934            let resp = http_result.unwrap();
8935            assert_eq!(resp.status().as_u16(), 200);
8936
8937            token.cancel();
8938        }
8939
8940        #[tokio::test]
8941        async fn test_producer_no_trace_context_no_crash() {
8942            let (url, _handle) = start_test_server().await;
8943            let ctx = test_producer_ctx();
8944
8945            let component = HttpComponent::new();
8946            let endpoint_ctx = NoOpComponentContext;
8947            let endpoint = component
8948                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8949                .unwrap();
8950            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8951
8952            // Create exchange with default (empty) otel_context - no trace context
8953            let exchange = Exchange::new(Message::default());
8954
8955            // Should succeed without panic
8956            let result = producer.oneshot(exchange).await.unwrap();
8957
8958            // Verify request succeeded
8959            let status = result
8960                .input
8961                .header("CamelHttpResponseCode")
8962                .and_then(|v| v.as_u64())
8963                .unwrap();
8964            assert_eq!(status, 200);
8965        }
8966
8967        /// Test server that captures and echoes back the traceparent header
8968        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
8969            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8970            let addr = listener.local_addr().unwrap();
8971            let url = format!("http://127.0.0.1:{}", addr.port());
8972
8973            let handle = tokio::spawn(async move {
8974                loop {
8975                    if let Ok((mut stream, _)) = listener.accept().await {
8976                        tokio::spawn(async move {
8977                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
8978                            let mut buf = vec![0u8; 8192];
8979                            let n = stream.read(&mut buf).await.unwrap_or(0);
8980                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
8981
8982                            // Extract traceparent header from request
8983                            let traceparent = request
8984                                .lines()
8985                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
8986                                .map(|line| {
8987                                    line.split(':')
8988                                        .nth(1)
8989                                        .map(|s| s.trim().to_string())
8990                                        .unwrap_or_default()
8991                                })
8992                                .unwrap_or_default();
8993
8994                            let body =
8995                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
8996                            let response = format!(
8997                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
8998                                body.len(),
8999                                traceparent,
9000                                body
9001                            );
9002                            let _ = stream.write_all(response.as_bytes()).await;
9003                        });
9004                    }
9005                }
9006            });
9007
9008            (url, handle)
9009        }
9010    }
9011
9012    // -----------------------------------------------------------------------
9013    // Response streaming tests (Eje A - Task 2)
9014    // -----------------------------------------------------------------------
9015
9016    // -----------------------------------------------------------------------
9017    // Request streaming tests (Eje B - Task 3)
9018    // -----------------------------------------------------------------------
9019
9020    #[tokio::test]
9021    async fn test_request_body_arrives_as_stream() {
9022        use camel_component_api::Body;
9023        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9024
9025        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9026        let port = listener.local_addr().unwrap().port();
9027        drop(listener);
9028
9029        let component = HttpComponent::new();
9030        let endpoint_ctx = NoOpComponentContext;
9031        let endpoint = component
9032            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
9033            .unwrap();
9034        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9035
9036        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9037        let token = tokio_util::sync::CancellationToken::new();
9038        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9039
9040        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9041        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9042
9043        let client = reqwest::Client::new();
9044        let send_fut = client
9045            .post(format!("http://127.0.0.1:{port}/upload"))
9046            .body("hello streaming world")
9047            .send();
9048
9049        let (http_result, _) = tokio::join!(send_fut, async {
9050            if let Some(mut envelope) = rx.recv().await {
9051                // Body must be Body::Stream, not Body::Text or Body::Bytes
9052                assert!(
9053                    matches!(envelope.exchange.input.body, Body::Stream(_)),
9054                    "expected Body::Stream, got discriminant {:?}",
9055                    std::mem::discriminant(&envelope.exchange.input.body)
9056                );
9057                // Materialize to verify content
9058                let bytes = envelope
9059                    .exchange
9060                    .input
9061                    .body
9062                    .into_bytes(1024 * 1024)
9063                    .await
9064                    .unwrap();
9065                assert_eq!(&bytes[..], b"hello streaming world");
9066
9067                envelope.exchange.input.body = camel_component_api::Body::Empty;
9068                if let Some(reply_tx) = envelope.reply_tx {
9069                    let _ = reply_tx.send(Ok(envelope.exchange));
9070                }
9071            }
9072        });
9073
9074        let resp = http_result.unwrap();
9075        assert_eq!(resp.status().as_u16(), 200);
9076
9077        token.cancel();
9078    }
9079
9080    // -----------------------------------------------------------------------
9081    // Response streaming tests (Eje A - Task 2)
9082    // -----------------------------------------------------------------------
9083
9084    #[tokio::test]
9085    async fn test_streaming_response_chunked() {
9086        use bytes::Bytes;
9087        use camel_component_api::Body;
9088        use camel_component_api::CamelError;
9089        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9090        use camel_component_api::{StreamBody, StreamMetadata};
9091        use futures::stream;
9092        use std::sync::Arc;
9093        use tokio::sync::Mutex;
9094
9095        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9096        let port = listener.local_addr().unwrap().port();
9097        drop(listener);
9098
9099        let component = HttpComponent::new();
9100        let endpoint_ctx = NoOpComponentContext;
9101        let endpoint = component
9102            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
9103            .unwrap();
9104        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9105
9106        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9107        let token = tokio_util::sync::CancellationToken::new();
9108        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9109
9110        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9111        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9112
9113        let client = reqwest::Client::new();
9114        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
9115
9116        let (http_result, _) = tokio::join!(send_fut, async {
9117            if let Some(mut envelope) = rx.recv().await {
9118                // Respond with Body::Stream
9119                let chunks: Vec<Result<Bytes, CamelError>> =
9120                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
9121                let stream = Box::pin(stream::iter(chunks));
9122                envelope.exchange.input.body = Body::Stream(StreamBody {
9123                    stream: Arc::new(Mutex::new(Some(stream))),
9124                    metadata: StreamMetadata::default(),
9125                });
9126                if let Some(reply_tx) = envelope.reply_tx {
9127                    let _ = reply_tx.send(Ok(envelope.exchange));
9128                }
9129            }
9130        });
9131
9132        let resp = http_result.unwrap();
9133        assert_eq!(resp.status().as_u16(), 200);
9134        let body = resp.text().await.unwrap();
9135        assert_eq!(body, "chunk1chunk2");
9136
9137        token.cancel();
9138    }
9139
9140    // -----------------------------------------------------------------------
9141    // 413 Content-Length limit test (Task 4)
9142    // -----------------------------------------------------------------------
9143
9144    #[tokio::test]
9145    async fn test_413_when_content_length_exceeds_limit() {
9146        use camel_component_api::ConsumerContext;
9147
9148        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9149        let port = listener.local_addr().unwrap().port();
9150        drop(listener);
9151
9152        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
9153        let component = HttpComponent::new();
9154        let endpoint_ctx = NoOpComponentContext;
9155        let endpoint = component
9156            .create_endpoint(
9157                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
9158                &endpoint_ctx,
9159            )
9160            .unwrap();
9161        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9162
9163        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9164        let token = tokio_util::sync::CancellationToken::new();
9165        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9166
9167        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9168        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9169
9170        let client = reqwest::Client::new();
9171        let resp = client
9172            .post(format!("http://127.0.0.1:{port}/upload"))
9173            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
9174            .body("x".repeat(1000))
9175            .send()
9176            .await
9177            .unwrap();
9178
9179        assert_eq!(resp.status().as_u16(), 413);
9180
9181        token.cancel();
9182    }
9183
9184    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
9185    /// The spec says: "If there is no Content-Length, the limit does not apply at the
9186    /// consumer level — the route is responsible."
9187    #[tokio::test]
9188    async fn test_chunked_upload_without_content_length_bypasses_limit() {
9189        use bytes::Bytes;
9190        use camel_component_api::Body;
9191        use camel_component_api::ConsumerContext;
9192        use futures::stream;
9193
9194        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9195        let port = listener.local_addr().unwrap().port();
9196        drop(listener);
9197
9198        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
9199        let component = HttpComponent::new();
9200        let endpoint_ctx = NoOpComponentContext;
9201        let endpoint = component
9202            .create_endpoint(
9203                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
9204                &endpoint_ctx,
9205            )
9206            .unwrap();
9207        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9208
9209        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9210        let token = tokio_util::sync::CancellationToken::new();
9211        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9212
9213        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9214        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9215
9216        let client = reqwest::Client::new();
9217
9218        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
9219        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
9220        // but since there's no Content-Length the 413 check must NOT fire.
9221        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
9222            Ok(Bytes::from("y".repeat(50))),
9223            Ok(Bytes::from("y".repeat(50))),
9224        ];
9225        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
9226        let send_fut = client
9227            .post(format!("http://127.0.0.1:{port}/upload"))
9228            .body(stream_body)
9229            .send();
9230
9231        let consumer_fut = async {
9232            // Use timeout to avoid deadlock if the handler rejects before enqueueing
9233            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
9234                Ok(Some(mut envelope)) => {
9235                    assert!(
9236                        matches!(envelope.exchange.input.body, Body::Stream(_)),
9237                        "expected Body::Stream"
9238                    );
9239                    envelope.exchange.input.body = camel_component_api::Body::Empty;
9240                    if let Some(reply_tx) = envelope.reply_tx {
9241                        let _ = reply_tx.send(Ok(envelope.exchange));
9242                    }
9243                }
9244                Ok(None) => panic!("consumer channel closed unexpectedly"),
9245                Err(_) => {
9246                    // Timeout: the request was rejected before reaching the consumer.
9247                    // The HTTP response will carry the real status code (we check below).
9248                }
9249            }
9250        };
9251
9252        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
9253
9254        let resp = http_result.unwrap();
9255        // Audit 2026-08-31 (F2-1): the request is no longer rejected at the door
9256        // (no Content-Length to pre-check), but the byte cap now travels with the
9257        // stream: ANY materialization past maxRequestBody fails closed. This test
9258        // does not consume the body, so the request still completes with 200 —
9259        // enforcement happens at consumption time (see
9260        // test_http_consumer_chunked_body_is_capped).
9261        assert_ne!(
9262            resp.status().as_u16(),
9263            413,
9264            "chunked upload has no Content-Length to pre-check"
9265        );
9266        assert_eq!(resp.status().as_u16(), 200);
9267
9268        token.cancel();
9269    }
9270
9271    #[test]
9272    fn test_is_private_ip_ranges() {
9273        use camel_api::is_ssrf_blocked_ip;
9274        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
9275        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
9276        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
9277        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
9278        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
9279        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
9280
9281        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
9282        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
9283        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
9284        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
9285        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
9286        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
9287        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
9288        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
9289
9290        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
9291        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
9292        assert!(!is_ssrf_blocked_ip(
9293            &"2001:4860:4860::8888".parse().unwrap()
9294        )); // allow-unwrap
9295    }
9296
9297    #[test]
9298    fn test_title_case_header() {
9299        assert_eq!(title_case_header("content-type"), "Content-Type");
9300        assert_eq!(title_case_header("authorization"), "Authorization");
9301        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
9302        assert_eq!(title_case_header("host"), "Host");
9303        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
9304        assert_eq!(title_case_header("single"), "Single");
9305        assert_eq!(title_case_header(""), "");
9306    }
9307
9308    #[test]
9309    fn test_resolve_url_combines_path_and_query_sources() {
9310        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
9311        let mut exchange = Exchange::new(Message::default());
9312        exchange.input.set_header(
9313            "CamelHttpPath",
9314            serde_json::Value::String("next".to_string()),
9315        );
9316        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9317        assert!(url.starts_with("http://example.com/base/next?"));
9318        assert!(url.contains("foo=bar"));
9319
9320        exchange.input.set_header(
9321            "CamelHttpUri",
9322            serde_json::Value::String("http://other.test/root".to_string()),
9323        );
9324        exchange.input.set_header(
9325            "CamelHttpQuery",
9326            serde_json::Value::String("a=1&b=2".to_string()),
9327        );
9328
9329        let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9330        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
9331    }
9332
9333    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
9334        let mut exchange = Exchange::new(Message::default());
9335        exchange
9336            .input
9337            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
9338        exchange.input.set_header(
9339            "CamelHttpQuery",
9340            serde_json::Value::String(query.to_string()),
9341        );
9342        exchange
9343    }
9344
9345    #[test]
9346    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
9347        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9348        cfg.bridge_endpoint = true;
9349        cfg.query_params
9350            .push(("token".to_string(), "secret".to_string()));
9351        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9352        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9353        // Verbatim assembly: the old round-trip normalized the empty base
9354        // path to `/` (`http://x/?token=secret`); authored bytes end-to-end
9355        // no longer insert it.
9356        assert_eq!(url, "http://x?token=secret");
9357        assert!(!url.contains("/foo"));
9358        assert!(!url.contains("dropme"));
9359    }
9360
9361    #[test]
9362    fn resolve_url_bridge_endpoint_false_merges_path() {
9363        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9364        cfg.bridge_endpoint = false;
9365        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9366        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9367        assert!(url.contains("/foo"), "url should contain /foo: {url}");
9368        assert!(
9369            url.contains("dropme=1"),
9370            "url should contain dropme=1: {url}"
9371        );
9372    }
9373
9374    #[test]
9375    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
9376        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9377        cfg.bridge_endpoint = true;
9378        let mut exchange = Exchange::new(Message::default());
9379        exchange.input.set_header(
9380            "CamelHttpPath",
9381            serde_json::Value::String("/foo".to_string()),
9382        );
9383        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9384        assert_eq!(url, "http://x");
9385        assert!(!url.contains("/foo"));
9386    }
9387
9388    #[test]
9389    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
9390        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9391        cfg.bridge_endpoint = true;
9392        // query_params stays empty ([])
9393        let mut exchange = Exchange::new(Message::default());
9394        exchange.input.set_header(
9395            "CamelHttpUri",
9396            serde_json::Value::String("http://dest/explicit".to_string()),
9397        );
9398        exchange.input.set_header(
9399            "CamelHttpPath",
9400            serde_json::Value::String("/foo".to_string()),
9401        );
9402        exchange.input.set_header(
9403            "CamelHttpQuery",
9404            serde_json::Value::String("x=1".to_string()),
9405        );
9406        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9407        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
9408        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
9409        // wins verbatim.
9410        assert_eq!(url, "http://x");
9411    }
9412
9413    #[test]
9414    fn bridge_programmatic_params_use_percent20() {
9415        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9416        cfg.bridge_endpoint = true;
9417        cfg.query_params = vec![("b".to_string(), "x y".to_string())];
9418        let exchange = Exchange::new(Message::default());
9419
9420        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9421
9422        // `%20 never +` is global for programmatic values — the bridge arm
9423        // uses the same encoder as the non-bridge path. Bridging
9424        // semantics (what gets bridged, precedence) are unchanged.
9425        assert_eq!(url, "http://x?b=x%20y");
9426        assert!(!url.contains('+'));
9427    }
9428
9429    #[test]
9430    fn bridge_arm_carries_authored_raw_query() {
9431        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9432        // bridgeEndpoint is consumed as an endpoint option; a=1 is the
9433        // authored leftover riding raw_query.
9434        let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
9435
9436        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9437
9438        // Authored leftovers ride under bridging (Apache Camel semantics):
9439        // query is a=1 in authored bytes; exchange path/query stay ignored.
9440        assert_eq!(url, "http://h/p?a=1");
9441        assert!(!url.contains("dropme"), "exchange query leaked: {url}");
9442        assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
9443    }
9444
9445    // -----------------------------------------------------------------------
9446    // Bridge arm verbatim assembly (Papal Direction A): the bridged base is
9447    // never round-tripped through `url::Url` normalization — authored bytes
9448    // end-to-end, identical assembly to every other resolve_url arm.
9449    // -----------------------------------------------------------------------
9450
9451    #[test]
9452    fn resolve_url_bridge_preserves_dot_segments() {
9453        let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
9454        cfg.bridge_endpoint = true;
9455        cfg.query_params.push(("k".to_string(), "1".to_string()));
9456        let exchange = Exchange::new(Message::default());
9457
9458        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9459
9460        // Dot segments are authored bytes; the old round-trip collapsed
9461        // them (`/a/../b` → `/b`). Verbatim keeps them.
9462        assert_eq!(url, "http://h/a/../b?k=1");
9463    }
9464
9465    #[test]
9466    fn resolve_url_bridge_preserves_default_port() {
9467        let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
9468        cfg.bridge_endpoint = true;
9469        cfg.query_params.push(("k".to_string(), "1".to_string()));
9470        let exchange = Exchange::new(Message::default());
9471
9472        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9473
9474        // The old round-trip stripped the default port `:80`. Verbatim
9475        // keeps it.
9476        assert_eq!(url, "http://h:80/p?k=1");
9477    }
9478
9479    #[test]
9480    fn resolve_url_bridge_preserves_scheme_and_host_case() {
9481        let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
9482        cfg.bridge_endpoint = true;
9483        cfg.query_params.push(("k".to_string(), "1".to_string()));
9484        // `from_uri`'s scheme validation is case-sensitive, so the scheme
9485        // case is applied on the stored base directly — the resolve path
9486        // must carry whatever bytes the operator authored.
9487        cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
9488        let exchange = Exchange::new(Message::default());
9489
9490        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9491
9492        // The old round-trip lowercased scheme and host. Verbatim keeps
9493        // both authored.
9494        assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
9495    }
9496
9497    #[test]
9498    fn resolve_url_bridge_no_query_emits_base_verbatim() {
9499        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9500        cfg.bridge_endpoint = true;
9501        let exchange = Exchange::new(Message::default());
9502
9503        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9504
9505        // No resolved query: exactly the authored base — no synthetic `/`,
9506        // no dangling `?`.
9507        assert_eq!(url, "http://h/p");
9508    }
9509
9510    #[test]
9511    fn resolve_url_bridge_and_non_bridge_byte_identical() {
9512        // (a) Bridged arm: the effective query comes from programmatic
9513        // query_params.
9514        let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9515        bridged.bridge_endpoint = true;
9516        bridged
9517            .query_params
9518            .push(("k".to_string(), "1".to_string()));
9519        let bridge_url =
9520            HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
9521
9522        // (b) Non-bridge CamelHttpQuery composition path: same effective
9523        // query riding the exchange header.
9524        let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9525        let mut exchange = Exchange::new(Message::default());
9526        exchange.input.set_header(
9527            "CamelHttpQuery",
9528            serde_json::Value::String("k=1".to_string()),
9529        );
9530        let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
9531
9532        assert_eq!(bridge_url, plain_url);
9533        assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
9534    }
9535
9536    #[test]
9537    fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
9538        let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
9539        cfg.bridge_endpoint = true;
9540        cfg.query_params.push(("k".to_string(), "1".to_string()));
9541        let exchange = Exchange::new(Message::default());
9542
9543        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9544
9545        assert_eq!(url, "http://[::1]:8080/p?k=1");
9546    }
9547
9548    #[test]
9549    fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
9550        let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
9551        let exchange = Exchange::new(Message::default());
9552
9553        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9554
9555        // Authored query on an empty base path: the old round-trip
9556        // inserted a synthetic `/` (`http://h/?x=1`); verbatim does not.
9557        assert_eq!(url, "http://h?x=1");
9558    }
9559
9560    // -----------------------------------------------------------------------
9561    // Raw-preserving outbound query serialization (http-query-wire-fidelity)
9562    // -----------------------------------------------------------------------
9563
9564    #[test]
9565    fn resolve_url_preserves_authored_query_order_and_bytes() {
9566        let config =
9567            HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
9568        let exchange = Exchange::new(Message::default());
9569
9570        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9571
9572        // Authored order, authored separators, no %2C/%3A re-encoding,
9573        // consumed option (connectTimeout) removed.
9574        assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
9575    }
9576
9577    #[test]
9578    fn resolve_url_consumes_encoded_option_key() {
9579        let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
9580        let exchange = Exchange::new(Message::default());
9581
9582        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9583
9584        // The raw filter matches the decoded key, not the encoded bytes.
9585        assert_eq!(url, "http://h/p?a=1");
9586    }
9587
9588    #[test]
9589    fn resolve_url_all_options_consumed_drops_query() {
9590        let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
9591        let exchange = Exchange::new(Message::default());
9592
9593        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9594
9595        // A non-empty query whose every pair was consumed drops the query
9596        // component entirely — no dangling `?`.
9597        assert_eq!(url, "http://h/p");
9598        assert!(!url.contains('?'));
9599    }
9600
9601    #[test]
9602    fn resolve_url_preserves_empty_query_marker() {
9603        let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
9604        let exchange = Exchange::new(Message::default());
9605
9606        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9607
9608        // A bare `?` marker is preserved distinctly, never conflated with
9609        // an all-consumed query.
9610        assert_eq!(url, "http://h/p?");
9611    }
9612
9613    #[test]
9614    fn resolve_url_raw_wrapper_not_re_encoded() {
9615        let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
9616        let exchange = Exchange::new(Message::default());
9617
9618        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9619
9620        // RAW(...) wrapper bytes survive exactly as authored (rc-g4isv).
9621        assert_eq!(url, "http://h/p?token=RAW(abc)");
9622        assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
9623    }
9624
9625    #[test]
9626    fn resolve_url_camel_http_query_composes_verbatim_span() {
9627        let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
9628        let mut exchange = Exchange::new(Message::default());
9629        exchange.input.set_header(
9630            "CamelHttpQuery",
9631            serde_json::Value::String("userFilter=a%2Cb".to_string()),
9632        );
9633
9634        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9635
9636        // Policy change (ADR-0071): the header no longer replaces the
9637        // endpoint query — it composes, the endpoint winning collisions.
9638        // The header span bytes still ride verbatim: `a%2Cb` is carried
9639        // as-authored, never re-encoded (no %252C).
9640        assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
9641        assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
9642    }
9643
9644    // -----------------------------------------------------------------------
9645    // Outbound query composition (http-contract-surface, ADR-0071)
9646    // -----------------------------------------------------------------------
9647
9648    #[test]
9649    fn header_composes_with_endpoint_query() {
9650        let config =
9651            HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
9652        let mut exchange = Exchange::new(Message::default());
9653        exchange.input.set_header(
9654            "CamelHttpQuery",
9655            serde_json::Value::String("lang=es&page=2".to_string()),
9656        );
9657
9658        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9659
9660        // Higher precedence (endpoint) wins collisions: `lang` stays `en`;
9661        // the header appends only its absent keys.
9662        assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
9663    }
9664
9665    #[test]
9666    fn header_alone_still_rides() {
9667        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9668        let mut exchange = Exchange::new(Message::default());
9669        exchange.input.set_header(
9670            "CamelHttpQuery",
9671            serde_json::Value::String("page=2".to_string()),
9672        );
9673
9674        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9675
9676        // No endpoint query: the header pairs are the whole query.
9677        assert_eq!(url, "http://upstream/api?page=2");
9678    }
9679
9680    #[test]
9681    fn empty_reflected_query_leaves_endpoint_query_intact() {
9682        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9683        let mut exchange = Exchange::new(Message::default());
9684        // The consumer installs an empty CamelHttpQuery on requests that
9685        // arrived without a query string.
9686        exchange
9687            .input
9688            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9689
9690        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9691
9692        // No second `?` marker, no dropped endpoint pair.
9693        assert_eq!(url, "http://upstream/api?apiKey=secret");
9694        assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
9695    }
9696
9697    #[test]
9698    fn forbidden_byte_in_header_query_errors() {
9699        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9700        let mut exchange = Exchange::new(Message::default());
9701        exchange.input.set_header(
9702            "CamelHttpQuery",
9703            serde_json::Value::String("q=ab<cd".to_string()),
9704        );
9705
9706        let err = HttpProducer::resolve_url(&exchange, &config)
9707            .unwrap_err()
9708            .to_string();
9709
9710        // Fail loud naming the forbidden byte (`<` = 0x3C); a resolve
9711        // error means no URL is emitted, never a re-encoded one.
9712        assert!(err.contains("0x3C"), "error must name the byte: {err}");
9713    }
9714
9715    #[test]
9716    fn override_uri_with_query_plus_header_query() {
9717        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9718        let mut exchange = Exchange::new(Message::default());
9719        exchange.input.set_header(
9720            "CamelHttpUri",
9721            serde_json::Value::String("http://host/api?a=1".to_string()),
9722        );
9723        exchange.input.set_header(
9724            "CamelHttpQuery",
9725            serde_json::Value::String("a=2&b=3".to_string()),
9726        );
9727
9728        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9729
9730        // Pair-level merge with a single `?`: the override's `a=1` wins
9731        // the collision, the header appends `b=3` — no `?a=1?a=2` concat.
9732        assert_eq!(url, "http://host/api?a=1&b=3");
9733    }
9734
9735    #[test]
9736    fn path_applies_before_query_composition() {
9737        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9738        let mut exchange = Exchange::new(Message::default());
9739        exchange.input.set_header(
9740            "CamelHttpUri",
9741            serde_json::Value::String("http://host/api?a=1".to_string()),
9742        );
9743        exchange.input.set_header(
9744            "CamelHttpPath",
9745            serde_json::Value::String("/extra".to_string()),
9746        );
9747        exchange.input.set_header(
9748            "CamelHttpQuery",
9749            serde_json::Value::String("b=2".to_string()),
9750        );
9751
9752        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9753
9754        // CamelHttpPath applies to the override base without its query,
9755        // then the query composes.
9756        assert_eq!(url, "http://host/api/extra?a=1&b=2");
9757    }
9758
9759    #[test]
9760    fn plain_proxy_reflection_composes() {
9761        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9762        // Headers as the consumer installs them from the wire.
9763        let exchange = exchange_with_path_and_query("/in/extra", "page=2");
9764
9765        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9766
9767        // Reflection rides by default and composes: the operator pair is
9768        // not replaced (rc-k3pir parity).
9769        assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
9770    }
9771
9772    #[test]
9773    fn bridge_endpoint_ignores_url_headers() {
9774        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9775        let mut exchange = Exchange::new(Message::default());
9776        exchange.input.set_header(
9777            "CamelHttpUri",
9778            serde_json::Value::String("http://evil.test/x".to_string()),
9779        );
9780        exchange.input.set_header(
9781            "CamelHttpPath",
9782            serde_json::Value::String("/foo".to_string()),
9783        );
9784        exchange.input.set_header(
9785            "CamelHttpQuery",
9786            serde_json::Value::String("z=9".to_string()),
9787        );
9788
9789        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9790
9791        // All three URL headers ignored; the endpoint base plus its own
9792        // (consumed-option-filtered) query is sent, exactly as before.
9793        assert_eq!(url, "http://h/p?a=1");
9794        assert!(!url.contains("evil"), "override leaked: {url}");
9795        assert!(!url.contains("z=9"), "header query leaked: {url}");
9796        assert!(!url.contains("/foo"), "header path leaked: {url}");
9797    }
9798
9799    #[test]
9800    fn resolve_url_programmatic_params_use_percent20_deterministic() {
9801        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9802        config.query_params = vec![
9803            ("b".to_string(), "x y".to_string()),
9804            ("a".to_string(), "1".to_string()),
9805        ];
9806        let exchange = Exchange::new(Message::default());
9807
9808        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9809
9810        // Declaration order (not lexical), minimal RFC-3986 encoding,
9811        // `%20` — never `+` — for spaces.
9812        assert_eq!(url, "http://h/p?b=x%20y&a=1");
9813        assert!(!url.contains('+'));
9814    }
9815
9816    #[test]
9817    fn resolve_url_authored_and_programmatic_merge() {
9818        let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
9819        config.query_params = vec![
9820            ("b".to_string(), "2".to_string()),
9821            ("a".to_string(), "9".to_string()),
9822        ];
9823        let exchange = Exchange::new(Message::default());
9824
9825        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9826
9827        // Programmatic `b` appended (absent from raw); programmatic `a=9`
9828        // ignored (authored key wins); no duplication.
9829        assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
9830    }
9831
9832    #[test]
9833    fn from_uri_no_longer_fills_query_params_from_uri() {
9834        let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
9835
9836        // Authored pairs live in raw_query ONLY (provenance pin).
9837        assert!(
9838            config.query_params.is_empty(),
9839            "query_params is programmatic-only: {:?}",
9840            config.query_params
9841        );
9842        assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
9843    }
9844
9845    #[test]
9846    fn resolve_url_forbidden_raw_byte_errors() {
9847        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9848        config.raw_query = Some("a=x y".to_string());
9849        let exchange = Exchange::new(Message::default());
9850
9851        let err = HttpProducer::resolve_url(&exchange, &config)
9852            .expect_err("literal space in raw query must error");
9853
9854        // The error names the forbidden byte; no output string is produced.
9855        assert!(
9856            err.to_string().contains("0x20"),
9857            "error must name the forbidden byte: {err}"
9858        );
9859    }
9860
9861    /// rc-m4xk1: the override URI's own query is span-validated at resolve
9862    /// time — a forbidden byte in the override arm errors naming the byte,
9863    /// instead of riding verbatim to a reqwest send error.
9864    #[test]
9865    fn resolve_url_override_query_forbidden_byte_errors() {
9866        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9867        let mut exchange = Exchange::new(Message::default());
9868        exchange.input.set_header(
9869            "CamelHttpUri",
9870            serde_json::Value::String("http://h2/p?a=x y".to_string()),
9871        );
9872
9873        let err = HttpProducer::resolve_url(&exchange, &config)
9874            .expect_err("literal space in the override URI's query must error");
9875
9876        assert!(
9877            err.to_string().contains("0x20"),
9878            "error must name the forbidden byte from the override query: {err}"
9879        );
9880    }
9881
9882    /// rc-m4xk1 pin: decoded-key collision — a header pair whose key decodes
9883    /// to a key already present in the higher-precedence query (here
9884    /// `%61=2`, decoding to `a`) is dropped by the shared decoded-key
9885    /// matching; the higher-precedence authored span rides verbatim.
9886    #[test]
9887    fn merge_header_query_decoded_key_collision_drops_header_pair() {
9888        let merged = merge_header_query(Some("a=1"), "%61=2")
9889            .expect("decoded-key collision must not be a parse error");
9890        assert_eq!(
9891            merged.as_deref(),
9892            Some("a=1"),
9893            "the higher-precedence span wins and the colliding header pair is dropped"
9894        );
9895    }
9896
9897    /// rc-m4xk1 pin: duplicate keys within ONE header query are not
9898    /// deduplicated — both spans ride verbatim in authored order.
9899    #[test]
9900    fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
9901        let merged = merge_header_query(None, "k=1&k=2")
9902            .expect("duplicate header keys must not be a parse error");
9903        assert_eq!(
9904            merged.as_deref(),
9905            Some("k=1&k=2"),
9906            "intra-header duplicate keys ride verbatim"
9907        );
9908    }
9909
9910    /// rc-dhkeo: the Debug surface masks userinfo-style bytes in
9911    /// `base_url` and leaves a userinfo-free base untouched, byte-for-byte.
9912    /// rc-yvjp3 (ADR-0076 strictest-wins): `base_url` routes through the
9913    /// canonical `camel_api::redact::redact_url` — query and fragment bytes
9914    /// now drop behind their sentinels and later `//user:pass@` windows
9915    /// mask too, dimensions the former byte-preserving local variant kept.
9916    #[test]
9917    fn endpoint_config_debug_masks_base_url_userinfo() {
9918        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9919        config.base_url = "http://user:pass@h.example/p".to_string();
9920        let rendered = format!("{config:?}");
9921        assert!(
9922            rendered.contains("***@h.example"),
9923            "userinfo must render masked: {rendered}"
9924        );
9925        assert!(
9926            !rendered.contains("user:pass"),
9927            "no credentials in Debug output: {rendered}"
9928        );
9929
9930        let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9931        let rendered_plain = format!("{plain:?}");
9932        assert!(
9933            rendered_plain.contains("http://h.example/p"),
9934            "a base without userinfo renders unchanged: {rendered_plain}"
9935        );
9936    }
9937
9938    /// rc-yvjp3 convergence: an authored query and fragment on `base_url`
9939    /// render as sentinels, never as raw bytes (strictest-wins over the
9940    /// former byte-preserving variant), and the rendered value is
9941    /// byte-identical to the canonical helper.
9942    #[test]
9943    fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
9944        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9945
9946        config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
9947        let rendered = format!("{config:?}");
9948        assert!(
9949            rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
9950            "query and fragment must render as composed sentinels: {rendered}"
9951        );
9952        assert!(
9953            !rendered.contains("token=secret") && !rendered.contains("access_token"),
9954            "query/fragment credential bytes must not render: {rendered}"
9955        );
9956
9957        config.base_url = "http://h.example//u2:p2@evil/".to_string();
9958        let rendered = format!("{config:?}");
9959        assert!(
9960            rendered.contains("base_url: \"http://h.example//***@evil/\""),
9961            "later //window userinfo must mask (canonical window rule): {rendered}"
9962        );
9963        assert!(
9964            !rendered.contains("u2:p2"),
9965            "later-window credentials must not render: {rendered}"
9966        );
9967
9968        // Cross-surface identity: the Debug field is byte-identical to the
9969        // canonical helper output for the same input.
9970        config.base_url = "http://user:pass@h.example/p?token=x".to_string();
9971        let canonical = camel_api::redact::redact_url(&config.base_url);
9972        assert_eq!(canonical, "http://***@h.example/p?[redacted]");
9973        let rendered = format!("{config:?}");
9974        assert!(
9975            rendered.contains(&format!("base_url: \"{canonical}\"")),
9976            "Debug base_url must equal canonical redact_url output: {rendered}"
9977        );
9978    }
9979
9980    /// rc-nmupb: authored apostrophe (0x27) is RFC 3986 pchar-legal, but
9981    /// reqwest's WHATWG parser re-encodes it as `%27` in every http/https
9982    /// query — the raw byte can never ride the wire verbatim. Resolve
9983    /// rejects it naming the byte; the authored `%27` escape is the
9984    /// wire-faithful form and rides verbatim.
9985    #[test]
9986    fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
9987        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9988
9989        config.raw_query = Some("q=it's".to_string());
9990        let exchange = Exchange::new(Message::default());
9991        let err = HttpProducer::resolve_url(&exchange, &config)
9992            .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
9993        assert!(
9994            err.to_string().contains("0x27"),
9995            "error must name the apostrophe byte: {err}"
9996        );
9997
9998        config.raw_query = Some("q=it%27s".to_string());
9999        let url = HttpProducer::resolve_url(&exchange, &config)
10000            .expect("authored %27 escape is wire-legal");
10001        assert!(
10002            url.contains("q=it%27s"),
10003            "the authored escape must ride byte-for-byte: {url}"
10004        );
10005
10006        // The rest of reqwest's WHATWG special-query set shares the same
10007        // rationale and is rejected alongside (`"` and backtick are not
10008        // RFC 3986 query-legal bytes; `<`/`>` likewise).
10009        for &byte in b"\"`<>" {
10010            config.raw_query = Some(format!("k={}x", byte as char));
10011            let err = HttpProducer::resolve_url(&exchange, &config)
10012                .expect_err("WHATWG special-query byte must be rejected");
10013            assert!(
10014                err.to_string().contains(&format!("0x{byte:02X}")),
10015                "error must name byte 0x{byte:02X}: {err}"
10016            );
10017        }
10018    }
10019
10020    #[test]
10021    fn armed_fence_rejects_unknown_host_redacted() {
10022        let cfg = HttpEndpointConfig::from_uri(
10023            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10024        )
10025        .unwrap();
10026        let mut exchange = Exchange::new(Message::default());
10027        exchange.input.set_header(
10028            "CamelHttpUri",
10029            serde_json::Value::String(
10030                "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
10031            ),
10032        );
10033
10034        let err = HttpProducer::resolve_url(&exchange, &cfg)
10035            .expect_err("override host outside the fence must fail resolution");
10036
10037        let message = err.to_string();
10038        assert!(!message.contains("pass"), "userinfo leaked: {message}");
10039        assert!(!message.contains("s3cret"), "query leaked: {message}");
10040    }
10041
10042    #[test]
10043    fn armed_fence_rejects_unparseable_override_redacted() {
10044        let cfg = HttpEndpointConfig::from_uri(
10045            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10046        )
10047        .unwrap();
10048        let mut exchange = Exchange::new(Message::default());
10049        exchange.input.set_header(
10050            "CamelHttpUri",
10051            serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
10052        );
10053
10054        let err = HttpProducer::resolve_url(&exchange, &cfg)
10055            .expect_err("unparseable override outside the fence must fail resolution");
10056
10057        let message = err.to_string();
10058        assert!(
10059            message.contains("allowedUriHosts fence"),
10060            "fence must be named: {message}"
10061        );
10062        assert!(
10063            message.contains("[redacted]"),
10064            "suppression sentinel missing: {message}"
10065        );
10066        assert!(
10067            !message.contains("evil.example.com"),
10068            "host leaked: fail-closed arm must render only the sentinel: {message}"
10069        );
10070        assert!(
10071            !message.contains("fencesecret"),
10072            "password leaked: {message}"
10073        );
10074        assert!(!message.contains("u:"), "userinfo leaked: {message}");
10075    }
10076
10077    #[test]
10078    fn armed_fence_rejects_password_only_userinfo_redacted() {
10079        let cfg = HttpEndpointConfig::from_uri(
10080            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10081        )
10082        .unwrap();
10083        let mut exchange = Exchange::new(Message::default());
10084        exchange.input.set_header(
10085            "CamelHttpUri",
10086            serde_json::Value::String(
10087                "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
10088            ),
10089        );
10090
10091        let err = HttpProducer::resolve_url(&exchange, &cfg)
10092            .expect_err("password-only override outside the fence must fail resolution");
10093
10094        let message = err.to_string();
10095        assert!(
10096            !message.contains("passwordonly"),
10097            "password-only userinfo leaked: {message}"
10098        );
10099        assert!(!message.contains("querysecret"), "query leaked: {message}");
10100        assert!(
10101            message.contains("http://***@evil.example.com/x?[redacted]"),
10102            "masked shape missing: {message}"
10103        );
10104    }
10105
10106    #[test]
10107    fn armed_fence_allows_listed_host() {
10108        let cfg = HttpEndpointConfig::from_uri(
10109            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10110        )
10111        .unwrap();
10112        let mut exchange = Exchange::new(Message::default());
10113        exchange.input.set_header(
10114            "CamelHttpUri",
10115            serde_json::Value::String("http://cdn.example.com/x".to_string()),
10116        );
10117
10118        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10119        assert_eq!(url, "http://cdn.example.com/x");
10120    }
10121
10122    #[test]
10123    fn host_only_entry_permits_any_port() {
10124        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
10125        let mut exchange = Exchange::new(Message::default());
10126        exchange.input.set_header(
10127            "CamelHttpUri",
10128            serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
10129        );
10130
10131        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10132        assert_eq!(url, "http://cdn.example.com:9443/x");
10133    }
10134
10135    #[test]
10136    fn unarmed_endpoint_unchanged() {
10137        let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10138        let mut exchange = Exchange::new(Message::default());
10139        exchange.input.set_header(
10140            "CamelHttpUri",
10141            serde_json::Value::String("http://any.example.com/path".to_string()),
10142        );
10143
10144        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10145        assert_eq!(url, "http://any.example.com/path");
10146    }
10147
10148    #[test]
10149    fn empty_allowlist_fails_endpoint_creation() {
10150        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
10151    }
10152
10153    #[test]
10154    fn malformed_entry_fails_endpoint_creation() {
10155        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
10156    }
10157
10158    #[test]
10159    fn fence_entry_with_path_fails_creation() {
10160        // A trailing path is a typo'd entry: silently narrowing it to the
10161        // hostname would widen or skew the fence. Reject loudly.
10162        assert!(
10163            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
10164        );
10165    }
10166
10167    #[test]
10168    fn fence_entry_with_userinfo_fails_creation() {
10169        assert!(
10170            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
10171        );
10172    }
10173
10174    #[test]
10175    fn ipv6_fence_entry_allows_bracketed_host() {
10176        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
10177        // The textual host forms differ; both parse to the same bracketed
10178        // canonical host (`[::1]`) that the entry stores, so both ride.
10179        for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
10180            let mut exchange = Exchange::new(Message::default());
10181            exchange
10182                .input
10183                .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
10184            let url = HttpProducer::resolve_url(&exchange, &cfg)
10185                .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
10186            assert_eq!(url, uri, "bracketed IPv6 override not honored");
10187        }
10188    }
10189
10190    #[test]
10191    fn dns_case_insensitive_fence_match() {
10192        // The entry is stored ASCII-lowercased, so the mixed-case option
10193        // matches the lowercase override host.
10194        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
10195        let mut exchange = Exchange::new(Message::default());
10196        exchange.input.set_header(
10197            "CamelHttpUri",
10198            serde_json::Value::String("http://cdn.example.com/x".to_string()),
10199        );
10200        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10201        assert_eq!(url, "http://cdn.example.com/x");
10202    }
10203
10204    #[test]
10205    fn fence_allowed_override_query_merges_with_header() {
10206        // Fence pass plus full composition: the override URI query is the
10207        // higher-precedence source, the header pair appends.
10208        let cfg =
10209            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
10210        let mut exchange = Exchange::new(Message::default());
10211        exchange.input.set_header(
10212            "CamelHttpUri",
10213            serde_json::Value::String("http://host.example/api?a=1".to_string()),
10214        );
10215        exchange.input.set_header(
10216            "CamelHttpQuery",
10217            serde_json::Value::String("b=2".to_string()),
10218        );
10219
10220        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10221        assert_eq!(url, "http://host.example/api?a=1&b=2");
10222    }
10223
10224    #[test]
10225    fn empty_header_with_armed_fence_leaves_no_query() {
10226        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
10227        let mut exchange = Exchange::new(Message::default());
10228        exchange.input.set_header(
10229            "CamelHttpUri",
10230            serde_json::Value::String("http://host.example/api".to_string()),
10231        );
10232        exchange
10233            .input
10234            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10235
10236        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10237        assert_eq!(url, "http://host.example/api");
10238        assert!(!url.contains('?'), "query marker leaked: {url}");
10239    }
10240
10241    #[test]
10242    fn fence_option_is_consumed() {
10243        // A raw query on the base URI plus the fence option; no override
10244        // header. The option is consumed at parse time and must never
10245        // appear in the outbound query.
10246        let cfg =
10247            HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
10248        let exchange = Exchange::new(Message::default());
10249
10250        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10251        assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
10252        assert!(url.contains("x=1"), "authored query lost: {url}");
10253    }
10254
10255    #[tokio::test]
10256    async fn resolve_url_malformed_base_url_errors_no_panic() {
10257        use tower::ServiceExt;
10258
10259        let (url, _handle) = start_test_server().await;
10260        let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
10261        config.allow_internal = true; // test server binds 127.0.0.1
10262        let producer = HttpProducer {
10263            config: Arc::new(config),
10264            client: build_client(&HttpConfig::default(), None),
10265            pinned_cache: Arc::new(PinnedClientCache::new(
10266                PINNED_CLIENT_TTL,
10267                PINNED_CLIENT_MAX_ENTRIES,
10268            )),
10269            http_config: Arc::new(HttpConfig::default()),
10270            runtime: rt(),
10271        };
10272
10273        // First call: malformed base URL propagates as an error through the
10274        // real producer path — no panic, no poisoned state (rc-ph7z2).
10275        let first = producer
10276            .clone()
10277            .oneshot(Exchange::new(Message::default()))
10278            .await;
10279        let err = first.expect_err("malformed base URL must error, not panic");
10280        assert!(
10281            err.to_string().to_lowercase().contains("url"),
10282            "error must name the malformed URL: {err}"
10283        );
10284
10285        // Second call through the SAME producer succeeds — the failure
10286        // left no poisoned state.
10287        let mut exchange = Exchange::new(Message::default());
10288        exchange.input.set_header(
10289            "CamelHttpUri",
10290            serde_json::Value::String(format!("{url}/api")),
10291        );
10292        let response = producer
10293            .oneshot(exchange)
10294            .await
10295            .expect("valid request through same producer must succeed");
10296        let status = response
10297            .input
10298            .header("CamelHttpResponseCode")
10299            .and_then(|v| v.as_u64())
10300            .unwrap();
10301        assert_eq!(status, 200);
10302    }
10303
10304    #[test]
10305    fn resolve_url_bridge_malformed_base_errors_no_panic() {
10306        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10307        cfg.bridge_endpoint = true;
10308        cfg.query_params.push(("k".to_string(), "1".to_string()));
10309        // `from_uri` rejects the malformed authority, so the base is set on
10310        // the stored config directly (same build shape as the scheme-case
10311        // test). The bridge arm's validation-only parse (rc-ph7z2) must
10312        // surface it as an error — no panic.
10313        cfg.base_url = "http://[::1:bad".to_string();
10314        let exchange = Exchange::new(Message::default());
10315
10316        let err = HttpProducer::resolve_url(&exchange, &cfg)
10317            .expect_err("malformed bridge base URL must error");
10318        assert!(
10319            err.to_string().contains("invalid base URL"),
10320            "error must name the invalid base URL: {err}"
10321        );
10322    }
10323
10324    #[test]
10325    fn test_http_producer_helpers_status_and_size_boundaries() {
10326        assert!(HttpProducer::is_ok_status(200, (200, 299)));
10327        assert!(HttpProducer::is_ok_status(299, (200, 299)));
10328        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
10329        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
10330
10331        assert!(!exceeds_max_response_body(10, 10));
10332        assert!(exceeds_max_response_body(11, 10));
10333    }
10334
10335    // -----------------------------------------------------------------------
10336    // Content-Type inference tests
10337    // -----------------------------------------------------------------------
10338
10339    #[allow(clippy::await_holding_lock)]
10340    async fn setup_consumer_on_free_port(
10341        path: &str,
10342    ) -> (
10343        u16,
10344        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
10345        tokio_util::sync::CancellationToken,
10346    ) {
10347        use camel_component_api::ConsumerContext;
10348
10349        // ADR-0070 staged-listener law: bind, KEEP the socket, and stage it
10350        // in the ServerRegistry; the consumer's `get_or_spawn` consumes the
10351        // staged listener, so the port never returns to the ephemeral pool
10352        // between probe and serve (no bind-read-drop race).
10353        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10354        let port = listener.local_addr().unwrap().port();
10355
10356        // Hold the registry test mutex across the whole stage→spawn→ready
10357        // window so a concurrent `ServerRegistry::reset()` cannot evict the
10358        // staged listener between staging and readiness. The guard covers
10359        // stage_listener, the consumer spawn, the readiness poll and the
10360        // tail-yield loop; it releases when this helper returns.
10361        // Poison-recovering acquire: a failed sibling test must not
10362        // cascade — the mutex guards test serialization only, no
10363        // structural invariant, so recovery via into_inner is safe.
10364        let _registry_guard = lock_registry_test_mutex();
10365
10366        ServerRegistry::global()
10367            .stage_listener(listener)
10368            .await
10369            .expect("stage consumer test listener");
10370
10371        let consumer_cfg = HttpServerConfig {
10372            scheme: "http".to_string(),
10373            host: "127.0.0.1".to_string(),
10374            port,
10375            path: path.to_string(),
10376            max_request_body: 2 * 1024 * 1024,
10377            max_response_body: 10 * 1024 * 1024,
10378            max_inflight_requests: 1024,
10379            method: None,
10380            tls_config: None,
10381        };
10382        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
10383
10384        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10385        let token = tokio_util::sync::CancellationToken::new();
10386        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10387
10388        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10389
10390        // Readiness without a fixed wall-clock sleep: poll the registry
10391        // entry live (1ms doubling backoff, 10s deadline), then yield so
10392        // the spawned `start()` completes route registration (that tail
10393        // path has no pending timers — only the registry lock — so
10394        // scheduler yields order it deterministically behind this loop).
10395        wait_for_registry_ready("127.0.0.1", port).await;
10396        for _ in 0..8 {
10397            tokio::task::yield_now().await;
10398        }
10399
10400        (port, rx, token)
10401    }
10402
10403    /// Poll `ServerRegistry::bound_addr(host, port)` until the entry
10404    /// appears: 1ms backoff doubling per iteration, capped at 64ms, with
10405    /// a 10s deadline. Panics with a hint naming the likely causes when
10406    /// the deadline fires.
10407    async fn wait_for_registry_ready(host: &str, port: u16) {
10408        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
10409        let mut backoff = std::time::Duration::from_millis(1);
10410        while ServerRegistry::global().bound_addr(host, port).is_none() {
10411            assert!(
10412                tokio::time::Instant::now() < deadline,
10413                "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
10414            );
10415            tokio::time::sleep(backoff).await;
10416            backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
10417        }
10418    }
10419
10420    #[tokio::test]
10421    #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
10422    async fn readiness_deadline_fires_loud_with_hint() {
10423        // Poll a key no writer can produce. Registry keys come from
10424        // either the listener's resolved IP string (staged path) or the
10425        // caller-provided host verbatim (legacy get_or_spawn path), so a
10426        // synthetic host literal that no test passes is unreachable on
10427        // BOTH paths. Binding and HOLDING the listener (never dropped,
10428        // never staged) additionally keeps its port out of the ephemeral
10429        // pool, so no concurrent test can register that port either.
10430        // (Earlier drafts polled 127.0.0.2 — rejected: macOS exposes only
10431        // 127.0.0.1 and the bind fails there, rc-dwmd; and "localhost" —
10432        // rejected: the legacy host-verbatim path could produce it.)
10433        let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10434        let port = held.local_addr().unwrap().port();
10435        wait_for_registry_ready("httpflake-unreachable-host", port).await;
10436    }
10437
10438    // -----------------------------------------------------------------------
10439    // Readiness vs concurrent registry reset (httpflake, regression RED)
10440    // -----------------------------------------------------------------------
10441
10442    #[tokio::test]
10443    async fn readiness_survives_concurrent_registry_reset() {
10444        let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10445        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
10446
10447        // Hammer thread: loop legal resets, counting a contention whenever
10448        // its try-lock on the registry test mutex blocks (someone else held
10449        // it). The guard is dropped at each iteration end.
10450        let contended_hammer = std::sync::Arc::clone(&contended);
10451        let stop_hammer = std::sync::Arc::clone(&stop);
10452        let handle = std::thread::spawn(move || {
10453            while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
10454                let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
10455                    Err(_) => {
10456                        contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10457                        lock_registry_test_mutex()
10458                    }
10459                    Ok(guard) => guard,
10460                };
10461                ServerRegistry::reset();
10462            }
10463        });
10464
10465        // Drop guard: even if a setup panics, stop the hammer and join it so
10466        // the thread never outlives the test.
10467        struct StopHammerOnDrop {
10468            handle: Option<std::thread::JoinHandle<()>>,
10469            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
10470        }
10471        impl Drop for StopHammerOnDrop {
10472            fn drop(&mut self) {
10473                self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
10474                if let Some(handle) = self.handle.take() {
10475                    let _ = handle.join();
10476                }
10477            }
10478        }
10479        let _hammer_guard = StopHammerOnDrop {
10480            handle: Some(handle),
10481            stop,
10482        };
10483
10484        // Always at least 25 setups on fresh ephemeral ports; continue past
10485        // 25 only until one contended reset is observed; hard cap 50.
10486        let mut setups = 0;
10487        loop {
10488            setups += 1;
10489            let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
10490            drop(rx);
10491            token.cancel();
10492            if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
10493                || setups >= 50
10494            {
10495                break;
10496            }
10497        }
10498
10499        let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
10500        assert!(
10501            contended_hits >= 1,
10502            "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
10503        );
10504    }
10505
10506    #[tokio::test]
10507    async fn test_content_type_inferred_for_json_body() {
10508        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
10509
10510        let client = reqwest::Client::new();
10511        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
10512
10513        let (http_result, _) = tokio::join!(send_fut, async {
10514            if let Some(mut envelope) = rx.recv().await {
10515                envelope.exchange.input.body =
10516                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
10517                if let Some(reply_tx) = envelope.reply_tx {
10518                    let _ = reply_tx.send(Ok(envelope.exchange));
10519                }
10520            }
10521        });
10522
10523        let resp = http_result.unwrap();
10524        assert_eq!(resp.status().as_u16(), 200);
10525        let ct = resp
10526            .headers()
10527            .get("content-type")
10528            .expect("Content-Type header should be present");
10529        assert_eq!(ct, "application/json");
10530        let body = resp.text().await.unwrap();
10531        assert_eq!(body, r#"{"message":"hello"}"#);
10532
10533        token.cancel();
10534    }
10535
10536    #[tokio::test]
10537    async fn test_content_type_inferred_for_text_body() {
10538        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
10539
10540        let client = reqwest::Client::new();
10541        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
10542
10543        let (http_result, _) = tokio::join!(send_fut, async {
10544            if let Some(mut envelope) = rx.recv().await {
10545                envelope.exchange.input.body =
10546                    camel_component_api::Body::Text("plain text response".to_string());
10547                if let Some(reply_tx) = envelope.reply_tx {
10548                    let _ = reply_tx.send(Ok(envelope.exchange));
10549                }
10550            }
10551        });
10552
10553        let resp = http_result.unwrap();
10554        assert_eq!(resp.status().as_u16(), 200);
10555        let ct = resp
10556            .headers()
10557            .get("content-type")
10558            .expect("Content-Type header should be present");
10559        assert_eq!(ct, "text/plain; charset=utf-8");
10560        let body = resp.text().await.unwrap();
10561        assert_eq!(body, "plain text response");
10562
10563        token.cancel();
10564    }
10565
10566    #[tokio::test]
10567    async fn test_content_type_inferred_for_xml_body() {
10568        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
10569
10570        let client = reqwest::Client::new();
10571        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
10572
10573        let (http_result, _) = tokio::join!(send_fut, async {
10574            if let Some(mut envelope) = rx.recv().await {
10575                envelope.exchange.input.body =
10576                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
10577                if let Some(reply_tx) = envelope.reply_tx {
10578                    let _ = reply_tx.send(Ok(envelope.exchange));
10579                }
10580            }
10581        });
10582
10583        let resp = http_result.unwrap();
10584        assert_eq!(resp.status().as_u16(), 200);
10585        let ct = resp
10586            .headers()
10587            .get("content-type")
10588            .expect("Content-Type header should be present");
10589        assert_eq!(ct, "application/xml");
10590        let body = resp.text().await.unwrap();
10591        assert_eq!(body, "<root><item>value</item></root>");
10592
10593        token.cancel();
10594    }
10595
10596    #[tokio::test]
10597    async fn test_no_content_type_for_empty_body() {
10598        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
10599
10600        let client = reqwest::Client::new();
10601        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
10602
10603        let (http_result, _) = tokio::join!(send_fut, async {
10604            if let Some(mut envelope) = rx.recv().await {
10605                envelope.exchange.input.body = camel_component_api::Body::Empty;
10606                if let Some(reply_tx) = envelope.reply_tx {
10607                    let _ = reply_tx.send(Ok(envelope.exchange));
10608                }
10609            }
10610        });
10611
10612        let resp = http_result.unwrap();
10613        assert_eq!(resp.status().as_u16(), 200);
10614        assert!(
10615            resp.headers().get("content-type").is_none(),
10616            "Empty body should not set Content-Type"
10617        );
10618
10619        token.cancel();
10620    }
10621
10622    #[tokio::test]
10623    async fn test_no_content_type_for_raw_bytes_body() {
10624        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
10625
10626        let client = reqwest::Client::new();
10627        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
10628
10629        let (http_result, _) = tokio::join!(send_fut, async {
10630            if let Some(mut envelope) = rx.recv().await {
10631                envelope.exchange.input.body =
10632                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
10633                if let Some(reply_tx) = envelope.reply_tx {
10634                    let _ = reply_tx.send(Ok(envelope.exchange));
10635                }
10636            }
10637        });
10638
10639        let resp = http_result.unwrap();
10640        assert_eq!(resp.status().as_u16(), 200);
10641        assert!(
10642            resp.headers().get("content-type").is_none(),
10643            "Raw Bytes body should not set Content-Type"
10644        );
10645
10646        token.cancel();
10647    }
10648
10649    #[tokio::test]
10650    async fn test_content_type_from_stream_metadata() {
10651        use camel_component_api::{StreamBody, StreamMetadata};
10652        use futures::stream;
10653
10654        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
10655
10656        let client = reqwest::Client::new();
10657        let send_fut = client
10658            .get(format!("http://127.0.0.1:{port}/stream-ct"))
10659            .send();
10660
10661        let (http_result, _) = tokio::join!(send_fut, async {
10662            if let Some(mut envelope) = rx.recv().await {
10663                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
10664                    vec![Ok(bytes::Bytes::from("audio data"))];
10665                let stream = Box::pin(stream::iter(chunks));
10666                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
10667                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
10668                    metadata: StreamMetadata {
10669                        size_hint: None,
10670                        content_type: Some("audio/mpeg".to_string()),
10671                        origin: None,
10672                    },
10673                });
10674                if let Some(reply_tx) = envelope.reply_tx {
10675                    let _ = reply_tx.send(Ok(envelope.exchange));
10676                }
10677            }
10678        });
10679
10680        let resp = http_result.unwrap();
10681        assert_eq!(resp.status().as_u16(), 200);
10682        let ct = resp
10683            .headers()
10684            .get("content-type")
10685            .expect("Content-Type header should be present");
10686        assert_eq!(ct, "audio/mpeg");
10687        let body = resp.text().await.unwrap();
10688        assert_eq!(body, "audio data");
10689
10690        token.cancel();
10691    }
10692
10693    #[tokio::test]
10694    async fn test_user_content_type_overrides_inferred() {
10695        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
10696
10697        let client = reqwest::Client::new();
10698        let send_fut = client
10699            .get(format!("http://127.0.0.1:{port}/override-ct"))
10700            .send();
10701
10702        let (http_result, _) = tokio::join!(send_fut, async {
10703            if let Some(mut envelope) = rx.recv().await {
10704                envelope.exchange.input.body =
10705                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
10706                envelope.exchange.input.set_header(
10707                    "Content-Type",
10708                    serde_json::Value::String("text/html".to_string()),
10709                );
10710                if let Some(reply_tx) = envelope.reply_tx {
10711                    let _ = reply_tx.send(Ok(envelope.exchange));
10712                }
10713            }
10714        });
10715
10716        let resp = http_result.unwrap();
10717        assert_eq!(resp.status().as_u16(), 200);
10718        let ct = resp
10719            .headers()
10720            .get("content-type")
10721            .expect("Content-Type header should be present");
10722        assert_eq!(
10723            ct, "text/html",
10724            "User-set Content-Type should take precedence over inferred type"
10725        );
10726
10727        token.cancel();
10728    }
10729
10730    #[tokio::test]
10731    async fn test_user_content_type_with_bytes_body() {
10732        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
10733
10734        let client = reqwest::Client::new();
10735        let send_fut = client
10736            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
10737            .send();
10738
10739        let (http_result, _) = tokio::join!(send_fut, async {
10740            if let Some(mut envelope) = rx.recv().await {
10741                envelope.exchange.input.body =
10742                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
10743                envelope.exchange.input.set_header(
10744                    "Content-Type",
10745                    serde_json::Value::String("application/json".to_string()),
10746                );
10747                if let Some(reply_tx) = envelope.reply_tx {
10748                    let _ = reply_tx.send(Ok(envelope.exchange));
10749                }
10750            }
10751        });
10752
10753        let resp = http_result.unwrap();
10754        assert_eq!(resp.status().as_u16(), 200);
10755        let ct = resp
10756            .headers()
10757            .get("content-type")
10758            .expect("Content-Type header should be present for Bytes body with user header");
10759        assert_eq!(
10760            ct, "application/json",
10761            "User Content-Type should be sent for Bytes body"
10762        );
10763
10764        token.cancel();
10765    }
10766
10767    // -----------------------------------------------------------------------
10768    // Server monitor tests (GRL-005)
10769    // -----------------------------------------------------------------------
10770
10771    #[tokio::test]
10772    async fn monitor_task_silent_on_clean_exit() {
10773        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
10774        let server_exited = tokio_util::sync::CancellationToken::new();
10775        // Clean exit should complete without panicking or logging errors
10776        monitor_axum_task(
10777            handle,
10778            "127.0.0.1:0".to_string(),
10779            noop_rt(),
10780            "test-monitor".into(),
10781            server_exited.clone(),
10782        )
10783        .await;
10784        // rc-szmob: a clean exit must NOT fail hosted consumers — route
10785        // stops own their termination (no CrashNotification storm on
10786        // graceful process shutdown).
10787        assert!(
10788            !server_exited.is_cancelled(),
10789            "clean server exit must not cancel server_exited"
10790        );
10791    }
10792
10793    #[tokio::test]
10794    async fn monitor_task_handles_panicked_task() {
10795        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
10796            panic!("simulated server crash");
10797        });
10798        let server_exited = tokio_util::sync::CancellationToken::new();
10799        // Should complete without panicking even though the inner task panicked
10800        monitor_axum_task(
10801            handle,
10802            "127.0.0.1:9999".to_string(),
10803            noop_rt(),
10804            "test-monitor".into(),
10805            server_exited.clone(),
10806        )
10807        .await;
10808        // rc-szmob: unexpected exit must cancel the token so every hosted
10809        // consumer fails and supervision engages (ADR-0007).
10810        assert!(
10811            server_exited.is_cancelled(),
10812            "crashed server must cancel server_exited"
10813        );
10814    }
10815
10816    // -----------------------------------------------------------------------
10817    // Credential redaction tests
10818    // -----------------------------------------------------------------------
10819
10820    #[test]
10821    fn http_auth_basic_debug_redacts_password() {
10822        let auth = HttpAuth::Basic {
10823            username: "admin".to_string(),
10824            password: "hunter2".to_string(),
10825        };
10826        let debug = format!("{:?}", auth);
10827        assert!(
10828            !debug.contains("hunter2"),
10829            "password must be redacted: {debug}"
10830        );
10831        assert!(debug.contains("admin"), "username should appear: {debug}");
10832    }
10833
10834    #[test]
10835    fn http_auth_bearer_debug_redacts_token() {
10836        let auth = HttpAuth::Bearer {
10837            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
10838        };
10839        let debug = format!("{:?}", auth);
10840        assert!(
10841            !debug.contains("eyJhbGci"),
10842            "token must be redacted: {debug}"
10843        );
10844    }
10845
10846    #[test]
10847    fn http_auth_none_debug_shows_variant() {
10848        let debug = format!("{:?}", HttpAuth::None);
10849        assert!(
10850            debug.contains("None"),
10851            "None variant should appear: {debug}"
10852        );
10853    }
10854
10855    #[test]
10856    fn http_endpoint_config_debug_redacts_auth_credentials() {
10857        let config = HttpEndpointConfig::from_uri(
10858            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
10859        )
10860        .unwrap();
10861        let debug = format!("{:?}", config);
10862        assert!(
10863            !debug.contains("secret123"),
10864            "password must be redacted in HttpEndpointConfig debug: {debug}"
10865        );
10866    }
10867
10868    #[test]
10869    fn debug_lists_all_public_fields() {
10870        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10871        let debug = format!("{:?}", config);
10872        for field in [
10873            "base_url",
10874            "http_method",
10875            "throw_exception_on_failure",
10876            "ok_status_code_range",
10877            "response_timeout",
10878            "query_params",
10879            "raw_query",
10880            "allow_internal",
10881            "allow_cleartext",
10882            "blocked_hosts",
10883            "max_body_size",
10884            "read_timeout_ms",
10885            "max_response_bytes",
10886            "auth",
10887            "token_provider",
10888            "user_agent",
10889            "bridge_endpoint",
10890            "connection_close",
10891            "skip_request_headers",
10892            "skip_response_headers",
10893            "follow_redirects",
10894            "max_redirects",
10895        ] {
10896            assert!(
10897                debug.contains(field),
10898                "Debug output missing field '{field}': {debug}"
10899            );
10900        }
10901    }
10902
10903    // -----------------------------------------------------------------------
10904    // Static file serving tests (Task 5)
10905    // -----------------------------------------------------------------------
10906
10907    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
10908    use tower_http::services::ServeDir;
10909
10910    fn make_test_registry() -> HttpRouteRegistry {
10911        HttpRouteRegistry::new()
10912    }
10913
10914    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
10915        AppState {
10916            registry,
10917            max_request_body: 2 * 1024 * 1024,
10918            max_response_body: 10 * 1024 * 1024,
10919            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
10920        }
10921    }
10922
10923    #[allow(clippy::await_holding_lock)]
10924    #[tokio::test]
10925    async fn test_static_file_serving_serves_file_contents() {
10926        let _guard = lock_registry_test_mutex();
10927        ServerRegistry::reset();
10928
10929        // Create temp dir with test files
10930        let temp_dir =
10931            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
10932        std::fs::create_dir_all(&temp_dir).unwrap();
10933        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
10934        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
10935
10936        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10937
10938        let registry = make_test_registry();
10939        let serve_dir = ServeDir::new(&canonical_dir)
10940            .precompressed_gzip()
10941            .precompressed_br()
10942            .append_index_html_on_directories(true);
10943
10944        let mount = StaticMount {
10945            mount_path: "/".to_string(),
10946            mode: MountMode::Static,
10947            dir: canonical_dir.clone(),
10948            cache_control: "public, max-age=3600".to_string(),
10949            error_pages: std::collections::HashMap::new(),
10950            serve_dir,
10951        };
10952        registry.register_static_mount(mount).await.unwrap();
10953
10954        let state = make_test_state(registry);
10955
10956        // Test serving hello.txt
10957        let req = Request::builder()
10958            .uri("/hello.txt")
10959            .body(AxumBody::empty())
10960            .unwrap();
10961        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
10962        assert_eq!(resp.status(), StatusCode::OK);
10963        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10964            .await
10965            .unwrap();
10966        assert_eq!(&body[..], b"Hello, static world!");
10967
10968        // Test serving style.css
10969        let req = Request::builder()
10970            .uri("/style.css")
10971            .body(AxumBody::empty())
10972            .unwrap();
10973        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10974        assert_eq!(resp.status(), StatusCode::OK);
10975        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10976            .await
10977            .unwrap();
10978        assert_eq!(&body[..], b"body { color: red; }");
10979
10980        // Test 404 for non-existent file
10981        let req = Request::builder()
10982            .uri("/missing.txt")
10983            .body(AxumBody::empty())
10984            .unwrap();
10985        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
10986        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10987
10988        // Cleanup
10989        std::fs::remove_dir_all(&temp_dir).ok();
10990    }
10991
10992    #[allow(clippy::await_holding_lock)]
10993    #[tokio::test]
10994    async fn test_spa_fallback_serves_index_for_unknown_paths() {
10995        let _guard = lock_registry_test_mutex();
10996        ServerRegistry::reset();
10997
10998        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
10999        std::fs::create_dir_all(&temp_dir).unwrap();
11000        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
11001        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
11002
11003        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11004
11005        let registry = make_test_registry();
11006        let serve_dir = ServeDir::new(&canonical_dir)
11007            .precompressed_gzip()
11008            .precompressed_br()
11009            .append_index_html_on_directories(true);
11010
11011        let mount = StaticMount {
11012            mount_path: "/".to_string(),
11013            mode: MountMode::Spa,
11014            dir: canonical_dir.clone(),
11015            cache_control: "public, max-age=0".to_string(),
11016            error_pages: std::collections::HashMap::new(),
11017            serve_dir,
11018        };
11019        // Register as SPA mount
11020        registry.register_static_mount(mount).await.unwrap();
11021
11022        let state = make_test_state(registry);
11023
11024        // SPA fallback: GET /dashboard with Accept: text/html → index.html
11025        let req = Request::builder()
11026            .method("GET")
11027            .uri("/dashboard")
11028            .header("Accept", "text/html")
11029            .body(AxumBody::empty())
11030            .unwrap();
11031        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
11032        assert_eq!(resp.status(), StatusCode::OK);
11033        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11034            .await
11035            .unwrap();
11036        assert_eq!(&body[..], b"<h1>SPA App</h1>");
11037
11038        // Static file still works: GET /app.js
11039        let req = Request::builder()
11040            .method("GET")
11041            .uri("/app.js")
11042            .body(AxumBody::empty())
11043            .unwrap();
11044        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
11045        assert_eq!(resp.status(), StatusCode::OK);
11046        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11047            .await
11048            .unwrap();
11049        assert_eq!(&body[..], b"console.log('app')");
11050
11051        // No SPA fallback for JSON accept → 404
11052        let req = Request::builder()
11053            .method("GET")
11054            .uri("/api/data")
11055            .header("Accept", "application/json")
11056            .body(AxumBody::empty())
11057            .unwrap();
11058        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
11059        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11060
11061        // No SPA fallback for file extensions → 404
11062        let req = Request::builder()
11063            .method("GET")
11064            .uri("/style.css")
11065            .header("Accept", "text/html")
11066            .body(AxumBody::empty())
11067            .unwrap();
11068        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11069        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11070
11071        // Cleanup
11072        std::fs::remove_dir_all(&temp_dir).ok();
11073    }
11074
11075    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
11076    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
11077    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
11078    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
11079    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
11080    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
11081    #[allow(clippy::await_holding_lock)]
11082    async fn run_conditional_get_returns_304(mode: MountMode) {
11083        let _guard = lock_registry_test_mutex();
11084        ServerRegistry::reset();
11085
11086        let temp_dir = std::env::temp_dir().join(format!(
11087            "http_cond_get_{}_{}",
11088            if mode == MountMode::Spa {
11089                "spa"
11090            } else {
11091                "static"
11092            },
11093            std::process::id()
11094        ));
11095        std::fs::create_dir_all(&temp_dir).unwrap();
11096        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11097
11098        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11099
11100        let registry = make_test_registry();
11101        let serve_dir = ServeDir::new(&canonical_dir)
11102            .precompressed_gzip()
11103            .precompressed_br()
11104            .append_index_html_on_directories(true);
11105
11106        let mount = StaticMount {
11107            mount_path: "/".to_string(),
11108            mode,
11109            dir: canonical_dir.clone(),
11110            cache_control: "public, max-age=3600".to_string(),
11111            error_pages: std::collections::HashMap::new(),
11112            serve_dir,
11113        };
11114        registry.register_static_mount(mount).await.unwrap();
11115
11116        let state = make_test_state(registry);
11117
11118        // 1st request: normal GET → 200, capture validators.
11119        let req = Request::builder()
11120            .method("GET")
11121            .uri("/index.html")
11122            .body(AxumBody::empty())
11123            .unwrap();
11124        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11125        assert_eq!(
11126            resp.status(),
11127            StatusCode::OK,
11128            "first GET should return 200, got {}",
11129            resp.status()
11130        );
11131        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
11132        assert!(
11133            resp.headers().contains_key(http::header::CACHE_CONTROL),
11134            "200 response missing Cache-Control"
11135        );
11136        let etag = resp
11137            .headers()
11138            .get(http::header::ETAG)
11139            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
11140            .clone();
11141        let last_modified = resp
11142            .headers()
11143            .get(http::header::LAST_MODIFIED)
11144            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
11145            .clone();
11146        // Consume the body so the response is fully drained.
11147        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
11148            .await
11149            .unwrap();
11150
11151        // 2nd request: If-None-Match with the captured ETag → 304.
11152        // Unconditional: ETag presence is required (asserted above) so this
11153        // sub-test cannot silently skip on a ServeDir etag_method change.
11154        let req = Request::builder()
11155            .method("GET")
11156            .uri("/index.html")
11157            .header(http::header::IF_NONE_MATCH, etag.clone())
11158            .body(AxumBody::empty())
11159            .unwrap();
11160        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11161        assert_eq!(
11162            resp.status(),
11163            StatusCode::NOT_MODIFIED,
11164            "If-None-Match with matching ETag should return 304, got {}",
11165            resp.status()
11166        );
11167        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
11168        assert!(
11169            resp.headers().contains_key(http::header::CACHE_CONTROL),
11170            "304 (If-None-Match) missing Cache-Control"
11171        );
11172        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
11173        // response parts rebuild in serve_via_serve_dir preserves them.
11174        assert_eq!(
11175            resp.headers().get(http::header::ETAG),
11176            Some(&etag),
11177            "304 (If-None-Match) must echo the ETag validator"
11178        );
11179        assert_eq!(
11180            resp.headers().get(http::header::LAST_MODIFIED),
11181            Some(&last_modified),
11182            "304 (If-None-Match) must carry Last-Modified"
11183        );
11184
11185        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
11186        let req = Request::builder()
11187            .method("GET")
11188            .uri("/index.html")
11189            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
11190            .body(AxumBody::empty())
11191            .unwrap();
11192        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11193        assert_eq!(
11194            resp.status(),
11195            StatusCode::NOT_MODIFIED,
11196            "If-Modified-Since with matching timestamp should return 304, got {}",
11197            resp.status()
11198        );
11199        assert!(
11200            resp.headers().contains_key(http::header::CACHE_CONTROL),
11201            "304 (If-Modified-Since) missing Cache-Control"
11202        );
11203        assert_eq!(
11204            resp.headers().get(http::header::ETAG),
11205            Some(&etag),
11206            "304 (If-Modified-Since) must carry the ETag validator"
11207        );
11208        assert_eq!(
11209            resp.headers().get(http::header::LAST_MODIFIED),
11210            Some(&last_modified),
11211            "304 (If-Modified-Since) must echo Last-Modified"
11212        );
11213
11214        // Negative control: a PAST If-Modified-Since (before the file's mtime)
11215        // MUST return 200 — proving the 304 path is validator-aware, not a
11216        // blanket "always 304" regression. A future date would correctly yield
11217        // 304 since the file's mtime precedes it; that is RFC-correct 304
11218        // behaviour, not a negative control.
11219        let req = Request::builder()
11220            .method("GET")
11221            .uri("/index.html")
11222            .header(
11223                http::header::IF_MODIFIED_SINCE,
11224                "Wed, 21 Oct 2000 07:28:00 GMT",
11225            )
11226            .body(AxumBody::empty())
11227            .unwrap();
11228        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11229        assert_eq!(
11230            resp.status(),
11231            StatusCode::OK,
11232            "past If-Modified-Since should return 200 (file modified after it), got {}",
11233            resp.status()
11234        );
11235
11236        // Cleanup
11237        std::fs::remove_dir_all(&temp_dir).ok();
11238    }
11239
11240    #[tokio::test]
11241    async fn test_conditional_get_returns_304_static_mode() {
11242        run_conditional_get_returns_304(MountMode::Static).await;
11243    }
11244
11245    #[tokio::test]
11246    async fn test_conditional_get_returns_304_spa_mode() {
11247        run_conditional_get_returns_304(MountMode::Spa).await;
11248    }
11249
11250    #[allow(clippy::await_holding_lock)]
11251    #[tokio::test]
11252    async fn test_error_page_mapping_serves_custom_404() {
11253        let _guard = lock_registry_test_mutex();
11254        ServerRegistry::reset();
11255
11256        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
11257        let errors_dir = temp_dir.join("errors");
11258        std::fs::create_dir_all(&errors_dir).unwrap();
11259        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11260        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
11261
11262        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11263        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
11264
11265        let registry = make_test_registry();
11266        let serve_dir = ServeDir::new(&canonical_dir)
11267            .precompressed_gzip()
11268            .precompressed_br()
11269            .append_index_html_on_directories(true);
11270
11271        let mut error_pages = std::collections::HashMap::new();
11272        error_pages.insert(404, canonical_404);
11273
11274        let mount = StaticMount {
11275            mount_path: "/".to_string(),
11276            mode: MountMode::Static,
11277            dir: canonical_dir.clone(),
11278            cache_control: "public, max-age=0".to_string(),
11279            error_pages,
11280            serve_dir,
11281        };
11282        registry.register_static_mount(mount).await.unwrap();
11283
11284        let state = make_test_state(registry);
11285
11286        // Request non-existent file → custom 404 page
11287        let req = Request::builder()
11288            .method("GET")
11289            .uri("/missing.html")
11290            .body(AxumBody::empty())
11291            .unwrap();
11292        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
11293        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11294        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11295            .await
11296            .unwrap();
11297        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
11298
11299        // Existing file still works
11300        let req = Request::builder()
11301            .method("GET")
11302            .uri("/index.html")
11303            .body(AxumBody::empty())
11304            .unwrap();
11305        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11306        assert_eq!(resp.status(), StatusCode::OK);
11307        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11308            .await
11309            .unwrap();
11310        assert_eq!(&body[..], b"<h1>Home</h1>");
11311
11312        // Cleanup
11313        std::fs::remove_dir_all(&temp_dir).ok();
11314    }
11315
11316    #[tokio::test]
11317    async fn http_consumer_returns_body_and_code_on_stop() {
11318        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
11319        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11320        use tower::ServiceExt;
11321
11322        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
11323        let set_body_step = CompiledStep::Process {
11324            kind_hint: camel_api::SpanKindHint::Internal,
11325            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11326                ex.input.body = Body::Text("nope".into());
11327                Box::pin(async move { Ok(ex) })
11328            }),
11329            body_contract: None,
11330            lifecycle: None,
11331            label: None,
11332            to_uri: None,
11333        };
11334        let set_status_step = CompiledStep::Process {
11335            kind_hint: camel_api::SpanKindHint::Internal,
11336            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11337                ex.input.set_header(
11338                    "CamelHttpResponseCode",
11339                    serde_json::Value::Number(409.into()),
11340                );
11341                Box::pin(async move { Ok(ex) })
11342            }),
11343            body_contract: None,
11344            lifecycle: None,
11345            label: None,
11346            to_uri: None,
11347        };
11348        let pipeline = compose_pipeline_with_handler(
11349            vec![set_body_step, set_status_step, CompiledStep::Stop],
11350            None,
11351            PipelineRuntimeCtx::compile_time(),
11352        );
11353
11354        let ex = Exchange::new(Message::default());
11355        let result = pipeline.oneshot(ex).await;
11356        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
11357        let returned = result.unwrap();
11358        assert_eq!(returned.input.body.as_text(), Some("nope"));
11359        assert_eq!(
11360            returned
11361                .input
11362                .header("CamelHttpResponseCode")
11363                .and_then(|v| v.as_u64()),
11364            Some(409)
11365        );
11366    }
11367
11368    #[tokio::test]
11369    async fn http_consumer_returns_200_when_body_empty_on_stop() {
11370        // After ADR-0024: Stop with no body + no status header produces 200 (same as
11371        // a normal completion with no body). The 204 default is gone — users who
11372        // want 204 set CamelHttpResponseCode=204 explicitly.
11373        //
11374        // This test stays at the pipeline level (consistent with the test above).
11375        // E2E coverage of the full HTTP dispatch path is in
11376        // crates/camel-test/tests/integration_test.rs.
11377        use camel_api::{Exchange, Message};
11378        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11379        use tower::ServiceExt;
11380
11381        let pipeline = compose_pipeline_with_handler(
11382            vec![CompiledStep::Stop],
11383            None,
11384            PipelineRuntimeCtx::compile_time(),
11385        );
11386        let ex = Exchange::new(Message::default());
11387        let result = pipeline.oneshot(ex).await;
11388        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
11389        // Body is default (empty); no CamelHttpResponseCode header was set.
11390        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
11391    }
11392
11393    // -----------------------------------------------------------------------
11394    // Task 5: Method-aware REST dispatch tests
11395    // -----------------------------------------------------------------------
11396
11397    /// Spins up an axum server on a free port with a fresh registry.
11398    /// Returns the port plus the registry so the caller can register
11399    /// REST endpoints directly.
11400    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
11401        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11402        let port = listener.local_addr().unwrap().port();
11403        let registry = HttpRouteRegistry::new();
11404        tokio::spawn(run_axum_server(
11405            listener,
11406            registry.clone(),
11407            2 * 1024 * 1024,
11408            10 * 1024 * 1024,
11409            Arc::new(tokio::sync::Semaphore::new(1024)),
11410            test_rt(),
11411            "test-route".into(),
11412        ));
11413        // Give the server a moment to start accepting.
11414        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
11415        (port, registry)
11416    }
11417
11418    /// Helper for REST integration tests: spawns a responder task that
11419    /// reads from `rx`, writes a fixed `(status, body)` back via the
11420    /// envelope's reply channel, and returns once the test request is
11421    /// satisfied.
11422    fn spawn_responder(
11423        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
11424        status: u16,
11425        body: String,
11426    ) -> tokio::task::JoinHandle<()> {
11427        tokio::spawn(async move {
11428            if let Some(envelope) = rx.recv().await {
11429                let _ = envelope.reply_tx.send(HttpReply {
11430                    status,
11431                    headers: vec![],
11432                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
11433                });
11434            }
11435        })
11436    }
11437
11438    #[tokio::test]
11439    async fn method_aware_dispatch_same_path_different_verbs() {
11440        let (port, registry) = spawn_test_server().await;
11441
11442        // Register two REST endpoints on the same path with different
11443        // methods. This is the core scenario REST DSL needs to support:
11444        // GET /users (list) and POST /users (create) must not overwrite
11445        // each other.
11446        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11447        registry
11448            .register_rest_endpoint(
11449                "GET".into(),
11450                vec![PathSegment::Literal("users".into())],
11451                get_tx,
11452            )
11453            .await;
11454
11455        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11456        registry
11457            .register_rest_endpoint(
11458                "POST".into(),
11459                vec![PathSegment::Literal("users".into())],
11460                post_tx,
11461            )
11462            .await;
11463
11464        let get_handle = spawn_responder(get_rx, 200, "list".into());
11465        let post_handle = spawn_responder(post_rx, 201, "create".into());
11466
11467        let client = reqwest::Client::new();
11468
11469        // GET /users → list route
11470        let resp = client
11471            .get(format!("http://127.0.0.1:{port}/users"))
11472            .send()
11473            .await
11474            .unwrap();
11475        assert_eq!(resp.status().as_u16(), 200);
11476        let body = resp.text().await.unwrap();
11477        assert_eq!(body, "list");
11478
11479        // POST /users → create route
11480        let resp = client
11481            .post(format!("http://127.0.0.1:{port}/users"))
11482            .send()
11483            .await
11484            .unwrap();
11485        assert_eq!(resp.status().as_u16(), 201);
11486        let body = resp.text().await.unwrap();
11487        assert_eq!(body, "create");
11488
11489        let _ = tokio::join!(get_handle, post_handle);
11490    }
11491
11492    #[tokio::test]
11493    async fn method_aware_dispatch_templated_path_extracts_params() {
11494        let (port, registry) = spawn_test_server().await;
11495
11496        // Register GET /users/{id} as a templated endpoint. The
11497        // dispatcher should match `/users/42` against the template and
11498        // attach `id=42` to the envelope's path_params.
11499        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11500        registry
11501            .register_rest_endpoint(
11502                "GET".into(),
11503                vec![
11504                    PathSegment::Literal("users".into()),
11505                    PathSegment::Param("id".into()),
11506                ],
11507                tx,
11508            )
11509            .await;
11510
11511        // Spawn a responder that echoes the captured id back in the body
11512        // so the test can verify the param was set.
11513        let handle = tokio::spawn(async move {
11514            if let Some(envelope) = rx.recv().await {
11515                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
11516                let _ = envelope.reply_tx.send(HttpReply {
11517                    status: 200,
11518                    headers: vec![],
11519                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
11520                });
11521            }
11522        });
11523
11524        let client = reqwest::Client::new();
11525        let resp = client
11526            .get(format!("http://127.0.0.1:{port}/users/42"))
11527            .send()
11528            .await
11529            .unwrap();
11530        assert_eq!(resp.status().as_u16(), 200);
11531        let body = resp.text().await.unwrap();
11532        assert_eq!(body, "id=42");
11533
11534        let _ = handle.await;
11535    }
11536
11537    #[tokio::test]
11538    async fn method_aware_dispatch_unmatched_method_falls_through() {
11539        // If no REST endpoint matches the method, dispatch must fall
11540        // through to the legacy api_routes lookup or static mounts. With
11541        // nothing else registered, the request gets 404 from static
11542        // dispatch.
11543        let (port, _registry) = spawn_test_server().await;
11544
11545        // Register only GET /users; a DELETE /users request has no match.
11546        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11547        _registry
11548            .register_rest_endpoint(
11549                "GET".into(),
11550                vec![PathSegment::Literal("users".into())],
11551                get_tx,
11552            )
11553            .await;
11554
11555        // Drain the GET channel in the background so the consumer side
11556        // doesn't block (we don't expect any envelopes here).
11557        let drain = tokio::spawn(async move {
11558            let mut get_rx = get_rx;
11559            while get_rx.recv().await.is_some() {}
11560        });
11561
11562        let client = reqwest::Client::new();
11563        let resp = client
11564            .delete(format!("http://127.0.0.1:{port}/users"))
11565            .send()
11566            .await
11567            .unwrap();
11568        assert_eq!(resp.status().as_u16(), 404);
11569
11570        drop(drain);
11571    }
11572
11573    #[tokio::test]
11574    async fn regression_legacy_exact_api_route_still_works() {
11575        // A `http:` route registered without an `httpMethod=` URI param
11576        // lands in the legacy api_routes registry. The dispatcher must
11577        // still find it via exact path lookup. This guards against
11578        // regressions introduced by the new REST-aware dispatch.
11579        let (port, registry) = spawn_test_server().await;
11580
11581        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11582        registry.register_api_route("/legacy/path".into(), tx).await;
11583
11584        let handle = tokio::spawn(async move {
11585            if let Some(envelope) = rx.recv().await {
11586                let _ = envelope.reply_tx.send(HttpReply {
11587                    status: 200,
11588                    headers: vec![],
11589                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
11590                });
11591            }
11592        });
11593
11594        let client = reqwest::Client::new();
11595        let resp = client
11596            .get(format!("http://127.0.0.1:{port}/legacy/path"))
11597            .send()
11598            .await
11599            .unwrap();
11600        assert_eq!(resp.status().as_u16(), 200);
11601        let body = resp.text().await.unwrap();
11602        assert_eq!(body, "legacy ok");
11603
11604        let _ = handle.await;
11605    }
11606
11607    #[allow(clippy::await_holding_lock)]
11608    #[tokio::test]
11609    async fn regression_static_mount_still_works() {
11610        // Verify that static file serving still works after the
11611        // dispatch refactor. We register a temp-dir mount and request
11612        // a file from it; the static dispatcher should serve it.
11613        let _guard = lock_registry_test_mutex();
11614        ServerRegistry::reset();
11615
11616        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
11617        std::fs::create_dir_all(&temp_dir).unwrap();
11618        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
11619        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11620
11621        let registry = make_test_registry();
11622        let serve_dir = ServeDir::new(&canonical_dir)
11623            .precompressed_gzip()
11624            .precompressed_br()
11625            .append_index_html_on_directories(true);
11626        let mount = StaticMount {
11627            mount_path: "/".to_string(),
11628            mode: MountMode::Static,
11629            dir: canonical_dir.clone(),
11630            cache_control: "public, max-age=3600".to_string(),
11631            error_pages: std::collections::HashMap::new(),
11632            serve_dir,
11633        };
11634        registry.register_static_mount(mount).await.unwrap();
11635
11636        let state = make_test_state(registry);
11637        let req = Request::builder()
11638            .uri("/regress.txt")
11639            .body(AxumBody::empty())
11640            .unwrap();
11641        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
11642        assert_eq!(resp.status(), StatusCode::OK);
11643        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11644            .await
11645            .unwrap();
11646        assert_eq!(&body[..], b"static works");
11647
11648        std::fs::remove_dir_all(&temp_dir).ok();
11649    }
11650
11651    // -----------------------------------------------------------------------
11652    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
11653    // templated from-URI round-trip. These exercise the real axum dispatch
11654    // path (register → HTTP request → reply) so a regression in any of the
11655    // three critical fixes surfaces as a test failure rather than a silent
11656    // production 404/500.
11657    // -----------------------------------------------------------------------
11658
11659    #[tokio::test]
11660    async fn deregister_one_method_keeps_sibling_verbs() {
11661        // Review C1: stopping the GET /users consumer must NOT tear down the
11662        // live POST /users endpoint. Register both, deregister GET only,
11663        // then verify POST still dispatches.
11664        let (port, registry) = spawn_test_server().await;
11665
11666        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11667        registry
11668            .register_rest_endpoint(
11669                "GET".into(),
11670                vec![PathSegment::Literal("users".into())],
11671                get_tx,
11672            )
11673            .await;
11674
11675        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11676        registry
11677            .register_rest_endpoint(
11678                "POST".into(),
11679                vec![PathSegment::Literal("users".into())],
11680                post_tx,
11681            )
11682            .await;
11683
11684        // Drain GET in the background (no requests expected after deregister).
11685        let drain = tokio::spawn(async move {
11686            let mut get_rx = get_rx;
11687            while get_rx.recv().await.is_some() {}
11688        });
11689
11690        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
11691        registry.unregister_rest_endpoint("GET", "/users").await;
11692        drop(drain);
11693
11694        let post_handle = spawn_responder(post_rx, 201, "create".into());
11695
11696        let client = reqwest::Client::new();
11697        // POST /users must still reach its consumer after GET was removed.
11698        let resp = client
11699            .post(format!("http://127.0.0.1:{port}/users"))
11700            .send()
11701            .await
11702            .unwrap();
11703        assert_eq!(resp.status().as_u16(), 201);
11704        assert_eq!(resp.text().await.unwrap(), "create");
11705
11706        let _ = post_handle.await;
11707    }
11708
11709    #[tokio::test]
11710    async fn dispatch_exact_legacy_beats_rest_template() {
11711        // Review C2: an exact legacy API route (`GET /api/users`, no
11712        // httpMethod) must win over a templated REST route
11713        // (`GET /api/{resource}`) for the request `/api/users`, per spec
11714        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
11715        let (port, registry) = spawn_test_server().await;
11716
11717        // Exact legacy route.
11718        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11719        registry
11720            .register_api_route("/api/users".into(), exact_tx)
11721            .await;
11722        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
11723
11724        // Templated REST route that would ALSO match /api/users.
11725        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11726        registry
11727            .register_rest_endpoint(
11728                "GET".into(),
11729                vec![
11730                    PathSegment::Literal("api".into()),
11731                    PathSegment::Param("resource".into()),
11732                ],
11733                tpl_tx,
11734            )
11735            .await;
11736        // The templated handler must NOT receive the /api/users request. If
11737        // it does, it replies "template-leak" so a future assertion could
11738        // catch it. We do NOT await this task: the exact-match branch wins
11739        // and the templated channel never receives, so awaiting would block
11740        // until the test runtime tears down.
11741        let _tpl_drain = tokio::spawn(async move {
11742            let mut tpl_rx = tpl_rx;
11743            if let Some(env) = tpl_rx.recv().await {
11744                let _ = env.reply_tx.send(HttpReply {
11745                    status: 200,
11746                    headers: vec![],
11747                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
11748                });
11749            }
11750        });
11751
11752        let client = reqwest::Client::new();
11753        let resp = client
11754            .get(format!("http://127.0.0.1:{port}/api/users"))
11755            .send()
11756            .await
11757            .unwrap();
11758        assert_eq!(resp.status().as_u16(), 200);
11759        // Exact-match handler answered — not the templated one.
11760        assert_eq!(resp.text().await.unwrap(), "exact");
11761
11762        let _ = exact_handle.await;
11763    }
11764
11765    #[tokio::test]
11766    async fn ambiguous_rest_templates_return_500_not_silent_404() {
11767        // Review C3: two equal-specificity templates that both match one
11768        // request are an ambiguous registration. At runtime this must
11769        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
11770        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
11771        let (port, registry) = spawn_test_server().await;
11772
11773        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11774        registry
11775            .register_rest_endpoint(
11776                "GET".into(),
11777                vec![
11778                    PathSegment::Literal("users".into()),
11779                    PathSegment::Param("id".into()),
11780                ],
11781                a_tx,
11782            )
11783            .await;
11784
11785        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11786        registry
11787            .register_rest_endpoint(
11788                "GET".into(),
11789                vec![
11790                    PathSegment::Literal("users".into()),
11791                    PathSegment::Param("name".into()),
11792                ],
11793                b_tx,
11794            )
11795            .await;
11796
11797        let client = reqwest::Client::new();
11798        let resp = client
11799            .get(format!("http://127.0.0.1:{port}/users/42"))
11800            .send()
11801            .await
11802            .unwrap();
11803        // Ambiguous → 500 (previously a silent 404).
11804        assert_eq!(resp.status().as_u16(), 500);
11805    }
11806
11807    #[test]
11808    fn from_uri_round_trips_templated_path_with_http_method() {
11809        // Review I4: a REST-lowered from-URI like
11810        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
11811        // through HttpServerConfig::from_uri, preserving the templated path
11812        // and the (uppercased) method. This is the binding the DSL lowering
11813        // emits and the consumer reads; it was previously unasserted.
11814        use crate::UriConfig;
11815        let cfg =
11816            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
11817        assert_eq!(cfg.host, "0.0.0.0");
11818        assert_eq!(cfg.port, 8080);
11819        assert_eq!(cfg.path, "/users/{id}");
11820        assert_eq!(cfg.method.as_deref(), Some("GET"));
11821
11822        // Lower-case httpMethod is uppercased (review I5).
11823        let cfg_lc =
11824            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
11825        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
11826        assert_eq!(cfg_lc.path, "/orders");
11827    }
11828
11829    // -----------------------------------------------------------------------
11830    // rc-1dk4: TypeConversionFailed → 400 Bad Request
11831    // -----------------------------------------------------------------------
11832
11833    #[test]
11834    fn type_conversion_failed_maps_to_400() {
11835        let reply = pipeline_error_to_reply(
11836            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
11837            "/api/users",
11838        );
11839        assert_eq!(reply.status, 400);
11840        // Exactly one Content-Type header, application/json
11841        let json_ct = reply
11842            .headers
11843            .iter()
11844            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11845            .count();
11846        assert_eq!(json_ct, 1);
11847        // Body must be structured error JSON with the expected fields
11848        let body = match &reply.body {
11849            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11850            _ => panic!("expected bytes body"),
11851        };
11852        let parsed: serde_json::Value =
11853            serde_json::from_str(&body).expect("body must be valid JSON");
11854        assert_eq!(parsed["error"], "bad_request");
11855        assert_eq!(parsed["message"], "invalid JSON at line 1");
11856    }
11857
11858    #[test]
11859    fn other_error_still_maps_to_500() {
11860        let reply =
11861            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
11862        assert_eq!(reply.status, 500);
11863    }
11864
11865    #[test]
11866    fn unauthenticated_maps_to_401() {
11867        let reply = pipeline_error_to_reply(
11868            CamelError::Unauthenticated("no token".to_string()),
11869            "/api/users",
11870        );
11871        assert_eq!(reply.status, 401);
11872    }
11873
11874    #[test]
11875    fn unauthorized_maps_to_403() {
11876        let reply = pipeline_error_to_reply(
11877            CamelError::Unauthorized("forbidden".to_string()),
11878            "/api/users",
11879        );
11880        assert_eq!(reply.status, 403);
11881    }
11882
11883    #[test]
11884    fn validation_error_maps_to_400() {
11885        let reply = pipeline_error_to_reply(
11886            CamelError::ValidationError("body does not match schema".to_string()),
11887            "/api/users",
11888        );
11889        assert_eq!(reply.status, 400);
11890        let json_ct = reply
11891            .headers
11892            .iter()
11893            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11894            .count();
11895        assert_eq!(json_ct, 1);
11896        let body = match &reply.body {
11897            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11898            _ => panic!("expected bytes body"),
11899        };
11900        let parsed: serde_json::Value =
11901            serde_json::from_str(&body).expect("body must be valid JSON");
11902        assert_eq!(parsed["error"], "validation_error");
11903        assert_eq!(parsed["message"], "body does not match schema");
11904    }
11905
11906    // -----------------------------------------------------------------------
11907    // rc-hlb1q: media negotiation errors → 415 / 406
11908    // -----------------------------------------------------------------------
11909
11910    #[test]
11911    fn finalizer_maps_unsupported_media_type() {
11912        let reply = pipeline_error_to_reply(
11913            CamelError::UnsupportedMediaType {
11914                consumed: "text/plain".to_string(),
11915                declared: "application/json".to_string(),
11916            },
11917            "/x",
11918        );
11919        assert_eq!(reply.status, 415);
11920        let json_ct = reply
11921            .headers
11922            .iter()
11923            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11924            .count();
11925        assert_eq!(json_ct, 1);
11926        let body = match &reply.body {
11927            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11928            _ => panic!("expected bytes body"),
11929        };
11930        let parsed: serde_json::Value =
11931            serde_json::from_str(&body).expect("body must be valid JSON");
11932        assert_eq!(parsed["error"], "unsupported_media_type");
11933        assert_eq!(
11934            parsed["message"],
11935            "consumed text/plain, declared application/json"
11936        );
11937    }
11938
11939    #[test]
11940    fn finalizer_maps_not_acceptable() {
11941        let reply = pipeline_error_to_reply(
11942            CamelError::NotAcceptable {
11943                accept: "application/xml".to_string(),
11944                produced: "application/json".to_string(),
11945            },
11946            "/x",
11947        );
11948        assert_eq!(reply.status, 406);
11949        let json_ct = reply
11950            .headers
11951            .iter()
11952            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11953            .count();
11954        assert_eq!(json_ct, 1);
11955        let body = match &reply.body {
11956            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11957            _ => panic!("expected bytes body"),
11958        };
11959        let parsed: serde_json::Value =
11960            serde_json::from_str(&body).expect("body must be valid JSON");
11961        assert_eq!(parsed["error"], "not_acceptable");
11962        assert_eq!(
11963            parsed["message"],
11964            "accept application/xml, produced application/json"
11965        );
11966    }
11967
11968    #[test]
11969    fn json_error_reply_preserves_empty_message() {
11970        let reply = json_error_reply(400, "bad_request", "".to_string());
11971        assert_eq!(reply.status, 400);
11972        let json_ct = reply
11973            .headers
11974            .iter()
11975            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11976            .count();
11977        assert_eq!(json_ct, 1);
11978        let body = match &reply.body {
11979            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11980            _ => panic!("expected bytes body"),
11981        };
11982        let parsed: serde_json::Value =
11983            serde_json::from_str(&body).expect("body must be valid JSON");
11984        assert_eq!(parsed["error"], "bad_request");
11985        assert_eq!(parsed["message"], "");
11986    }
11987
11988    #[test]
11989    fn https_consumer_without_tls_cert_errors() {
11990        let endpoint = HttpEndpoint {
11991            uri: "https://0.0.0.0:8443/api".to_string(),
11992            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11993            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11994            client: reqwest::Client::new(),
11995            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11996                PINNED_CLIENT_TTL,
11997                PINNED_CLIENT_MAX_ENTRIES,
11998            )),
11999            http_config: HttpConfig::default(),
12000        };
12001        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12002        let result = endpoint.create_consumer(rt);
12003        assert!(result.is_err(), "expected error for https without tls cert");
12004        if let Err(e) = result {
12005            let msg = e.to_string();
12006            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
12007        }
12008    }
12009
12010    #[test]
12011    fn http_consumer_with_tls_config_errors() {
12012        let endpoint = HttpEndpoint {
12013            uri: "http://0.0.0.0:8080/api".to_string(),
12014            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
12015            server_config: HttpServerConfig::from_uri(
12016                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
12017            )
12018            .unwrap(),
12019            client: reqwest::Client::new(),
12020            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12021                PINNED_CLIENT_TTL,
12022                PINNED_CLIENT_MAX_ENTRIES,
12023            )),
12024            http_config: HttpConfig::default(),
12025        };
12026        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12027        let result = endpoint.create_consumer(rt);
12028        assert!(result.is_err(), "expected error for http with tls config");
12029        if let Err(e) = result {
12030            let msg = e.to_string();
12031            assert!(msg.contains("https"), "error must mention https: {msg}");
12032        }
12033    }
12034
12035    #[test]
12036    fn https_consumer_with_partial_tls_cert_only_errors() {
12037        // tlsCert without tlsKey → tls_config is None at parse time
12038        // → create_consumer sees https:// + no TLS → must error
12039        let server_config =
12040            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12041        assert!(
12042            server_config.tls_config.is_none(),
12043            "partial tlsCert must not create ServerTlsConfig"
12044        );
12045        let endpoint = HttpEndpoint {
12046            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
12047            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
12048                .unwrap(),
12049            server_config,
12050            client: reqwest::Client::new(),
12051            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12052                PINNED_CLIENT_TTL,
12053                PINNED_CLIENT_MAX_ENTRIES,
12054            )),
12055            http_config: HttpConfig::default(),
12056        };
12057        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12058        let result = endpoint.create_consumer(rt);
12059        assert!(
12060            result.is_err(),
12061            "must error: https:// requires both tlsCert and tlsKey"
12062        );
12063    }
12064
12065    #[test]
12066    fn load_tls_config_parses_valid_pem() {
12067        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
12068        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12069        use camel_component_api::test_support::tls;
12070        let (_, cert_pem, key_pem) = tls::gen_server_cert();
12071        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
12072        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
12073
12074        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
12075        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
12076    }
12077
12078    #[tokio::test(flavor = "multi_thread")]
12079    #[allow(clippy::await_holding_lock)]
12080    async fn consumer_tls_handshake_roundtrip() {
12081        use camel_component_api::test_support::tls;
12082        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12083
12084        // Install rustls crypto provider (aws-lc-rs)
12085        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12086
12087        // Serialize against global ServerRegistry singleton
12088        let _guard = lock_registry_test_mutex();
12089
12090        // Generate CA + server cert
12091        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
12092        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
12093        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
12094        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
12095
12096        // Get ephemeral port
12097        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12098        let port = probe.local_addr().unwrap().port();
12099        drop(probe);
12100
12101        ServerRegistry::reset();
12102
12103        // Create real HttpComponent + endpoint with TLS URI
12104        let component = HttpComponent::new();
12105        let endpoint_ctx = NoOpComponentContext;
12106        let uri = format!(
12107            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12108            cert_path.to_string_lossy(),
12109            key_path.to_string_lossy(),
12110        );
12111        let endpoint = component
12112            .create_endpoint(&uri, &endpoint_ctx)
12113            .expect("create TLS endpoint");
12114        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
12115
12116        // Start consumer — this calls get_or_spawn with tls_config
12117        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12118        let token = tokio_util::sync::CancellationToken::new();
12119        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
12120        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12121
12122        // Give server time to start
12123        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
12124
12125        // Client with CA cert — REAL verification (no danger_accept_invalid)
12126        let ca_bytes = std::fs::read(&ca_path).unwrap();
12127        let client = reqwest::Client::builder()
12128            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
12129            .build()
12130            .unwrap();
12131
12132        let send_fut = client
12133            .post(format!("https://localhost:{port}/test"))
12134            .body("ping")
12135            .send();
12136
12137        // Handler: receive envelope, reply 200 with "pong" body
12138        let (http_result, _) = tokio::join!(send_fut, async {
12139            if let Some(mut envelope) = rx.recv().await {
12140                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
12141                if let Some(reply_tx) = envelope.reply_tx {
12142                    let _ = reply_tx.send(Ok(envelope.exchange));
12143                }
12144            }
12145        });
12146
12147        let resp = http_result.expect("TLS handshake + request must succeed");
12148
12149        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
12150        let body = resp.text().await.unwrap();
12151        assert_eq!(body, "pong");
12152
12153        token.cancel();
12154    }
12155
12156    #[tokio::test(flavor = "multi_thread")]
12157    #[allow(clippy::await_holding_lock)]
12158    async fn consumer_tls_rejects_client_without_ca() {
12159        use camel_component_api::test_support::tls;
12160        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12161
12162        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12163
12164        // Serialize against global ServerRegistry singleton
12165        let _guard = lock_registry_test_mutex();
12166
12167        let (_, cert_pem, key_pem) = tls::gen_server_cert();
12168        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
12169        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
12170
12171        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12172        let port = probe.local_addr().unwrap().port();
12173        drop(probe);
12174
12175        ServerRegistry::reset();
12176
12177        // Spawn TLS server via real HttpComponent path
12178        let component = HttpComponent::new();
12179        let endpoint_ctx = NoOpComponentContext;
12180        let uri = format!(
12181            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12182            cert_path.to_string_lossy(),
12183            key_path.to_string_lossy(),
12184        );
12185        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
12186        let mut consumer = endpoint.create_consumer(rt()).unwrap();
12187        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12188        let token = tokio_util::sync::CancellationToken::new();
12189        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
12190        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12191
12192        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
12193
12194        // Client WITHOUT CA cert — must fail TLS verification
12195        let client = reqwest::Client::builder().build().unwrap();
12196
12197        let result = client
12198            .get(format!("https://localhost:{port}/test"))
12199            .send()
12200            .await;
12201
12202        assert!(
12203            result.is_err(),
12204            "must reject without CA — proves real verification"
12205        );
12206
12207        token.cancel();
12208    }
12209
12210    #[test]
12211    fn server_config_partial_tls_cert_without_key() {
12212        // Parse URI with only tlsCert (no tlsKey)
12213        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12214        // Partial params → tls_config must be None
12215        assert!(cfg.tls_config.is_none());
12216    }
12217
12218    #[test]
12219    fn endpoint_uri_options_count_parity() {
12220        // Mirror struct must stay in sync with bespoke from_components parser.
12221        assert_eq!(
12222            HttpEndpointConfig::uri_options().len(),
12223            23,
12224            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
12225        );
12226    }
12227
12228    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
12229        pairs
12230            .iter()
12231            .map(|(k, v)| {
12232                (
12233                    (*k).to_string(),
12234                    serde_json::Value::String((*v).to_string()),
12235                )
12236            })
12237            .collect()
12238    }
12239
12240    #[test]
12241    fn response_emits_cache_control_via_pragma_warning() {
12242        let headers = make_headers(&[
12243            ("Cache-Control", "public, max-age=3600"),
12244            ("Via", "1.1 myproxy"),
12245            ("Pragma", "no-cache"),
12246            ("Warning", "199 misc"),
12247        ]);
12248        let selected = select_response_headers(&headers, None, None);
12249        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12250        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
12251            assert!(
12252                names.contains(&expected),
12253                "{expected} should pass through to the response"
12254            );
12255        }
12256    }
12257
12258    #[test]
12259    fn response_excludes_request_only_and_server_owned() {
12260        let headers = make_headers(&[
12261            ("User-Agent", "x"),
12262            ("Accept", "*/*"),
12263            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
12264        ]);
12265        let selected = select_response_headers(&headers, None, None);
12266        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12267        for excluded in ["User-Agent", "Accept", "Date"] {
12268            assert!(
12269                !names.contains(&excluded),
12270                "{excluded} should NOT appear in the response"
12271            );
12272        }
12273    }
12274
12275    #[test]
12276    fn response_re_derives_content_type() {
12277        let headers = make_headers(&[("Content-Type", "text/plain")]);
12278        let selected = select_response_headers(&headers, Some("application/json".into()), None);
12279        let ct_entries: Vec<&str> = selected
12280            .iter()
12281            .filter(|(k, _)| k == "Content-Type")
12282            .map(|(_, v)| v.as_str())
12283            .collect();
12284        assert_eq!(
12285            ct_entries,
12286            ["application/json"],
12287            "exactly one Content-Type entry, re-derived from user_content_type"
12288        );
12289    }
12290
12291    #[test]
12292    fn response_excludes_camel_headers() {
12293        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
12294        let selected = select_response_headers(&headers, None, None);
12295        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12296        assert!(
12297            !names.contains(&"CamelHttpPath"),
12298            "Camel-namespace headers must be excluded"
12299        );
12300        assert!(
12301            names.contains(&"Cache-Control"),
12302            "Cache-Control must pass through"
12303        );
12304    }
12305
12306    #[test]
12307    fn response_stringifies_scalar_header_values() {
12308        let mut headers = make_headers(&[("X-Label", "keep")]);
12309        headers.insert("X-Retries".to_string(), serde_json::json!(3));
12310        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12311        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12312        let selected = select_response_headers(&headers, None, None);
12313        let get = |name: &str| -> Option<&str> {
12314            selected
12315                .iter()
12316                .find(|(k, _)| k == name)
12317                .map(|(_, v)| v.as_str())
12318        };
12319        assert_eq!(
12320            get("X-Retries"),
12321            Some("3"),
12322            "integer header must be stringified"
12323        );
12324        assert_eq!(
12325            get("X-Ratio"),
12326            Some("3.5"),
12327            "float header must be stringified"
12328        );
12329        assert_eq!(
12330            get("X-Enabled"),
12331            Some("true"),
12332            "bool header must be stringified"
12333        );
12334        assert_eq!(
12335            get("X-Label"),
12336            Some("keep"),
12337            "string header must pass through"
12338        );
12339    }
12340
12341    #[test]
12342    fn response_drops_null_and_structured_header_values() {
12343        let mut headers = make_headers(&[("X-Keep", "yes")]);
12344        headers.insert("X-Null".to_string(), serde_json::Value::Null);
12345        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12346        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12347        let selected = select_response_headers(&headers, None, None);
12348        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12349        for dropped in ["X-Null", "X-Obj", "X-Arr"] {
12350            assert!(
12351                !names.contains(&dropped),
12352                "{dropped} must not be emitted: no single-value form"
12353            );
12354        }
12355        assert!(names.contains(&"X-Keep"), "scalar headers must survive");
12356    }
12357
12358    #[test]
12359    fn response_stringifies_scalars_despite_excluded_names() {
12360        // Excluded names stay excluded regardless of value type: the policy
12361        // filter runs before stringification, so numeric values cannot smuggle
12362        // content-length or server-owned headers into the reply.
12363        let mut headers = HashMap::new();
12364        headers.insert("Content-Length".to_string(), serde_json::json!(999));
12365        headers.insert("Date".to_string(), serde_json::json!(12345));
12366        let selected = select_response_headers(&headers, None, None);
12367        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12368        assert!(
12369            !names.contains(&"Content-Length"),
12370            "content-length is re-derived by the server"
12371        );
12372        assert!(!names.contains(&"Date"), "date is server-owned");
12373    }
12374
12375    #[test]
12376    fn outbound_stringifies_scalar_header_values() {
12377        let mut headers = make_headers(&[("X-Label", "keep")]);
12378        headers.insert("X-Retries".to_string(), serde_json::json!(3));
12379        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12380        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12381        let outbound = select_outbound_headers(&headers, &[], &[]);
12382        // HeaderName construction lowercases; lookups compare case-blind.
12383        let get = |name: &str| -> Option<String> {
12384            outbound
12385                .accepted
12386                .iter()
12387                .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12388                .map(|(_, v)| v.to_str().unwrap().to_string())
12389        };
12390        assert_eq!(
12391            get("X-Retries").as_deref(),
12392            Some("3"),
12393            "integer header must be stringified"
12394        );
12395        assert_eq!(
12396            get("X-Ratio").as_deref(),
12397            Some("3.5"),
12398            "float header must be stringified"
12399        );
12400        assert_eq!(
12401            get("X-Enabled").as_deref(),
12402            Some("true"),
12403            "bool header must be stringified"
12404        );
12405        assert_eq!(
12406            get("X-Label").as_deref(),
12407            Some("keep"),
12408            "string header must pass through"
12409        );
12410        assert!(outbound.drops.is_empty(), "scalar headers must not drop");
12411    }
12412
12413    #[test]
12414    fn outbound_drops_null_and_structured_header_values() {
12415        let mut headers = make_headers(&[("X-Keep", "yes")]);
12416        headers.insert("X-Null".to_string(), serde_json::Value::Null);
12417        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12418        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12419        let outbound = select_outbound_headers(&headers, &[], &[]);
12420        let has = |name: &str| {
12421            outbound
12422                .accepted
12423                .iter()
12424                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12425        };
12426        assert!(has("X-Keep"), "scalar headers must survive");
12427        for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
12428            let dropped = outbound
12429                .drops
12430                .iter()
12431                .find(|d| d.name == name)
12432                .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
12433            assert_eq!(
12434                dropped.reason, "no scalar string form",
12435                "{name} drop reason must name the value kind absence"
12436            );
12437            assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
12438        }
12439    }
12440
12441    #[test]
12442    fn outbound_stringifies_scalars_despite_excluded_names() {
12443        // Excluded names stay excluded regardless of value type: the policy
12444        // filter runs before stringification, so numeric values cannot smuggle
12445        // hop-by-hop or client-derived headers onto the wire.
12446        let mut headers = HashMap::new();
12447        headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
12448        headers.insert("Host".to_string(), serde_json::json!(12345));
12449        headers.insert("X-Ok".to_string(), serde_json::json!(7));
12450        let outbound = select_outbound_headers(&headers, &[], &[]);
12451        let has = |name: &str| {
12452            outbound
12453                .accepted
12454                .iter()
12455                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12456        };
12457        assert!(
12458            !has("Transfer-Encoding"),
12459            "hop-by-hop header must stay excluded"
12460        );
12461        assert!(!has("Host"), "host is destination-derived");
12462        assert!(has("X-Ok"), "non-excluded scalar must be stringified");
12463        assert!(
12464            outbound
12465                .drops
12466                .iter()
12467                .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
12468            "policy drop must be recorded before coercion"
12469        );
12470    }
12471
12472    #[test]
12473    fn outbound_drops_invalid_names_values_and_skip_config() {
12474        let mut headers = make_headers(&[("X-Good", "fine")]);
12475        headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
12476        headers.insert(
12477            "X-Control-Value".to_string(),
12478            serde_json::json!("line1\nline2"),
12479        );
12480        headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
12481        headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
12482        let skip = vec!["x-secret".to_string()];
12483        let outbound = select_outbound_headers(&headers, &skip, &[]);
12484        let has = |name: &str| {
12485            outbound
12486                .accepted
12487                .iter()
12488                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12489        };
12490        assert!(has("X-Good"), "valid header must survive");
12491        assert!(!has("X Bad Name"), "invalid header name must drop");
12492        assert!(!has("X-Control-Value"), "control-char value must drop");
12493        assert!(!has("X-Secret"), "skipped header must drop");
12494        assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
12495        let reason = |n: &str| {
12496            outbound
12497                .drops
12498                .iter()
12499                .find(|d| d.name == n)
12500                .map(|d| d.reason)
12501        };
12502        assert_eq!(reason("X Bad Name"), Some("invalid header name"));
12503        assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
12504        assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
12505        assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
12506    }
12507
12508    #[test]
12509    fn constructed_header_invalid_value_returns_drop_record() {
12510        let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
12511        let Err(record) = result else {
12512            panic!("invalid value must produce a drop record");
12513        };
12514        assert_eq!(record.reason, "invalid header value");
12515        assert_eq!(record.name, "user-agent");
12516        assert!(record.value_kind.is_none());
12517        let debug = format!("{record:?}");
12518        assert!(
12519            !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
12520            "drop record debug must not leak the value"
12521        );
12522    }
12523
12524    #[test]
12525    fn constructed_header_invalid_name_returns_drop_record() {
12526        let result = constructed_header("bad name", "ok");
12527        let Err(record) = result else {
12528            panic!("invalid name must produce a drop record");
12529        };
12530        assert_eq!(record.reason, "invalid header name");
12531        assert_eq!(record.name, "bad name");
12532        let debug = format!("{record:?}");
12533        assert!(
12534            !debug.contains("ok"),
12535            "drop record debug must not leak the value"
12536        );
12537    }
12538
12539    #[test]
12540    fn constructed_header_valid_pair_roundtrip() {
12541        let result = constructed_header("authorization", "Bearer abc123");
12542        let Ok((name, val)) = result else {
12543            panic!("valid pair must construct");
12544        };
12545        assert_eq!(name.as_str(), "authorization");
12546        let Ok(roundtrip) = val.to_str() else {
12547            panic!("valid value must roundtrip to str");
12548        };
12549        assert_eq!(roundtrip, "Bearer abc123");
12550    }
12551
12552    // -----------------------------------------------------------------------
12553    // Bridge proxy end-to-end integration tests (Task 4.1)
12554    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
12555    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
12556    // -----------------------------------------------------------------------
12557
12558    /// Destination server that captures the outbound request line and the
12559    /// `Host:` header the producer actually sent on the wire. Returns
12560    /// `(host_value, request_line)` so a bridge-proxy test can assert that
12561    /// the producer derived `Host` from the destination (not the exchange)
12562    /// and honoured bridging semantics for the path.
12563    async fn start_host_capturing_destination() -> (
12564        String,
12565        Arc<std::sync::Mutex<Option<(String, String)>>>,
12566        tokio::task::JoinHandle<()>,
12567    ) {
12568        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12569        let port = listener.local_addr().unwrap().port();
12570        let url = format!("http://127.0.0.1:{port}");
12571        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
12572            Arc::new(std::sync::Mutex::new(None));
12573        let captured_clone = Arc::clone(&captured);
12574        let handle = tokio::spawn(async move {
12575            use tokio::io::{AsyncReadExt, AsyncWriteExt};
12576            if let Ok((mut stream, _)) = listener.accept().await {
12577                let mut buf = vec![0u8; 16384];
12578                let n = stream.read(&mut buf).await.unwrap_or(0);
12579                let request = String::from_utf8_lossy(&buf[..n]).to_string();
12580                if request.contains("\r\n\r\n") {
12581                    let request_line = request.lines().next().unwrap_or("").to_string();
12582                    let host_value = request
12583                        .lines()
12584                        .find(|l| l.to_lowercase().starts_with("host:"))
12585                        .and_then(|l| l.split_once(':'))
12586                        .map(|(_, v)| v.trim().to_string())
12587                        .unwrap_or_default();
12588                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
12589                }
12590                let body = r#"{"echo":"ok"}"#;
12591                let resp = format!(
12592                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
12593                    body.len(),
12594                    body
12595                );
12596                let _ = stream.write_all(resp.as_bytes()).await;
12597            }
12598        });
12599        (url, captured, handle)
12600    }
12601
12602    /// A bridging producer must derive `Host` from the destination URL and
12603    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
12604    /// semantics. The wire-level proof is the raw `Host:` header and request
12605    /// line captured at the destination TCP socket.
12606    #[tokio::test]
12607    async fn bridge_proxy_outbound_host_matches_destination() {
12608        use tower::ServiceExt;
12609
12610        let (url, captured, _handle) = start_host_capturing_destination().await;
12611        // The Host header reqwest derives for http://127.0.0.1:{port} is the
12612        // authority, scheme-stripped: "127.0.0.1:{port}".
12613        let expected_host = url.strip_prefix("http://").unwrap();
12614
12615        let ctx = test_producer_ctx();
12616        let component = HttpComponent::new();
12617        let endpoint_ctx = NoOpComponentContext;
12618        let endpoint = component
12619            .create_endpoint(
12620                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
12621                &endpoint_ctx,
12622            )
12623            .unwrap();
12624        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
12625
12626        // Exchange carries a stale Host and a CamelHttpPath that bridging
12627        // must drop.
12628        let mut exchange = Exchange::new(Message::default());
12629        exchange.input.set_header("Host", "localhost");
12630        exchange.input.set_header("CamelHttpPath", "/foo");
12631
12632        let result = producer.oneshot(exchange).await;
12633        assert!(result.is_ok(), "producer call failed: {:?}", result);
12634
12635        tokio::time::sleep(Duration::from_millis(100)).await;
12636        let (host_value, request_line) = captured
12637            .lock()
12638            .unwrap()
12639            .take()
12640            .expect("destination capture mutex empty — producer did not reach the destination");
12641
12642        assert_ne!(
12643            host_value, "localhost",
12644            "bridge producer must not forward the exchange Host: localhost"
12645        );
12646        assert_eq!(
12647            host_value, expected_host,
12648            "Host must be derived from the destination authority (no scheme)"
12649        );
12650        assert!(
12651            !request_line.contains("/foo"),
12652            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
12653        );
12654    }
12655
12656    /// A response header set by the route (`Cache-Control`) must survive to
12657    /// the wire. The assertion is on the reqwest HTTP response — not an
12658    /// in-process HttpReply struct — so it proves the consumer's reply
12659    /// finaliser emitted the header over the socket.
12660    #[tokio::test]
12661    async fn bridge_proxy_route_set_response_header_survives() {
12662        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12663
12664        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12665        let port = listener.local_addr().unwrap().port();
12666        drop(listener);
12667
12668        let component = HttpComponent::new();
12669        let endpoint_ctx = NoOpComponentContext;
12670        let endpoint = component
12671            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
12672            .unwrap();
12673        let mut consumer = endpoint.create_consumer(rt()).unwrap();
12674
12675        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12676        let token = tokio_util::sync::CancellationToken::new();
12677        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
12678
12679        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12680        tokio::time::sleep(Duration::from_millis(50)).await;
12681
12682        let client = reqwest::Client::new();
12683        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
12684
12685        // Route sets Cache-Control on the outbound reply (exchange.input is
12686        // the message the reply finaliser reads — see select_response_headers
12687        // at the dispatch site).
12688        let (http_result, _) = tokio::join!(send_fut, async {
12689            if let Some(mut envelope) = rx.recv().await {
12690                envelope
12691                    .exchange
12692                    .input
12693                    .set_header("Cache-Control", "public, max-age=3600");
12694                if let Some(reply_tx) = envelope.reply_tx {
12695                    let _ = reply_tx.send(Ok(envelope.exchange));
12696                }
12697            }
12698        });
12699
12700        let resp = http_result.unwrap();
12701        assert_eq!(resp.status().as_u16(), 200);
12702
12703        let cache_control = resp.headers().get("cache-control");
12704        assert!(
12705            cache_control.is_some(),
12706            "Cache-Control header must survive to the wire response"
12707        );
12708        assert_eq!(
12709            cache_control.unwrap().to_str().unwrap(),
12710            "public, max-age=3600"
12711        );
12712
12713        token.cancel();
12714    }
12715
12716    // -----------------------------------------------------------------------
12717    // credential-sources task 2.3: credential values stay out of diagnostics
12718    // -----------------------------------------------------------------------
12719    //
12720    // camel-http has no request access log (design.md "Redaction sinks",
12721    // ADR-0051). The only diagnostic sink on the failed-auth path is
12722    // `pipeline_error_to_reply`, which renders the (generic) error message and
12723    // the *configured* route path — never the request URI, query string, or
12724    // extracted credential. These tests pin that redact-by-construction
12725    // contract: a sentinel credential presented in a declared source must not
12726    // appear in the reply body nor in any tracing record emitted while the
12727    // request is handled.
12728    //
12729    // Capture scope: `#[traced_test]` installs a per-crate env filter
12730    // (`camel_component_http=trace`), so records from OTHER targets
12731    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
12732    // redaction contract for those crates is guarded by their own tests.
12733    // Revisit this capture scope if camel-auth ever logs on the auth path.
12734    use camel_api::security_policy::CredentialSource;
12735    use camel_auth::credential_source::extract_token_from_exchange;
12736    use camel_auth::native_auth::NativeCredentialStore;
12737    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
12738
12739    // Sentinel credential values — test fixtures only, not real secrets.
12740    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
12741    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
12742    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
12743
12744    /// Build the exchange the consumer would build for a request envelope:
12745    /// standard Camel HTTP headers plus title-cased forwarded request headers.
12746    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
12747        let mut msg = Message::default();
12748        msg.set_header(
12749            "CamelHttpMethod",
12750            serde_json::Value::String(envelope.method.clone()),
12751        );
12752        msg.set_header(
12753            "CamelHttpPath",
12754            serde_json::Value::String(envelope.path.clone()),
12755        );
12756        msg.set_header(
12757            "CamelHttpQuery",
12758            serde_json::Value::String(envelope.query.clone()),
12759        );
12760        for (k, v) in &envelope.headers {
12761            if let Ok(val_str) = v.to_str() {
12762                msg.set_header(
12763                    title_case_header(k.as_str()),
12764                    serde_json::Value::String(val_str.to_string()),
12765                );
12766            }
12767        }
12768        Exchange::new(msg)
12769    }
12770
12771    /// Register a route whose responder authenticates each request against an
12772    /// empty native store, so every presented credential fails lookup with
12773    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
12774    /// authentication step (extract per `sources` → authenticate → deny) so the
12775    /// credential-extraction redaction contract is exercised on a real
12776    /// authentication failure.
12777    async fn spawn_failing_auth_route(
12778        registry: &HttpRouteRegistry,
12779        path: &str,
12780        sources: Vec<CredentialSource>,
12781    ) {
12782        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
12783            NativeCredentialStore::try_new(vec![]).unwrap(),
12784        ));
12785        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12786        registry.register_api_route(path.to_string(), tx).await;
12787        let path_owned = path.to_string();
12788        tokio::spawn(async move {
12789            while let Some(envelope) = rx.recv().await {
12790                let exchange = envelope_to_exchange(&envelope);
12791                let reply_tx = envelope.reply_tx;
12792                let result: Result<(), CamelError> = async {
12793                    let token = extract_token_from_exchange(&exchange, &sources)
12794                        .map(|extracted| extracted.token)
12795                        .ok_or_else(|| {
12796                            CamelError::Unauthenticated("no credential in any source".into())
12797                        })?;
12798                    authenticator.authenticate_bearer(&token).await?;
12799                    Ok(())
12800                }
12801                .await;
12802                let reply = match result {
12803                    Ok(()) => HttpReply {
12804                        status: 200,
12805                        headers: vec![],
12806                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
12807                    },
12808                    Err(e) => pipeline_error_to_reply(e, &path_owned),
12809                };
12810                let _ = reply_tx.send(reply);
12811            }
12812        });
12813    }
12814
12815    /// Whether any tracing record captured so far (process-wide) contains
12816    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
12817    /// shared buffer, so logs from spawned request-handling tasks are included.
12818    fn captured_logs_contain(needle: &str) -> bool {
12819        let buf = tracing_test::internal::global_buf().lock().unwrap();
12820        String::from_utf8_lossy(&buf).contains(needle)
12821    }
12822
12823    #[tracing_test::traced_test]
12824    #[tokio::test]
12825    async fn error_context_redacts_query_sentinel() {
12826        let (port, registry) = spawn_test_server().await;
12827        spawn_failing_auth_route(
12828            &registry,
12829            "/secure-query",
12830            vec![CredentialSource::QueryParam {
12831                param: "token".to_string(),
12832            }],
12833        )
12834        .await;
12835
12836        let client = reqwest::Client::new();
12837        let resp = client
12838            // allow-secret: `token` is the declared query-source param name, not a credential
12839            .get(format!(
12840                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
12841            ))
12842            .send()
12843            .await
12844            .unwrap();
12845
12846        assert_eq!(resp.status().as_u16(), 401);
12847        let body = resp.text().await.unwrap();
12848        assert_eq!(body, "Unauthorized");
12849        assert!(
12850            !body.contains(SENTINEL_QRY_42),
12851            "reply body must not contain the query credential"
12852        );
12853        assert!(
12854            !captured_logs_contain(SENTINEL_QRY_42),
12855            "no tracing record during request handling may render the query credential"
12856        );
12857        // Permanent positive control: the failed-auth warn! must be captured.
12858        // If the per-crate env filter ever stops matching, this fails loudly
12859        // instead of letting the sentinel assertions pass vacuously.
12860        assert!(
12861            captured_logs_contain("Authentication failed"),
12862            "positive control: the failed-auth warn! must be captured by the test subscriber"
12863        );
12864    }
12865
12866    #[tracing_test::traced_test]
12867    #[tokio::test]
12868    async fn error_context_redacts_cookie_sentinel() {
12869        let (port, registry) = spawn_test_server().await;
12870        spawn_failing_auth_route(
12871            &registry,
12872            "/secure-cookie",
12873            vec![CredentialSource::Cookie {
12874                name: "session".to_string(),
12875            }],
12876        )
12877        .await;
12878
12879        let client = reqwest::Client::new();
12880        let resp = client
12881            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
12882            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
12883            .send()
12884            .await
12885            .unwrap();
12886
12887        assert_eq!(resp.status().as_u16(), 401);
12888        let body = resp.text().await.unwrap();
12889        assert_eq!(body, "Unauthorized");
12890        assert!(
12891            !body.contains(SENTINEL_CKY_7),
12892            "reply body must not contain the cookie credential"
12893        );
12894        assert!(
12895            !captured_logs_contain(SENTINEL_CKY_7),
12896            "no tracing record during request handling may render the cookie credential"
12897        );
12898    }
12899
12900    #[tracing_test::traced_test]
12901    #[tokio::test]
12902    async fn error_reply_no_credential_value() {
12903        let (port, registry) = spawn_test_server().await;
12904        spawn_failing_auth_route(
12905            &registry,
12906            "/secure-bad",
12907            vec![CredentialSource::Cookie {
12908                name: "session".to_string(),
12909            }],
12910        )
12911        .await;
12912
12913        let client = reqwest::Client::new();
12914        let resp = client
12915            .get(format!("http://127.0.0.1:{port}/secure-bad"))
12916            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
12917            .send()
12918            .await
12919            .unwrap();
12920
12921        assert_eq!(resp.status().as_u16(), 401);
12922        let body = resp.text().await.unwrap();
12923        assert_eq!(body, "Unauthorized");
12924        assert!(
12925            !body.contains(SENTINEL_BAD_1),
12926            "reply body must not contain the credential value"
12927        );
12928        assert!(
12929            !captured_logs_contain(SENTINEL_BAD_1),
12930            "error logs must not render the credential value"
12931        );
12932    }
12933
12934    // -----------------------------------------------------------------------
12935    // Pinned-client-cache producer-path behavioral tests
12936    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
12937    // the endpoint cache, hostname requests build one client while the entry
12938    // stays retrievable, IP-literal requests bypass the cache)
12939    // -----------------------------------------------------------------------
12940
12941    /// Local responder that accepts any number of HTTP/1.1 connections on an
12942    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
12943    /// Unlike [`start_host_capturing_destination`], which serves exactly one
12944    /// connection, this loop keeps accepting so cache-reuse tests can drive
12945    /// several requests through one destination. Returns
12946    /// `(base_url, JoinHandle)`.
12947    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12948        use tokio::io::AsyncWriteExt;
12949
12950        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12951            .await
12952            .expect("bind ephemeral 127.0.0.1 listener");
12953        let port = listener.local_addr().expect("local addr").port();
12954        let base_url = format!("http://localhost:{port}");
12955        let handle = tokio::spawn(async move {
12956            while let Ok((mut conn, _)) = listener.accept().await {
12957                let _ = conn
12958                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12959                    .await;
12960                let _ = conn.shutdown().await;
12961            }
12962        });
12963        (base_url, handle)
12964    }
12965
12966    /// rc-0li3: local HTTPS responder — the TLS twin of
12967    /// [`spawn_multi_accept_200`]. Accepts any number of TLS connections on
12968    /// an ephemeral 127.0.0.1 port and answers each with a fixed 200. The
12969    /// certificate comes from `camel_component_api::test_support`
12970    /// (SANs: localhost, 127.0.0.1, ::1); clients run with
12971    /// `tls.insecure = true`.
12972    async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12973        use tokio::io::AsyncWriteExt;
12974
12975        let (_ca_pem, cert_pem, key_pem) =
12976            camel_component_api::test_support::tls::gen_server_cert();
12977        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
12978            .collect::<Result<_, _>>()
12979            .expect("parse server cert pem");
12980        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
12981            .expect("parse server key pem")
12982            .expect("server key present");
12983        // Explicit provider: the process default is ambiguous when multiple
12984        // crates pull rustls feature sets; the graph enables aws-lc-rs.
12985        let provider =
12986            std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
12987        let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
12988            .with_safe_default_protocol_versions()
12989            .expect("safe default protocol versions")
12990            .with_no_client_auth()
12991            .with_single_cert(certs, key)
12992            .expect("build rustls server config");
12993        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
12994
12995        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12996            .await
12997            .expect("bind ephemeral 127.0.0.1 listener");
12998        let port = listener.local_addr().expect("local addr").port();
12999        let base_url = format!("https://localhost:{port}");
13000        let handle = tokio::spawn(async move {
13001            while let Ok((conn, _)) = listener.accept().await {
13002                let acceptor = acceptor.clone();
13003                tokio::spawn(async move {
13004                    if let Ok(mut tls) = acceptor.accept(conn).await {
13005                        let _ = tls
13006                            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13007                            .await;
13008                        let _ = tls.shutdown().await;
13009                    }
13010                });
13011            }
13012        });
13013        (base_url, handle)
13014    }
13015
13016    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
13017    /// target a different authority (the 127.0.0.1 literal) on the same
13018    /// listener.
13019    fn responder_port(base_url: &str) -> u16 {
13020        url::Url::parse(base_url)
13021            .expect("responder base URL parses")
13022            .port()
13023            .expect("responder base URL carries an explicit port")
13024    }
13025
13026    /// Build an endpoint literal whose outbound config points at
13027    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
13028    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
13029    /// build counts stay observable across producers.
13030    fn endpoint_with_shared_cache(
13031        base_url: &str,
13032        pinned_cache: &Arc<PinnedClientCache>,
13033    ) -> HttpEndpoint {
13034        let uri = format!("{base_url}?allowInternal=true");
13035        HttpEndpoint {
13036            uri: uri.clone(),
13037            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
13038            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
13039            client: reqwest::Client::new(),
13040            pinned_cache: Arc::clone(pinned_cache),
13041            http_config: HttpConfig::default(),
13042        }
13043    }
13044
13045    #[tokio::test]
13046    async fn producers_share_endpoint_cache() {
13047        use tower::ServiceExt;
13048
13049        let (base_url, _handle) = spawn_multi_accept_200().await;
13050        let pinned_cache = Arc::new(PinnedClientCache::new(
13051            PINNED_CLIENT_TTL,
13052            PINNED_CLIENT_MAX_ENTRIES,
13053        ));
13054
13055        let ctx = test_producer_ctx();
13056        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13057        let producer_a = endpoint.create_producer(rt(), &ctx);
13058        let producer_b = endpoint.create_producer(rt(), &ctx);
13059
13060        // Each producer sends one exchange whose resolved URL is the
13061        // endpoint's localhost base URL (a domain name → pinned-client path).
13062        for producer in [producer_a, producer_b] {
13063            let producer = producer.expect("create producer");
13064            let exchange = Exchange::new(Message::default());
13065            let reply = producer.oneshot(exchange).await;
13066            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13067        }
13068
13069        assert_eq!(
13070            pinned_cache.build_count(),
13071            1,
13072            "both producers must hit the same shared cache entry; a second \
13073             build means sharing is broken"
13074        );
13075    }
13076
13077    #[tokio::test]
13078    async fn producer_repeated_hostname_requests_build_one_client() {
13079        use tower::ServiceExt;
13080
13081        let (base_url, _handle) = spawn_multi_accept_200().await;
13082        let pinned_cache = Arc::new(PinnedClientCache::new(
13083            PINNED_CLIENT_TTL,
13084            PINNED_CLIENT_MAX_ENTRIES,
13085        ));
13086        let ctx = test_producer_ctx();
13087        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13088        let producer = endpoint
13089            .create_producer(rt(), &ctx)
13090            .expect("create producer");
13091
13092        // Two sequential hostname requests — the cached pinned client stays
13093        // retrievable between them, so no second build may happen.
13094        for i in 0..2 {
13095            let exchange = Exchange::new(Message::default());
13096            let reply = producer.clone().oneshot(exchange).await;
13097            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13098        }
13099
13100        assert_eq!(
13101            pinned_cache.build_count(),
13102            1,
13103            "repeated hostname requests must reuse the one pinned client; \
13104             0 builds means the producer bypassed the cache, more than 1 \
13105             means the entry was dropped"
13106        );
13107    }
13108
13109    #[tokio::test]
13110    async fn ip_literal_request_never_enters_cache() {
13111        use tower::ServiceExt;
13112
13113        let (base_url, _handle) = spawn_multi_accept_200().await;
13114        let pinned_cache = Arc::new(PinnedClientCache::new(
13115            PINNED_CLIENT_TTL,
13116            PINNED_CLIENT_MAX_ENTRIES,
13117        ));
13118
13119        let ctx = test_producer_ctx();
13120        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
13121        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
13122        let producer = endpoint
13123            .create_producer(rt(), &ctx)
13124            .expect("create producer");
13125
13126        let exchange = Exchange::new(Message::default());
13127        let reply = producer.oneshot(exchange).await;
13128        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13129
13130        assert_eq!(
13131            pinned_cache.build_count(),
13132            0,
13133            "an IP-literal URL must use the shared unpinned client and \
13134             never enter the pinned cache"
13135        );
13136    }
13137
13138    #[tokio::test]
13139    async fn test_component_endpoints_share_pinned_cache() {
13140        use tower::ServiceExt;
13141
13142        let component = HttpComponent::new();
13143        let (base_url, _handle) = spawn_multi_accept_200().await;
13144        let baseline = component.pinned_cache.build_count();
13145
13146        let ctx = test_producer_ctx();
13147        let endpoint_ctx = NoOpComponentContext;
13148        for uri in [
13149            format!("{base_url}/a?allowInternal=true&k=a"),
13150            format!("{base_url}/b?allowInternal=true&k=b"),
13151        ] {
13152            let endpoint = component
13153                .create_endpoint(&uri, &endpoint_ctx)
13154                .expect("create endpoint");
13155            let producer = endpoint
13156                .create_producer(rt(), &ctx)
13157                .expect("create producer");
13158            let exchange = Exchange::new(Message::default());
13159            let reply = producer.oneshot(exchange).await;
13160            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13161        }
13162
13163        assert_eq!(
13164            component.pinned_cache.build_count() - baseline,
13165            1,
13166            "endpoints created by one component must share its pinned cache; \
13167             0 builds means the endpoints bypassed it, more than 1 means \
13168             per-endpoint caches came back"
13169        );
13170    }
13171
13172    #[tokio::test]
13173    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
13174        use tower::ServiceExt;
13175
13176        let component = HttpComponent::new();
13177        let (base_url, _handle) = spawn_multi_accept_200().await;
13178        let baseline = component.pinned_cache.build_count();
13179
13180        let ctx = test_producer_ctx();
13181        let endpoint_ctx = NoOpComponentContext;
13182        for i in 0..3 {
13183            let endpoint = component
13184                .create_endpoint(
13185                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
13186                    &endpoint_ctx,
13187                )
13188                .expect("create endpoint");
13189            let producer = endpoint
13190                .create_producer(rt(), &ctx)
13191                .expect("create producer");
13192            let exchange = Exchange::new(Message::default());
13193            let reply = producer.oneshot(exchange).await;
13194            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13195        }
13196
13197        assert_eq!(
13198            component.pinned_cache.build_count() - baseline,
13199            1,
13200            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13201             must reuse the component's one pinned cache entry; 0 builds \
13202             means the endpoints bypassed it, more than 1 means \
13203             per-endpoint caches came back"
13204        );
13205    }
13206
13207    /// rc-0li3: BEHAVIORAL https sharing pin — two https endpoints created
13208    /// through one `HttpsComponent` drive real TLS requests through the
13209    /// component's single pinned cache. A regression that reintroduces
13210    /// per-endpoint `PinnedClientCache::new` inside
13211    /// `HttpsComponent::create_endpoint` leaves the component cache at
13212    /// delta 0 and fails this test (the structural ptr_eq test cannot see
13213    /// that).
13214    #[tokio::test]
13215    async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
13216        use tower::ServiceExt;
13217
13218        let http_config = HttpConfig {
13219            tls: Some(crate::config::TlsConfig {
13220                enabled: true,
13221                insecure: true,
13222                ..Default::default()
13223            }),
13224            ..Default::default()
13225        };
13226        let component = HttpsComponent::with_config(http_config);
13227        let (base_url, _handle) = spawn_tls_multi_accept_200().await;
13228        let baseline = component.pinned_cache.build_count();
13229
13230        let ctx = test_producer_ctx();
13231        let endpoint_ctx = NoOpComponentContext;
13232        for uri in [
13233            format!("{base_url}/a?allowInternal=true&k=a"),
13234            format!("{base_url}/b?allowInternal=true&k=b"),
13235        ] {
13236            let endpoint = component
13237                .create_endpoint(&uri, &endpoint_ctx)
13238                .expect("create https endpoint");
13239            let producer = endpoint
13240                .create_producer(rt(), &ctx)
13241                .expect("create producer");
13242            let exchange = Exchange::new(Message::default());
13243            let reply = producer.oneshot(exchange).await;
13244            assert!(reply.is_ok(), "https request failed: {reply:?}");
13245        }
13246
13247        assert_eq!(
13248            component.pinned_cache.build_count() - baseline,
13249            1,
13250            "endpoints of one HttpsComponent must share its pinned cache over \
13251             real https requests; 0 builds means the endpoints bypassed it \
13252             (per-endpoint cache regression), more than 1 means \
13253             per-endpoint caches came back"
13254        );
13255    }
13256
13257    #[test]
13258    fn test_https_component_owns_distinct_cache() {
13259        let http = HttpComponent::new();
13260        let https = HttpsComponent::new();
13261
13262        assert!(
13263            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
13264            "http and https components must each own their own pinned cache"
13265        );
13266
13267        let endpoint_ctx = NoOpComponentContext;
13268        let _ = http
13269            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
13270            .expect("http endpoint");
13271        let _ = https
13272            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
13273            .expect("https endpoint");
13274
13275        assert_eq!(
13276            http.pinned_cache.build_count(),
13277            0,
13278            "endpoint creation must not build a pinned client"
13279        );
13280        assert_eq!(
13281            https.pinned_cache.build_count(),
13282            0,
13283            "endpoint creation must not build a pinned client"
13284        );
13285    }
13286
13287    #[test]
13288    fn test_component_constructor_builds_one_unpinned_client() {
13289        let baseline = build_client_call_count();
13290
13291        let _http = HttpComponent::new();
13292        assert_eq!(
13293            build_client_call_count() - baseline,
13294            1,
13295            "HttpComponent::new() must build exactly one shared unpinned client"
13296        );
13297
13298        let _https = HttpsComponent::new();
13299        assert_eq!(
13300            build_client_call_count() - baseline,
13301            2,
13302            "HttpsComponent::new() must build exactly one more shared unpinned client"
13303        );
13304    }
13305
13306    #[test]
13307    fn test_component_endpoints_share_unpinned_client() {
13308        let component = HttpComponent::new();
13309        let baseline = build_client_call_count();
13310
13311        let endpoint_ctx = NoOpComponentContext;
13312        for uri in [
13313            "http://localhost:1/a?allowInternal=true",
13314            "http://localhost:1/b?allowInternal=true",
13315        ] {
13316            let _endpoint = component
13317                .create_endpoint(uri, &endpoint_ctx)
13318                .expect("create endpoint");
13319        }
13320
13321        assert_eq!(
13322            build_client_call_count() - baseline,
13323            0,
13324            "create_endpoint must clone the component's shared unpinned client, \
13325             never build a fresh one"
13326        );
13327    }
13328
13329    #[test]
13330    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
13331        let component = HttpComponent::new();
13332        let baseline = build_client_call_count();
13333
13334        let ctx = test_producer_ctx();
13335        let endpoint_ctx = NoOpComponentContext;
13336        for i in 0..3 {
13337            let endpoint = component
13338                .create_endpoint(
13339                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
13340                    &endpoint_ctx,
13341                )
13342                .expect("create endpoint");
13343            let _producer = endpoint
13344                .create_producer(rt(), &ctx)
13345                .expect("create producer");
13346        }
13347
13348        assert_eq!(
13349            build_client_call_count() - baseline,
13350            0,
13351            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13352             must reuse the component's shared unpinned client and build \
13353             no additional clients"
13354        );
13355    }
13356}