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    pub blocked_hosts: Vec<String>,
128    pub max_body_size: usize,
129    pub read_timeout_ms: u64,
130    pub max_response_bytes: usize,
131    pub auth: HttpAuth,
132    pub token_provider: Option<Arc<dyn TokenProvider>>,
133    pub user_agent: Option<String>,
134    pub bridge_endpoint: bool,
135    pub connection_close: bool,
136    pub skip_request_headers: Vec<String>,
137    pub skip_response_headers: Vec<String>,
138    pub follow_redirects: bool,
139    pub max_redirects: usize,
140    /// CamelHttpUri host fence (`allowedUriHosts`): `None` when the option
141    /// is absent (override behavior unchanged); `Some` arms the fail-closed
142    /// fence. Parsed entries only — never re-serialized into the outbound
143    /// query.
144    pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
145}
146
147/// ADR-0051 redact-by-construction: query bytes (authored `raw_query` and
148/// programmatic `query_params`) may carry credentials. The display-surface
149/// Debug renders the raw view blanket-masked (mirroring
150/// `redact_url_for_diagnostics`) and programmatic values masked, mirroring
151/// `UriComponents`' sensitive-value masking. Wire fidelity is unaffected.
152impl std::fmt::Debug for HttpEndpointConfig {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("HttpEndpointConfig")
155            .field("base_url", &mask_base_url_userinfo(&self.base_url))
156            .field("http_method", &self.http_method)
157            .field(
158                "throw_exception_on_failure",
159                &self.throw_exception_on_failure,
160            )
161            .field("ok_status_code_range", &self.ok_status_code_range)
162            .field("response_timeout", &self.response_timeout)
163            .field(
164                "query_params",
165                &self
166                    .query_params
167                    .iter()
168                    .map(|(key, _)| (key, "***"))
169                    .collect::<Vec<_>>(),
170            )
171            .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
172            .field("allow_internal", &self.allow_internal)
173            .field("blocked_hosts", &self.blocked_hosts)
174            .field("max_body_size", &self.max_body_size)
175            .field("read_timeout_ms", &self.read_timeout_ms)
176            .field("max_response_bytes", &self.max_response_bytes)
177            .field("auth", &self.auth)
178            .field("token_provider", &self.token_provider)
179            .field("user_agent", &self.user_agent)
180            .field("bridge_endpoint", &self.bridge_endpoint)
181            .field("connection_close", &self.connection_close)
182            .field("skip_request_headers", &self.skip_request_headers)
183            .field("skip_response_headers", &self.skip_response_headers)
184            .field("follow_redirects", &self.follow_redirects)
185            .field("max_redirects", &self.max_redirects)
186            .field("allowed_uri_hosts", &self.allowed_uri_hosts)
187            .finish()
188    }
189}
190
191#[derive(Clone, PartialEq)]
192pub enum HttpAuth {
193    None,
194    Basic { username: String, password: String },
195    Bearer { token: String },
196}
197
198impl std::fmt::Debug for HttpAuth {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        match self {
201            HttpAuth::None => f.write_str("None"),
202            HttpAuth::Basic { username, .. } => f
203                .debug_struct("Basic")
204                .field("username", username)
205                .field("password", &"***")
206                .finish(),
207            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
208        }
209    }
210}
211
212/// Whether `key` names a camel-http endpoint option consumed at parse time.
213///
214/// Single metadata-driven owner of OUTBOUND option filtering (ADR-0041):
215/// derived from the `#[uri_param]` metadata behind
216/// [`HttpEndpointConfig::uri_options`], so the raw query filter consumes
217/// exactly the keys the component documents — no duplicated handwritten
218/// key lists. `from_components`'s manual typed parsing stays direct and
219/// unchanged; this predicate never re-wires it.
220fn is_consumed_option(key: &str) -> bool {
221    HttpEndpointConfig::uri_options()
222        .iter()
223        .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
224}
225
226impl UriConfig for HttpEndpointConfig {
227    /// Returns "http" as the primary scheme (also accepts "https")
228    fn scheme() -> &'static str {
229        "http"
230    }
231
232    fn from_uri(uri: &str) -> Result<Self, CamelError> {
233        let parts = parse_uri(uri)?;
234        Self::from_components(parts)
235    }
236
237    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
238        // Validate scheme - accept both http and https
239        if parts.scheme != "http" && parts.scheme != "https" {
240            return Err(CamelError::InvalidUri(format!(
241                "expected scheme 'http' or 'https', got '{}'",
242                parts.scheme
243            )));
244        }
245
246        // Construct base_url from scheme + path
247        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
248        let base_url = format!("{}:{}", parts.scheme, parts.path);
249
250        let http_method = parts.params.get("httpMethod").cloned();
251
252        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
253            Some(v) => parse_bool_param_http(v).map_err(|e| {
254                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
255            })?,
256            None => true,
257        };
258
259        // Parse status code range from "start-end" format (e.g., "200-299")
260        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
261            Some(v) => parse_ok_status_code_range(v)?,
262            None => (200, 299),
263        };
264
265        let response_timeout = match parts.params.get("responseTimeout") {
266            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
267                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
268            })?),
269            None => None,
270        };
271
272        // SSRF protection settings
273        let allow_internal = match parts.params.get("allowInternal") {
274            Some(v) => parse_bool_param_http(v).map_err(|e| {
275                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
276            })?,
277            None => false, // Default: block private IPs
278        };
279
280        // Parse comma-separated blocked hosts
281        let blocked_hosts = parts
282            .params
283            .get("blockedHosts")
284            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
285            .unwrap_or_default();
286
287        let max_body_size = match parts.params.get("maxBodySize") {
288            Some(v) => v.parse::<usize>().map_err(|e| {
289                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
290            })?,
291            None => 10 * 1024 * 1024, // Default: 10MB
292        };
293
294        let read_timeout_ms = match parts.params.get("readTimeout") {
295            Some(v) => v.parse::<u64>().map_err(|e| {
296                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
297            })?,
298            None => 30_000, // Default: 30s
299        };
300
301        let max_response_bytes = match parts.params.get("maxResponseBytes") {
302            Some(v) => v.parse::<usize>().map_err(|e| {
303                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
304            })?,
305            None => 10 * 1024 * 1024, // Default: 10MB
306        };
307
308        let auth = parse_auth_from_params(&parts.params)?;
309
310        let user_agent = parts.params.get("userAgent").cloned();
311
312        if parts.params.contains_key("cookieHandling") {
313            return Err(CamelError::InvalidUri(
314                "cookieHandling is not supported".into(),
315            ));
316        }
317
318        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
319            Some(v) => parse_bool_param_http(v).map_err(|e| {
320                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
321            })?,
322            None => false,
323        };
324
325        let connection_close = match parts.params.get("connectionClose") {
326            Some(v) => parse_bool_param_http(v).map_err(|e| {
327                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
328            })?,
329            None => false,
330        };
331
332        let skip_request_headers = parts
333            .params
334            .get("skipRequestHeaders")
335            .map(|v| {
336                v.split(',')
337                    .map(str::trim)
338                    .filter(|s| !s.is_empty())
339                    .map(|s| s.to_ascii_lowercase())
340                    .collect::<Vec<_>>()
341            })
342            .unwrap_or_default();
343
344        let skip_response_headers = parts
345            .params
346            .get("skipResponseHeaders")
347            .map(|v| {
348                v.split(',')
349                    .map(str::trim)
350                    .filter(|s| !s.is_empty())
351                    .map(|s| s.to_ascii_lowercase())
352                    .collect::<Vec<_>>()
353            })
354            .unwrap_or_default();
355
356        let follow_redirects = match parts.params.get("followRedirects") {
357            Some(v) => parse_bool_param_http(v).map_err(|e| {
358                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
359            })?,
360            None => false,
361        };
362
363        let max_redirects = match parts.params.get("maxRedirects") {
364            Some(v) => v.parse::<usize>().map_err(|e| {
365                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
366            })?,
367            None => 10,
368        };
369
370        // CamelHttpUri host fence: parsed eagerly so a malformed or empty
371        // allowlist fails endpoint creation (fail-closed), not resolution.
372        let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
373            Some(v) => Some(parse_allowed_uri_hosts(v)?),
374            None => None,
375        };
376
377        // Authored pairs ride raw_query verbatim (the sole carrier);
378        // query_params is programmatic-only — never auto-populated from
379        // URI leftovers. Consumed option keys are filtered at
380        // serialization time by `is_consumed_option`.
381        let raw_query = parts.raw_query.clone();
382
383        Ok(Self {
384            base_url,
385            http_method,
386            throw_exception_on_failure,
387            ok_status_code_range,
388            response_timeout,
389            query_params: Vec::new(),
390            raw_query,
391            allow_internal,
392            blocked_hosts,
393            max_body_size,
394            read_timeout_ms,
395            max_response_bytes,
396            auth,
397            token_provider: None,
398            user_agent,
399            bridge_endpoint,
400            connection_close,
401            skip_request_headers,
402            skip_response_headers,
403            follow_redirects,
404            max_redirects,
405            allowed_uri_hosts,
406        })
407    }
408}
409
410/// Private container for macro-derived `uri_options()` and `metadata()`.
411///
412/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
413/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
414/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
415/// derivation targets this inner type whose fields are all URI-param-compatible.
416#[derive(Debug, Clone, UriConfig)]
417#[allow(dead_code)]
418#[uri_scheme = "http"]
419#[uri_config(
420    skip_impl,
421    metadata(
422        scheme = "http",
423        description = "HTTP client and server component",
424        producer,
425        consumer,
426        streaming
427    ),
428    crate = "camel_component_api"
429)]
430struct HttpEndpointUriConfig {
431    #[allow(dead_code)]
432    _base_url: String,
433
434    #[uri_param(
435        name = "httpMethod",
436        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
437    )]
438    http_method: Option<String>,
439
440    #[uri_param(
441        name = "throwExceptionOnFailure",
442        default = "true",
443        desc = "Throw on non-2xx status"
444    )]
445    throw_exception_on_failure: bool,
446
447    #[uri_param(
448        name = "okStatusCodeRange",
449        default = "200-299",
450        desc = "Success status code range"
451    )]
452    ok_status_code_range: String,
453
454    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
455    response_timeout: Option<u64>,
456
457    #[uri_param(
458        name = "connectTimeout",
459        desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
460    )]
461    connect_timeout: Option<u64>,
462
463    #[uri_param(
464        name = "allowInternal",
465        default = "false",
466        desc = "Allow private/internal network destinations (SSRF)"
467    )]
468    allow_internal: bool,
469
470    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
471    blocked_hosts: Option<String>,
472
473    #[uri_param(
474        name = "maxBodySize",
475        default = "10485760",
476        desc = "Max request/response body bytes"
477    )]
478    max_body_size: u64,
479
480    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
481    read_timeout: Option<u64>,
482
483    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
484    max_response_bytes: Option<u64>,
485
486    #[uri_param(
487        name = "authMethod",
488        kind = "enum:Basic,Bearer",
489        desc = "Authentication method"
490    )]
491    auth_method: Option<String>,
492
493    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
494    auth_username: Option<String>,
495
496    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
497    auth_password: Option<String>,
498
499    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
500    auth_bearer_token: Option<String>,
501
502    #[uri_param(name = "userAgent", desc = "User-Agent header")]
503    user_agent: Option<String>,
504
505    #[uri_param(
506        name = "bridgeEndpoint",
507        default = "false",
508        desc = "Bridge endpoint mode"
509    )]
510    bridge_endpoint: bool,
511
512    #[uri_param(
513        name = "connectionClose",
514        default = "false",
515        desc = "Send Connection: close"
516    )]
517    connection_close: bool,
518
519    #[uri_param(
520        name = "skipRequestHeaders",
521        desc = "Comma-separated request headers to skip"
522    )]
523    skip_request_headers: Option<String>,
524
525    #[uri_param(
526        name = "skipResponseHeaders",
527        desc = "Comma-separated response headers to skip"
528    )]
529    skip_response_headers: Option<String>,
530
531    #[uri_param(
532        name = "followRedirects",
533        default = "false",
534        desc = "Follow HTTP redirects"
535    )]
536    follow_redirects: bool,
537
538    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
539    max_redirects: u64,
540
541    #[uri_param(
542        name = "allowedUriHosts",
543        desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
544    )]
545    allowed_uri_hosts: Option<String>,
546}
547
548impl HttpEndpointConfig {
549    /// Component metadata for the http/https scheme, derived from the
550    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
551    pub fn metadata() -> ComponentMetadata {
552        HttpEndpointUriConfig::metadata()
553    }
554
555    /// URI option definitions, derived from `#[uri_param]` fields.
556    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
557        HttpEndpointUriConfig::uri_options()
558    }
559}
560
561fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
562    let Some(method) = params.get("authMethod") else {
563        return Ok(HttpAuth::None);
564    };
565
566    if method.eq_ignore_ascii_case("none") {
567        return Ok(HttpAuth::None);
568    }
569
570    if method.eq_ignore_ascii_case("basic") {
571        let username = params.get("authUsername").cloned().ok_or_else(|| {
572            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
573        })?;
574        let password = params.get("authPassword").cloned().ok_or_else(|| {
575            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
576        })?;
577        return Ok(HttpAuth::Basic { username, password });
578    }
579
580    if method.eq_ignore_ascii_case("bearer") {
581        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
582            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
583        })?;
584        return Ok(HttpAuth::Bearer { token });
585    }
586
587    Err(CamelError::InvalidUri(format!(
588        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
589    )))
590}
591
592fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
593    match value.to_ascii_lowercase().as_str() {
594        "true" | "1" | "yes" => Ok(true),
595        "false" | "0" | "no" => Ok(false),
596        _ => Err(CamelError::InvalidUri(format!(
597            "invalid boolean value: '{value}'"
598        ))),
599    }
600}
601
602impl HttpEndpointConfig {
603    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
604        let parts = parse_uri(uri)?;
605        let mut endpoint = Self::from_components(parts.clone())?;
606        if endpoint.response_timeout.is_none() {
607            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
608        }
609        if !parts.params.contains_key("allowInternal") {
610            endpoint.allow_internal = config.allow_internal;
611        }
612        if !parts.params.contains_key("blockedHosts") {
613            endpoint.blocked_hosts = config.blocked_hosts.clone();
614        }
615        if !parts.params.contains_key("maxBodySize") {
616            endpoint.max_body_size = config.max_body_size;
617        }
618        if !parts.params.contains_key("readTimeout") {
619            endpoint.read_timeout_ms = config.read_timeout_ms;
620        }
621        if !parts.params.contains_key("maxResponseBytes") {
622            endpoint.max_response_bytes = config.max_response_bytes;
623        }
624        if !parts.params.contains_key("okStatusCodeRange")
625            && let Some(range) = &config.ok_status_code_range
626        {
627            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
628        }
629        if !parts.params.contains_key("followRedirects") {
630            endpoint.follow_redirects = config.follow_redirects;
631        }
632        if !parts.params.contains_key("maxRedirects") {
633            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
634        }
635
636        Ok(endpoint)
637    }
638}
639
640// ---------------------------------------------------------------------------
641// HttpServerConfig
642// ---------------------------------------------------------------------------
643
644/// Configuration for an HTTP server (consumer) endpoint.
645#[derive(Debug, Clone)]
646pub struct HttpServerConfig {
647    /// URI scheme ("http" or "https") parsed from the endpoint URI.
648    pub scheme: String,
649    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
650    pub host: String,
651    /// TCP port to listen on.
652    pub port: u16,
653    /// URL path this consumer handles, e.g. "/orders".
654    pub path: String,
655    /// Maximum request body size in bytes.
656    pub max_request_body: usize,
657    /// Maximum response body size for materializing streams in bytes.
658    pub max_response_body: usize,
659    /// Maximum number of in-flight requests handled concurrently by this server.
660    pub max_inflight_requests: usize,
661    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
662    /// the consumer registers as a method-aware REST endpoint and the
663    /// path is treated as a template (e.g. `/users/{id}` is matched
664    /// against any `/users/<value>`). When `None`, the consumer
665    /// registers in the legacy path-only `api_routes` registry.
666    /// Extracted from the `httpMethod=` URI param at config build time.
667    pub method: Option<String>,
668    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
669    /// `None` for plain HTTP servers.
670    pub tls_config: Option<crate::config::ServerTlsConfig>,
671}
672
673impl UriConfig for HttpServerConfig {
674    /// Returns "http" as the primary scheme (also accepts "https")
675    fn scheme() -> &'static str {
676        "http"
677    }
678
679    fn from_uri(uri: &str) -> Result<Self, CamelError> {
680        let parts = parse_uri(uri)?;
681        Self::from_components(parts)
682    }
683
684    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
685        // Validate scheme - accept both http and https
686        if parts.scheme != "http" && parts.scheme != "https" {
687            return Err(CamelError::InvalidUri(format!(
688                "expected scheme 'http' or 'https', got '{}'",
689                parts.scheme
690            )));
691        }
692
693        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
694        // Strip leading "//"
695        let authority_and_path = parts.path.trim_start_matches('/');
696
697        // Split on the first "/" to separate "host:port" from "/path"
698        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
699            (&authority_and_path[..idx], &authority_and_path[idx..])
700        } else {
701            (authority_and_path, "/")
702        };
703
704        let path = if path_suffix.is_empty() {
705            "/"
706        } else {
707            path_suffix
708        }
709        .to_string();
710
711        // Parse host:port from authority
712        let (host, port) = if let Some(colon) = authority.rfind(':') {
713            let port_str = &authority[colon + 1..];
714            match port_str.parse::<u16>() {
715                Ok(p) => (authority[..colon].to_string(), p),
716                Err(_) => {
717                    return Err(CamelError::InvalidUri(format!(
718                        "invalid port '{}' in authority",
719                        port_str
720                    )));
721                }
722            }
723        } else {
724            // Default port based on scheme: 443 for https, 80 for http
725            let default_port = if parts.scheme == "https" { 443 } else { 80 };
726            (authority.to_string(), default_port)
727        };
728
729        let max_request_body = parts
730            .params
731            .get("maxRequestBody")
732            .and_then(|v| v.parse::<usize>().ok())
733            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
734
735        let max_response_body = parts
736            .params
737            .get("maxResponseBody")
738            .and_then(|v| v.parse::<usize>().ok())
739            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
740
741        let max_inflight_requests = parts
742            .params
743            .get("maxInflightRequests")
744            .and_then(|v| v.parse::<usize>().ok())
745            .unwrap_or(1024);
746
747        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
748        // uppercase method the dispatcher compares against (axum's
749        // `req.method().to_string()` yields "GET"). Without this, a
750        // lower-case `httpMethod` would never match and silently 404.
751        // Review I5.
752        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
753
754        Ok(Self {
755            scheme: parts.scheme,
756            host,
757            port,
758            path,
759            max_request_body,
760            max_response_body,
761            max_inflight_requests,
762            method,
763            tls_config: {
764                let cert = parts.params.get("tlsCert").cloned();
765                let key = parts.params.get("tlsKey").cloned();
766                match (cert, key) {
767                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
768                        cert_path: c,
769                        key_path: k,
770                    }),
771                    (None, None) => None,
772                    _ => None, // partial — enforced in create_consumer, not here
773                }
774            },
775        })
776    }
777}
778
779impl HttpServerConfig {
780    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
781        let parts = parse_uri(uri)?;
782        let mut server = Self::from_components(parts.clone())?;
783        if !parts.params.contains_key("maxRequestBody") {
784            server.max_request_body = config.max_request_body;
785        }
786        if !parts.params.contains_key("maxResponseBody") {
787            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
788            server.max_response_body = config.max_body_size;
789        }
790        Ok(server)
791    }
792}
793
794// ---------------------------------------------------------------------------
795// RequestEnvelope / HttpReply
796// ---------------------------------------------------------------------------
797
798/// Body of the HTTP response: already-materialized bytes or a lazy stream.
799///
800/// **Internal plumbing** — subject to change without notice.
801pub enum HttpReplyBody {
802    Bytes(bytes::Bytes),
803    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
804}
805
806/// An inbound HTTP request sent from the Axum dispatch handler to an
807/// `HttpConsumer` receive loop.
808///
809/// **Internal plumbing** — subject to change without notice.
810pub struct RequestEnvelope {
811    pub method: String,
812    pub path: String,
813    pub query: String,
814    pub headers: http::HeaderMap,
815    pub body: StreamBody,
816    /// Path parameters extracted from a REST template match, e.g.
817    /// `id=42` for a request to `/users/42` matched against
818    /// `/users/{id}`. Empty for non-REST requests or for literal
819    /// template matches. The consumer turns these into
820    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
821    pub path_params: std::collections::HashMap<String, String>,
822    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
823}
824
825/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
826///
827/// **Internal plumbing** — subject to change without notice.
828pub struct HttpReply {
829    pub status: u16,
830    pub headers: Vec<(String, String)>,
831    pub body: HttpReplyBody,
832}
833
834// ---------------------------------------------------------------------------
835// HttpRouteRegistry / ServerRegistry
836// ---------------------------------------------------------------------------
837
838type ServerKey = (String, u16);
839
840/// Handle to a running Axum server on one interface/port.
841struct ServerHandle {
842    registry: HttpRouteRegistry,
843    /// Actual local address of the served listening socket (differs from the
844    /// configured `host:port` when spawning from a staged/pre-bound listener).
845    bound_addr: std::net::SocketAddr,
846    max_request_body: usize,
847    max_response_body: usize,
848    max_inflight_requests: usize,
849    is_tls: bool,
850    tls_cert_path: Option<String>,
851    tls_key_path: Option<String>,
852    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
853    /// dead-server eviction signal in `get_or_spawn`.
854    monitor_task: tokio::task::JoinHandle<()>,
855    // Retained so the reload handler (Task 7) can call reload_from_config()
856    // to hot-swap certs without restarting the server.
857    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
858    tls_source: Option<ServerTlsSource>,
859}
860
861/// Internal registry state: live server entries plus pre-bound listeners
862/// staged for consumption by the next spawn on the same key.
863#[derive(Default)]
864struct RegistryState {
865    entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
866    staged: HashMap<ServerKey, tokio::net::TcpListener>,
867}
868
869/// Process-global registry mapping (host, port) → running Axum server handle.
870pub struct ServerRegistry {
871    inner: Mutex<RegistryState>,
872}
873
874impl ServerRegistry {
875    /// Returns the global singleton.
876    pub fn global() -> &'static Self {
877        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
878        INSTANCE.get_or_init(|| ServerRegistry {
879            inner: Mutex::new(RegistryState::default()),
880        })
881    }
882
883    /// Returns route registry for `port`, spawning new Axum server if
884    /// none is running on that port yet.
885    #[allow(clippy::too_many_arguments)]
886    pub async fn get_or_spawn(
887        &'static self,
888        host: &str,
889        port: u16,
890        max_request_body: usize,
891        max_response_body: usize,
892        max_inflight_requests: usize,
893        runtime: Arc<dyn RuntimeObservability>,
894        route_id: String,
895        tls_config: Option<crate::config::ServerTlsConfig>,
896    ) -> Result<HttpRouteRegistry, CamelError> {
897        self.get_or_spawn_internal(
898            host,
899            port,
900            max_request_body,
901            max_response_body,
902            max_inflight_requests,
903            runtime,
904            route_id,
905            tls_config,
906            None,
907        )
908        .await
909    }
910
911    /// Like [`ServerRegistry::get_or_spawn`], but serves `listener` instead
912    /// of binding `host:port`. The registry key is derived from the listener's
913    /// actual local address, so callers must query that port afterwards. If an
914    /// entry for the key already holds a live server, the same compatibility
915    /// checks as `get_or_spawn` apply and the entry is reused; the passed
916    /// listener is simply dropped.
917    #[allow(clippy::too_many_arguments)]
918    pub async fn get_or_spawn_with_listener(
919        &'static self,
920        listener: tokio::net::TcpListener,
921        max_request_body: usize,
922        max_response_body: usize,
923        max_inflight_requests: usize,
924        runtime: Arc<dyn RuntimeObservability>,
925        route_id: String,
926        tls_config: Option<crate::config::ServerTlsConfig>,
927    ) -> Result<HttpRouteRegistry, CamelError> {
928        let addr = listener
929            .local_addr()
930            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
931        self.get_or_spawn_internal(
932            &addr.ip().to_string(),
933            addr.port(),
934            max_request_body,
935            max_response_body,
936            max_inflight_requests,
937            runtime,
938            route_id,
939            tls_config,
940            Some(listener),
941        )
942        .await
943    }
944
945    /// Stage a pre-bound listener so the next `get_or_spawn` for its
946    /// `(ip, port)` key serves this socket instead of binding a new one.
947    ///
948    /// The staged listener is consumed by exactly one spawn: the exact-key
949    /// `get_or_spawn` takes it under the registry lock, eliminating the bind
950    /// window between a port probe and server startup (itest-bound-ports).
951    pub async fn stage_listener(
952        &'static self,
953        listener: tokio::net::TcpListener,
954    ) -> Result<(), CamelError> {
955        let addr = listener
956            .local_addr()
957            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
958        let host = addr.ip().to_string();
959        use std::collections::hash_map::Entry;
960        let mut guard = self.inner.lock().map_err(|_| {
961            CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
962        })?;
963        match guard.staged.entry((host.clone(), addr.port())) {
964            Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
965                "listener already staged for {host}:{}",
966                addr.port()
967            ))),
968            Entry::Vacant(slot) => {
969                slot.insert(listener);
970                Ok(())
971            }
972        }
973    }
974
975    /// Returns the bound address of the live server entry for `(host, port)`,
976    /// if one is initialized.
977    pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
978        let guard = self.inner.lock().ok()?;
979        guard
980            .entries
981            .get(&(host.to_string(), port))
982            .and_then(|cell| cell.get())
983            .map(|handle| handle.bound_addr)
984    }
985
986    #[allow(clippy::too_many_arguments)]
987    async fn get_or_spawn_internal(
988        &'static self,
989        host: &str,
990        port: u16,
991        max_request_body: usize,
992        max_response_body: usize,
993        max_inflight_requests: usize,
994        runtime: Arc<dyn RuntimeObservability>,
995        route_id: String,
996        tls_config: Option<crate::config::ServerTlsConfig>,
997        provided: Option<tokio::net::TcpListener>,
998    ) -> Result<HttpRouteRegistry, CamelError> {
999        let host_owned = host.to_string();
1000        let key = (host.to_string(), port);
1001
1002        let cell = {
1003            let mut guard = self.inner.lock().map_err(|_| {
1004                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1005            })?;
1006            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
1007            // The monitor task awaits the server task, so monitor_task.is_finished()
1008            // is a reliable proxy for the server being gone (either crashed or aborted).
1009            if let Some(existing) = guard.entries.get(&key)
1010                && let Some(handle) = existing.get()
1011                && handle.monitor_task.is_finished()
1012            {
1013                // Deregister TLS reload handler so a respawned HTTPS server
1014                // doesn't reload stale cert config from the crashed handler.
1015                if handle.is_tls {
1016                    let scheme = if handle.is_tls { "https" } else { "http" };
1017                    camel_component_api::tls_source::TlsReloadRegistry::global()
1018                        .unregister(scheme, host, port);
1019                }
1020                guard.entries.remove(&key);
1021            }
1022            guard
1023                .entries
1024                .entry(key)
1025                .or_insert_with(|| Arc::new(OnceCell::new()))
1026                .clone()
1027        };
1028
1029        if let Some(existing) = cell.get()
1030            && existing.max_request_body != max_request_body
1031        {
1032            return Err(CamelError::EndpointCreationFailed(format!(
1033                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1034                existing.max_request_body, max_request_body
1035            )));
1036        }
1037
1038        if let Some(existing) = cell.get()
1039            && existing.max_response_body != max_response_body
1040        {
1041            return Err(CamelError::EndpointCreationFailed(format!(
1042                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1043                existing.max_response_body, max_response_body
1044            )));
1045        }
1046
1047        if let Some(existing) = cell.get()
1048            && existing.max_inflight_requests != max_inflight_requests
1049        {
1050            return Err(CamelError::EndpointCreationFailed(format!(
1051                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1052                existing.max_inflight_requests, max_inflight_requests
1053            )));
1054        }
1055
1056        // TLS mode mismatch: plain vs TLS
1057        if let Some(existing) = cell.get()
1058            && existing.is_tls != tls_config.is_some()
1059        {
1060            return Err(CamelError::EndpointCreationFailed(format!(
1061                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1062                existing.is_tls,
1063                tls_config.is_some()
1064            )));
1065        }
1066
1067        // TLS cert/key mismatch: different cert on same TLS port
1068        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1069            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1070                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1071        {
1072            return Err(CamelError::EndpointCreationFailed(format!(
1073                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1074            )));
1075        }
1076
1077        let handle = cell
1078            .get_or_try_init(|| {
1079                let rt = Arc::clone(&runtime);
1080                let rid = route_id.clone();
1081                let key = (host_owned.clone(), port);
1082                async move {
1083                    // Resolve the listener source inside the init body so
1084                    // exactly one caller — the init winner — consumes a
1085                    // staged listener. Resolving it before the cell init let
1086                    // a racing caller strand the staged socket in the
1087                    // loser's hands: the winner then bound the same port and
1088                    // failed with EADDRINUSE. The sync registry lock here is
1089                    // never held across an await. Occupied cells never run
1090                    // this body, so they never touch the staged map.
1091                    let source = match provided {
1092                        Some(listener) => ListenerSource::Staged(listener),
1093                        None => {
1094                            let mut guard = self.inner.lock().map_err(|_| {
1095                                CamelError::EndpointCreationFailed(
1096                                    "ServerRegistry lock poisoned".into(),
1097                                )
1098                            })?;
1099                            match guard.staged.remove(&key) {
1100                                Some(listener) => ListenerSource::Staged(listener),
1101                                // Conflict check before any entry is
1102                                // initialized so the error leaves the staged
1103                                // slot untouched.
1104                                None => {
1105                                    if let Some((staged_host, _)) = guard
1106                                        .staged
1107                                        .keys()
1108                                        .find(|(_, staged_port)| *staged_port == port)
1109                                    {
1110                                        let staged_host = staged_host.clone();
1111                                        return Err(CamelError::EndpointCreationFailed(
1112                                            format!(
1113                                                "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1114                                            ),
1115                                        ));
1116                                    }
1117                                    ListenerSource::Bind
1118                                }
1119                            }
1120                        }
1121                    };
1122                    spawn_entry(
1123                        key,
1124                        source,
1125                        max_request_body,
1126                        max_response_body,
1127                        max_inflight_requests,
1128                        rt,
1129                        rid,
1130                        tls_config,
1131                    )
1132                    .await
1133                    .and_then(|handle| {
1134                        // spawn_entry returns a freshly created Arc (refcount
1135                        // 1), so unwrapping it back into the owned handle for
1136                        // the cell always succeeds here.
1137                        Arc::try_unwrap(handle).map_err(|_| {
1138                            CamelError::EndpointCreationFailed(
1139                                "spawned server handle has dangling clones".into(),
1140                            )
1141                        })
1142                    })
1143                }
1144            })
1145            .await?;
1146
1147        Ok(handle.registry.clone())
1148    }
1149
1150    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
1151    /// the server stays in the registry for potential restart. Path
1152    /// deregistration happens separately in the consumer's cleanup.
1153    pub async fn unregister(&self, host: &str, port: u16) {
1154        debug!(
1155            host = host,
1156            port = port,
1157            "consumer unregistered from HTTP server"
1158        );
1159    }
1160
1161    /// Reset the global registry — **test-only**.
1162    ///
1163    /// Clears all registered server handles so that tests can start from a clean
1164    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1165    /// process-global singleton in production and resetting it would break
1166    /// running servers.
1167    #[cfg(test)]
1168    pub fn reset() {
1169        let instance = Self::global();
1170        let mut guard = instance
1171            .inner
1172            .lock()
1173            .expect("ServerRegistry lock poisoned during test reset");
1174        guard.entries.clear();
1175        guard.staged.clear();
1176    }
1177}
1178
1179/// Where a spawned server's listening socket comes from: a fresh bind on
1180/// `key`, or a listener pre-bound (staged or passed) by the caller.
1181enum ListenerSource {
1182    Bind,
1183    Staged(tokio::net::TcpListener),
1184}
1185
1186/// Create the server handle for a vacant registry entry: serve `key` via a
1187/// freshly bound or caller-provided listener. This is the OnceCell init body
1188/// of `get_or_spawn`, extracted so the legacy and staged entry points share
1189/// one spawn path.
1190#[allow(clippy::too_many_arguments)]
1191async fn spawn_entry(
1192    key: ServerKey,
1193    source: ListenerSource,
1194    max_request_body: usize,
1195    max_response_body: usize,
1196    max_inflight_requests: usize,
1197    runtime: Arc<dyn RuntimeObservability>,
1198    route_id: String,
1199    tls_config: Option<crate::config::ServerTlsConfig>,
1200) -> Result<Arc<ServerHandle>, CamelError> {
1201    let rt = Arc::clone(&runtime);
1202    let rid = route_id.clone();
1203    let (host_owned, port) = key;
1204    let listener = match source {
1205        ListenerSource::Bind => {
1206            let addr = format!("{host_owned}:{port}");
1207            tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1208                CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1209            })?
1210        }
1211        ListenerSource::Staged(listener) => listener,
1212    };
1213    let bound_addr = listener
1214        .local_addr()
1215        .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1216    let registry = HttpRouteRegistry::new();
1217    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1218    // Constructed once in the TLS branch so they can be retained
1219    // on ServerHandle for the reload handler (Task 7).
1220    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1221    let tls_source: Option<ServerTlsSource>;
1222    let server_task = if let Some(ref tls) = tls_config {
1223        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1224        let source = ServerTlsSource {
1225            cert_path: std::path::PathBuf::from(&tls.cert_path),
1226            key_path: std::path::PathBuf::from(&tls.key_path),
1227            client_ca_path: None,
1228        };
1229        // Build the RustlsConfig once — clone() is cheap (Arc
1230        // internally) and shares the ArcSwap the reload handler
1231        // will mutate via reload_from_config().
1232        let rustls_cfg =
1233            axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1234        tls_rustls_cfg = Some(rustls_cfg.clone());
1235        tls_source = Some(source);
1236        // Convert tokio listener to std for axum-server
1237        let std_listener = listener.into_std().map_err(|e| {
1238            CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1239        })?;
1240        tokio::spawn(run_axum_server_tls(
1241            std_listener,
1242            rustls_cfg,
1243            registry.clone(),
1244            max_request_body,
1245            max_response_body,
1246            Arc::clone(&inflight),
1247            Arc::clone(&rt),
1248            rid.clone(),
1249        ))
1250    } else {
1251        tls_rustls_cfg = None;
1252        tls_source = None;
1253        tokio::spawn(run_axum_server(
1254            listener,
1255            registry.clone(),
1256            max_request_body,
1257            max_response_body,
1258            Arc::clone(&inflight),
1259            Arc::clone(&rt),
1260            rid.clone(),
1261        ))
1262    };
1263    let addr_for_monitor = format!("{host_owned}:{port}");
1264    let monitor_task = tokio::spawn(monitor_axum_task(
1265        server_task,
1266        addr_for_monitor,
1267        Arc::clone(&rt),
1268        rid,
1269    ));
1270    let handle = ServerHandle {
1271        registry,
1272        bound_addr,
1273        max_request_body,
1274        max_response_body,
1275        max_inflight_requests,
1276        is_tls: tls_config.is_some(),
1277        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1278        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1279        monitor_task,
1280        tls_config: tls_rustls_cfg,
1281        tls_source,
1282    };
1283    // Register reload handler (exactly-once: inside OnceCell init closure).
1284    // Note: HTTP servers are process-lifetime (no release/eviction path),
1285    // so handlers are never unregistered. If eviction is added later,
1286    // add TlsReloadRegistry::global().unregister() there.
1287    if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1288    {
1289        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1290            tls_cfg.clone(),
1291            source.clone(),
1292            host_owned.clone(),
1293            port,
1294        ));
1295        camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1296    }
1297    Ok(Arc::new(handle))
1298}
1299
1300// ---------------------------------------------------------------------------
1301// Axum server
1302// ---------------------------------------------------------------------------
1303
1304use axum::{
1305    Router,
1306    body::Body as AxumBody,
1307    extract::{Request, State},
1308    http::{Response, StatusCode},
1309    response::IntoResponse,
1310};
1311
1312#[derive(Clone)]
1313pub(crate) struct AppState {
1314    registry: HttpRouteRegistry,
1315    max_request_body: usize,
1316    max_response_body: usize,
1317    inflight: Arc<tokio::sync::Semaphore>,
1318}
1319
1320/// Hard wall-clock limit for one inbound request on the consumer side
1321/// (audit 2026-08-31, F2-1). A slow-drip client otherwise holds an
1322/// `inflight` semaphore permit (and its connection) indefinitely, starving
1323/// the consumer into 503s. 30s matches the documented component default
1324/// timeouts. Applies to the whole dispatch; streaming bodies are additionally
1325/// protected by the byte cap in `dispatch_handler`.
1326const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1327
1328async fn run_axum_server(
1329    listener: tokio::net::TcpListener,
1330    registry: HttpRouteRegistry,
1331    max_request_body: usize,
1332    max_response_body: usize,
1333    inflight: Arc<tokio::sync::Semaphore>,
1334    runtime: Arc<dyn RuntimeObservability>,
1335    route_id: String,
1336) {
1337    let state = AppState {
1338        registry,
1339        max_request_body,
1340        max_response_body,
1341        inflight,
1342    };
1343    let app = Router::new()
1344        .fallback(dispatch_handler)
1345        .with_state(state)
1346        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1347            StatusCode::REQUEST_TIMEOUT,
1348            CONSUMER_REQUEST_TIMEOUT,
1349        ));
1350
1351    axum::serve(listener, app).await.unwrap_or_else(|e| {
1352        runtime
1353            .metrics()
1354            .increment_errors(&route_id, "e:http:accept");
1355        // log-policy: outside-contract
1356        tracing::error!(error = %e, "Axum server error");
1357    });
1358}
1359
1360#[allow(clippy::too_many_arguments)]
1361async fn run_axum_server_tls(
1362    listener: std::net::TcpListener,
1363    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1364    registry: HttpRouteRegistry,
1365    max_request_body: usize,
1366    max_response_body: usize,
1367    inflight: Arc<tokio::sync::Semaphore>,
1368    runtime: Arc<dyn RuntimeObservability>,
1369    route_id: String,
1370) {
1371    let state = AppState {
1372        registry,
1373        max_request_body,
1374        max_response_body,
1375        inflight,
1376    };
1377    let app = Router::new()
1378        .fallback(dispatch_handler)
1379        .with_state(state)
1380        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1381            StatusCode::REQUEST_TIMEOUT,
1382            CONSUMER_REQUEST_TIMEOUT,
1383        ));
1384
1385    // RustlsConfig is now constructed once in get_or_spawn and retained on
1386    // ServerHandle so the reload handler can call reload_from_config() on it.
1387
1388    // axum-server 0.8: from_tcp_rustls is fallible (TLS acceptor setup).
1389    let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1390        Ok(server) => server,
1391        Err(e) => {
1392            runtime
1393                .metrics()
1394                .increment_errors(&route_id, "e:http:accept-tls");
1395            // log-policy: outside-contract
1396            tracing::error!(error = %e, "Axum TLS server setup error");
1397            return;
1398        }
1399    };
1400
1401    server
1402        .serve(app.into_make_service())
1403        .await
1404        .unwrap_or_else(|e| {
1405            runtime
1406                .metrics()
1407                .increment_errors(&route_id, "e:http:accept-tls");
1408            // log-policy: outside-contract
1409            tracing::error!(error = %e, "Axum TLS server error");
1410        });
1411}
1412
1413/// Monitors an Axum server task and emits a structured error event if it
1414/// exits unexpectedly.
1415///
1416/// # Limitations
1417/// The HTTP server is shared across all routes on a port. Full per-route
1418/// CrashNotification propagation is deferred — this provides observable
1419/// structured logging as a first guard.
1420async fn monitor_axum_task(
1421    handle: tokio::task::JoinHandle<()>,
1422    addr: String,
1423    runtime: Arc<dyn RuntimeObservability>,
1424    route_id: String,
1425) {
1426    match handle.await {
1427        Ok(()) => {
1428            // Clean exit (process shutdown or normal stop)
1429        }
1430        Err(join_err) => {
1431            runtime
1432                .metrics()
1433                .increment_errors(&route_id, "e:http:server-task-exited");
1434            // log-policy: outside-contract
1435            tracing::error!(
1436                addr = %addr,
1437                error = %join_err,
1438                "Axum server task exited unexpectedly — all routes on this port are now dead"
1439            );
1440        }
1441    }
1442}
1443
1444/// Load a rustls ServerConfig from PEM cert/key files.
1445/// Adapted from camel-ws lib.rs load_tls_config.
1446fn load_tls_config(
1447    cert_path: &str,
1448    key_path: &str,
1449) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1450    use std::fs::File;
1451    use std::io::BufReader;
1452
1453    let cert_file = File::open(cert_path)
1454        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1455    let key_file = File::open(key_path)
1456        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1457
1458    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1459        .collect::<Result<Vec<_>, _>>()
1460        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1461
1462    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1463        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1464        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1465
1466    tokio_rustls::rustls::ServerConfig::builder()
1467        .with_no_client_auth()
1468        .with_single_cert(certs, key)
1469        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1470}
1471
1472async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1473    let path = req.uri().path().to_owned();
1474    let method = req.method().to_string();
1475
1476    // Dispatch precedence (spec §7.2 / ADR-0009):
1477    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1478    //   2. Templated API path match (REST, method-aware, by specificity)
1479    //   3. Static mount longest-prefix
1480    //   4. SPA fallback
1481    //
1482    // Legacy exact runs first: it is a cheap HashMap get, and the two
1483    // registries are mutually exclusive per route — a legacy route carries
1484    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1485    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1486    // exact hit can never shadow a REST route that should have matched,
1487    // and running exact-first honours the documented precedence (the prior
1488    // REST-first order let a templated `GET /api/{resource}` steal a
1489    // request meant for an exact `GET /api/users`). Intra-REST method
1490    // disambiguation is handled inside `match_endpoint`, not by this
1491    // ordering. Review C2.
1492    let api_sender = {
1493        let inner = state.registry.inner.read().await;
1494        inner.api_routes.get(&path).cloned()
1495    }; // lock released BEFORE any IO
1496
1497    let (rest_sender, path_params) = if api_sender.is_some() {
1498        // Exact legacy match won — skip the templated scan entirely.
1499        (None, Default::default())
1500    } else {
1501        let inner = state.registry.inner.read().await;
1502        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1503            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1504            rest_match::MatchOutcome::Ambiguous => {
1505                // Ambiguous registration should have been rejected at
1506                // lowering time (rest.rs). Reaching here means two
1507                // equal-specificity templates matched one request —
1508                // surface a loud error rather than a silent 404. Review C3.
1509                // log-policy: handler-owned
1510                tracing::warn!(
1511                    method = %method,
1512                    path = %path,
1513                    "ambiguous REST template match — returning 500"
1514                );
1515                return Response::builder()
1516                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1517                    .body(AxumBody::from("Internal Server Error"))
1518                    .expect("infallible"); // allow-unwrap
1519            }
1520            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1521        }
1522    }; // lock released BEFORE any IO
1523
1524    let sender = api_sender.or(rest_sender);
1525
1526    if let Some(sender) = sender {
1527        let query = req.uri().query().unwrap_or("").to_string();
1528        let headers = req.headers().clone();
1529
1530        // Check Content-Length against limit BEFORE opening the stream
1531        let content_length: Option<u64> = headers
1532            .get(http::header::CONTENT_LENGTH)
1533            .and_then(|v| v.to_str().ok())
1534            .and_then(|s| s.parse().ok());
1535
1536        if let Some(len) = content_length
1537            && len > state.max_request_body as u64
1538        {
1539            return Response::builder()
1540                .status(StatusCode::PAYLOAD_TOO_LARGE)
1541                .body(AxumBody::from("Request body exceeds configured limit"))
1542                .expect("infallible"); // allow-unwrap
1543        }
1544
1545        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1546            Ok(permit) => permit,
1547            Err(_) => {
1548                return Response::builder()
1549                    .status(StatusCode::SERVICE_UNAVAILABLE)
1550                    .body(AxumBody::from("Service Unavailable"))
1551                    .expect("infallible"); // allow-unwrap
1552            }
1553        };
1554
1555        // Build StreamBody from Axum body WITHOUT materializing.
1556        // SECURITY (audit 2026-08-31, F2-1): the Content-Length pre-check above
1557        // cannot see chunked/no-length requests. Wrap the stream with a hard
1558        // byte cap so ANY downstream consumption fails closed once
1559        // max_request_body is exceeded — the cap travels with the body.
1560        let content_type = headers
1561            .get(http::header::CONTENT_TYPE)
1562            .and_then(|v| v.to_str().ok())
1563            .map(|s| s.to_string());
1564
1565        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1566        let max_body = state.max_request_body;
1567        let mut seen: u64 = 0;
1568        let capped_stream =
1569            data_stream
1570                .map_err(|e| CamelError::Io(e.to_string()))
1571                .map(move |chunk| match chunk {
1572                    Ok(bytes) => {
1573                        seen = seen.saturating_add(bytes.len() as u64);
1574                        if seen > max_body as u64 {
1575                            Err(CamelError::ProcessorError(format!(
1576                                "Request body exceeds configured limit of {max_body} bytes"
1577                            )))
1578                        } else {
1579                            Ok(bytes)
1580                        }
1581                    }
1582                    Err(e) => Err(e),
1583                });
1584        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1585
1586        let stream_body = StreamBody {
1587            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1588            metadata: StreamMetadata {
1589                size_hint: content_length,
1590                content_type,
1591                origin: None,
1592            },
1593        };
1594
1595        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1596        let envelope = RequestEnvelope {
1597            method,
1598            path,
1599            query,
1600            headers,
1601            body: stream_body,
1602            path_params,
1603            reply_tx,
1604        };
1605
1606        if sender.send(envelope).await.is_err() {
1607            return Response::builder()
1608                .status(StatusCode::SERVICE_UNAVAILABLE)
1609                .body(AxumBody::from("Consumer unavailable"))
1610                .expect("infallible"); // allow-unwrap
1611        }
1612
1613        match reply_rx.await {
1614            Ok(reply) => {
1615                let reply = match reply.body {
1616                    HttpReplyBody::Bytes(b)
1617                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1618                    {
1619                        HttpReply {
1620                            status: 500,
1621                            headers: vec![],
1622                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1623                                "Response body exceeds configured limit",
1624                            )),
1625                        }
1626                    }
1627                    _ => reply,
1628                };
1629
1630                let status =
1631                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1632                let mut builder = Response::builder().status(status);
1633                for (k, v) in &reply.headers {
1634                    builder = builder.header(k.as_str(), v.as_str());
1635                }
1636                match reply.body {
1637                    HttpReplyBody::Bytes(b) => {
1638                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1639                            Response::builder()
1640                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1641                                .body(AxumBody::from("Invalid response headers from consumer"))
1642                                .expect("infallible") // allow-unwrap
1643                        })
1644                    }
1645                    HttpReplyBody::Stream(stream) => builder
1646                        .body(AxumBody::from_stream(stream))
1647                        .unwrap_or_else(|_| {
1648                            Response::builder()
1649                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1650                                .body(AxumBody::from("Invalid response headers from consumer"))
1651                                .expect("infallible") // allow-unwrap
1652                        }),
1653                }
1654            }
1655            Err(_) => Response::builder()
1656                .status(StatusCode::INTERNAL_SERVER_ERROR)
1657                .body(AxumBody::from("Pipeline error"))
1658                .expect("infallible"), // allow-unwrap
1659        }
1660    } else {
1661        // No API route matched — try static mounts
1662        static_dispatch::dispatch_static(&state, req, &path).await
1663    }
1664}
1665
1666fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1667    len > max
1668}
1669
1670fn title_case_header(name: &str) -> String {
1671    name.split('-')
1672        .map(|part| {
1673            let mut chars = part.chars();
1674            match chars.next() {
1675                None => String::new(),
1676                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1677            }
1678        })
1679        .collect::<Vec<_>>()
1680        .join("-")
1681}
1682
1683// ---------------------------------------------------------------------------
1684// HttpConsumer
1685// ---------------------------------------------------------------------------
1686
1687/// Kernel authentication state captured from a route's [`SecurityContext`]
1688/// (`unify-transport-auth`, Task 2.9).
1689///
1690/// Same construction-order lifecycle as gRPC's `GrpcKernelAuth` (Task 2.1):
1691/// the compiled plan and the provider registry arrive via
1692/// `Consumer::set_security_context` before `start()` accepts requests. A
1693/// context lacking either piece keeps `kernel = None` — a plan without
1694/// providers can never mint a principal (fail-closed, never a silently
1695/// unauthenticated route: the controller's strict-mode dispatch check then
1696/// denies carrier-less Exchanges on non-Public plans).
1697pub(crate) struct HttpKernelAuth {
1698    pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1699    pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1700}
1701
1702impl HttpKernelAuth {
1703    /// Capture the kernel state from a route's security context.
1704    ///
1705    /// `None` unless both the compiled plan and the provider registry are
1706    /// present.
1707    pub(crate) fn from_security_context(
1708        ctx: &camel_component_api::SecurityContext,
1709    ) -> Option<Self> {
1710        Some(Self {
1711            plan: ctx.plan.clone()?,
1712            providers: ctx.providers.clone()?,
1713        })
1714    }
1715}
1716
1717/// Capacity for the per-route RequestEnvelope channel.
1718///
1719/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1720/// permit from before `send()` until its reply, so at most N envelopes can be
1721/// outstanding at any time. A buffer of N therefore can never fill before the
1722/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1723/// and the semaphore stays the single, URI-configurable backpressure point.
1724/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1725/// (rc-3y6j: 64 vs default 1024 permits).
1726///
1727/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1728/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1729/// start panic-free (the empty semaphore still 503s every request).
1730fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1731    max_inflight_requests.max(1)
1732}
1733
1734pub struct HttpConsumer {
1735    config: HttpServerConfig,
1736    /// Runtime observability handle for ADR-0012 metrics and health calls.
1737    runtime: Arc<dyn RuntimeObservability>,
1738    /// Kernel authentication state (plan + providers), set via
1739    /// `set_security_context` before `start()` (Task 2.9). `None` for routes
1740    /// without route-level security (Public under the per-bind gate).
1741    kernel: Option<Arc<HttpKernelAuth>>,
1742}
1743
1744impl HttpConsumer {
1745    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1746        Self {
1747            config,
1748            runtime,
1749            kernel: None,
1750        }
1751    }
1752}
1753
1754#[async_trait::async_trait]
1755impl Consumer for HttpConsumer {
1756    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1757        use camel_component_api::{Body, Exchange, Message};
1758
1759        let registry = ServerRegistry::global()
1760            .get_or_spawn(
1761                &self.config.host,
1762                self.config.port,
1763                self.config.max_request_body,
1764                self.config.max_response_body,
1765                self.config.max_inflight_requests,
1766                self.runtime.clone(),
1767                ctx.route_id().to_string(),
1768                self.config.tls_config.clone(),
1769            )
1770            .await?;
1771
1772        // Create channel for this path and register it. Capacity matches the
1773        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1774        // the channel can never become a second backpressure point.
1775        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1776            envelope_channel_capacity(self.config.max_inflight_requests),
1777        );
1778        // When the from-URI carries `httpMethod=...` (REST-lowered
1779        // route), register the consumer as a method-aware REST endpoint
1780        // so the dispatcher can route by (method, path template).
1781        // Otherwise fall back to the legacy path-only api_routes
1782        // registry. The two registries never overlap for the same
1783        // route: each consumer registers in exactly one of them.
1784        if let Some(method) = self.config.method.clone() {
1785            let segments = rest_match::parse_path_template(&self.config.path);
1786            registry
1787                .register_rest_endpoint(method, segments, env_tx)
1788                .await;
1789        } else {
1790            registry
1791                .register_api_route(self.config.path.clone(), env_tx)
1792                .await;
1793        }
1794
1795        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1796        // (inside get_or_spawn above), (2) the axum server task was spawned,
1797        // and (3) this route's path/REST endpoint was registered. At this
1798        // point the listener is genuinely accepting connections and any
1799        // request to this route will be dispatched (not 404'd). The runtime
1800        // uses this signal to publish RouteStarted and to release
1801        // ctx.start() so external benchmarks can emit a reliable
1802        // listener-bound marker.
1803        ctx.mark_ready();
1804
1805        let path = self.config.path.clone();
1806        let registry_for_cleanup = registry.clone();
1807        let cancel_token = ctx.cancel_token();
1808        let kernel = self.kernel.clone();
1809        loop {
1810            tokio::select! {
1811                _ = ctx.cancelled() => {
1812                    break;
1813                }
1814                envelope = env_rx.recv() => {
1815                    let Some(envelope) = envelope else { break; };
1816
1817                    // Build Exchange from HTTP request
1818                    let mut msg = Message::default();
1819
1820                    // Set standard Camel HTTP headers
1821                    msg.set_header("CamelHttpMethod",
1822                        serde_json::Value::String(envelope.method.clone()));
1823                    msg.set_header("CamelHttpPath",
1824                        serde_json::Value::String(envelope.path.clone()));
1825                    msg.set_header("CamelHttpQuery",
1826                        serde_json::Value::String(envelope.query.clone()));
1827
1828                    // Set path-parameter headers from REST template
1829                    // match. Expert guidance E2: the consumer is
1830                    // responsible for translating the dispatcher's
1831                    // matched params into `CamelHttpPath_<param>`
1832                    // headers on the Exchange, matching the convention
1833                    // used by Camel HTTP for templated routes.
1834                    for (param_name, param_value) in &envelope.path_params {
1835                        msg.set_header(
1836                            format!("CamelHttpPath_{param_name}"),
1837                            serde_json::Value::String(param_value.clone()),
1838                        );
1839                    }
1840
1841                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1842                    for (k, v) in &envelope.headers {
1843                        if let Ok(val_str) = v.to_str() {
1844                            msg.set_header(
1845                                title_case_header(k.as_str()),
1846                                serde_json::Value::String(val_str.to_string()),
1847                            );
1848                        }
1849                    }
1850
1851                    // Body: always arrives as Body::Stream (native streaming)
1852                    // Routes can call into_bytes() if they need to materialize
1853                    msg.body = Body::Stream(envelope.body);
1854
1855                    #[allow(unused_mut)]
1856                    let mut exchange = Exchange::new(msg);
1857
1858                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1859                    #[cfg(feature = "otel")]
1860                    {
1861                        let headers: HashMap<String, String> = envelope
1862                            .headers
1863                            .iter()
1864                            .filter_map(|(k, v)| {
1865                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1866                            })
1867                            .collect();
1868                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1869                    }
1870
1871                    let reply_tx = envelope.reply_tx;
1872                    let sender = ctx.sender().clone();
1873                    let path_clone = path.clone();
1874                    let cancel = cancel_token.clone();
1875                    // Task 2.9 boundary-auth inputs: the raw header map and
1876                    // the request URI (path + query) feed kernel credential
1877                    // extraction inside the per-request task.
1878                    let auth_headers = envelope.headers.clone();
1879                    let auth_uri: http::Uri = {
1880                        let full = if envelope.query.is_empty() {
1881                            envelope.path.clone()
1882                        } else {
1883                            format!("{}?{}", envelope.path, envelope.query)
1884                        };
1885                        // A malformed path cannot become a valid `Uri`; the
1886                        // empty default then carries no credentials, so
1887                        // extraction finds nothing and authn fails closed.
1888                        full.parse().unwrap_or_default()
1889                    };
1890                    let kernel = kernel.clone();
1891
1892                    // Spawn a task to handle this request concurrently
1893                    //
1894                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1895                    // true concurrent request processing. This change was introduced as part of the
1896                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1897                    //
1898                    // Rationale:
1899                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1900                    //    the consumer's main loop until the pipeline processing completes
1901                    // 2. This blocking would prevent multiple HTTP requests from being processed
1902                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1903                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1904                    //    defeating the purpose of pipeline-side concurrency
1905                    // 4. By spawning a task per request, we allow the consumer loop to continue
1906                    //    accepting new requests while existing ones are processed in the pipeline
1907                    //
1908                    // This approach effectively decouples request acceptance from pipeline processing,
1909                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1910                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1911                    tokio::spawn(async move {
1912                        // Check for cancellation before sending to pipeline.
1913                        // Returns 503 (Service Unavailable) instead of letting the request
1914                        // enter a shutting-down pipeline. This is a behavioral change from
1915                        // the pre-concurrency implementation where cancellation during
1916                        // processing would result in a 500 (Internal Server Error).
1917                        // 503 is more semantically correct: the server is temporarily
1918                        // unable to handle the request due to shutdown.
1919                        if cancel.is_cancelled() {
1920                            let _ = reply_tx.send(HttpReply {
1921                                status: 503,
1922                                headers: vec![],
1923                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1924                            });
1925                            return;
1926                        }
1927
1928                        // ADR-0061 Task 2.9: kernel authentication at the
1929                        // request boundary. A `Public` plan passes through
1930                        // with no extraction; any other mode extracts per
1931                        // the plan's sources, authenticates through the
1932                        // kernel, and installs the typed carrier BEFORE the
1933                        // pipeline runs. A denial renders in the HTTP idiom
1934                        // (401 via `pipeline_error_to_reply`) and the route
1935                        // body never sees the request.
1936                        if let Some(kernel) = kernel.as_ref()
1937                            && !matches!(
1938                                kernel.plan.access_mode,
1939                                camel_api::security_policy::AccessMode::Public
1940                            )
1941                        {
1942                            let principal = match camel_auth::extract_token_multi(
1943                                &auth_headers,
1944                                &auth_uri,
1945                                &kernel.plan.credential_sources,
1946                            ) {
1947                                Some(extracted) => {
1948                                    match camel_auth::kernel_authenticate(
1949                                        &kernel.plan,
1950                                        &kernel.providers,
1951                                        &extracted,
1952                                    )
1953                                    .await
1954                                    {
1955                                        Ok(principal) => principal,
1956                                        Err(e) => {
1957                                            // log-policy: handler-owned
1958                                            tracing::warn!(
1959                                                path = %path_clone,
1960                                                error = %e,
1961                                                "HTTP request authentication failed"
1962                                            );
1963                                            let _ = reply_tx.send(pipeline_error_to_reply(
1964                                                e,
1965                                                &path_clone,
1966                                            ));
1967                                            return;
1968                                        }
1969                                    }
1970                                }
1971                                None => {
1972                                    // log-policy: handler-owned
1973                                    tracing::warn!(
1974                                        path = %path_clone,
1975                                        "HTTP request rejected: no credential found in any source"
1976                                    );
1977                                    let _ = reply_tx.send(pipeline_error_to_reply(
1978                                        CamelError::Unauthenticated(
1979                                            "no credential found in any source".to_string(),
1980                                        ),
1981                                        &path_clone,
1982                                    ));
1983                                    return;
1984                                }
1985                            };
1986                            camel_auth::install_carrier(&mut exchange, &principal);
1987                        }
1988
1989                        // Send through pipeline and await result
1990                        let (tx, rx) = tokio::sync::oneshot::channel();
1991                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
1992                            exchange,
1993                            reply_tx: Some(tx),
1994                        };
1995
1996                        let result = match sender.send(envelope).await {
1997                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1998                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1999                        }
2000                        .and_then(|r| r);
2001
2002                        let reply = match result {
2003                            Ok(out) => {
2004                                let status = out
2005                                    .input
2006                                    .header("CamelHttpResponseCode")
2007                                    .and_then(|v| {
2008                                        let raw = v.as_u64()
2009                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2010                                        let code = raw as u16;
2011                                        (100..1000).contains(&code).then_some(code)
2012                                    })
2013                                    .unwrap_or(200);
2014
2015                                let user_content_type = out
2016                                    .input
2017                                    .header("Content-Type")
2018                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2019
2020                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2021                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2022                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2023                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2024                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2025                                        v.to_string().into_bytes(),
2026                                    )), Some("application/json".to_string())),
2027                                    Body::Stream(s) => {
2028                                        let ct = s.metadata.content_type.clone();
2029                                        match s.stream.lock().await.take() {
2030                                            Some(stream) => (
2031                                                HttpReplyBody::Stream(stream),
2032                                                ct,
2033                                            ),
2034                                            None => {
2035                                                // log-policy: system-broken
2036                                                tracing::error!(
2037                                                    "Body::Stream already consumed before HTTP reply — returning 500"
2038                                                );
2039                                                let error_reply = HttpReply {
2040                                                    status: 500,
2041                                                    headers: vec![],
2042                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2043                                                };
2044                                                if reply_tx.send(error_reply).is_err() {
2045                                                    debug!("reply_tx dropped before error reply could be sent");
2046                                                }
2047                                                return;
2048                                            }
2049                                        }
2050                                    }
2051                                    // Empty and future variants produce an empty reply body.
2052                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2053                                };
2054
2055                                let resp_headers = select_response_headers(
2056                                    &out.input.headers,
2057                                    user_content_type,
2058                                    inferred_content_type,
2059                                );
2060
2061                                HttpReply {
2062                                    status,
2063                                    headers: resp_headers,
2064                                    body: reply_body,
2065                                }
2066                            }
2067                            Err(e) => {
2068                                pipeline_error_to_reply(e, &path_clone)
2069                            }
2070                        };
2071
2072                        // Reply to Axum handler (ignore error if client disconnected)
2073                        let _ = reply_tx.send(reply);
2074                    });
2075                }
2076            }
2077        }
2078
2079        // Deregister this consumer. Mirror the registration choice:
2080        // REST-registered consumers remove their (method, path) endpoint
2081        // WITHOUT touching sibling verbs on the same template (review C1);
2082        // legacy consumers clean up api_routes.
2083        if let Some(method) = &self.config.method {
2084            registry_for_cleanup
2085                .unregister_rest_endpoint(method, &path)
2086                .await;
2087        } else {
2088            registry_for_cleanup.unregister_api_route(&path).await;
2089        }
2090
2091        // D-L10: decrement the shared server's refcount. When the last
2092        // consumer on this (host, port) leaves, the server + monitor tasks
2093        // are aborted and the registry entry is removed.
2094        ServerRegistry::global()
2095            .unregister(&self.config.host, self.config.port)
2096            .await;
2097
2098        Ok(())
2099    }
2100
2101    async fn stop(&mut self) -> Result<(), CamelError> {
2102        Ok(())
2103    }
2104
2105    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2106        camel_component_api::ConcurrencyModel::Concurrent { max: None }
2107    }
2108
2109    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
2110    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
2111    // Opting into Explicit startup makes ctx.start() await the bind+register
2112    // completion so listeners fail fast on bind errors (previously a silent
2113    // background log) and external markers can reliably detect listener-bound
2114    // state.
2115    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2116        camel_component_api::ConsumerStartupMode::Explicit
2117    }
2118
2119    // Task 2.9: capture the kernel state (compiled plan + provider registry)
2120    // wired by the route controller before start(). See `HttpKernelAuth`.
2121    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2122        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2123    }
2124}
2125
2126// ---------------------------------------------------------------------------
2127// HttpComponent / HttpsComponent
2128// ---------------------------------------------------------------------------
2129
2130pub struct HttpComponent {
2131    config: HttpConfig,
2132    pinned_cache: std::sync::Arc<PinnedClientCache>,
2133    client: reqwest::Client,
2134}
2135
2136#[cfg(test)]
2137thread_local! {
2138    static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2139}
2140
2141pub(crate) fn build_client(
2142    config: &HttpConfig,
2143    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2144) -> reqwest::Client {
2145    #[cfg(test)]
2146    BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2147
2148    let mut builder = reqwest::Client::builder()
2149        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
2150        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2151        .pool_max_idle_per_host(config.pool_max_idle_per_host)
2152        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2153
2154    // Redirects are always handled manually in the producer's send path
2155    // so that each hop can be SSRF-validated. reqwest's built-in redirect
2156    // policy is sync and cannot perform async DNS resolution or SSRF checks.
2157    builder = builder.redirect(reqwest::redirect::Policy::none());
2158
2159    if let Some((host, addrs)) = resolve_override {
2160        builder = builder.resolve_to_addrs(host, addrs);
2161    }
2162
2163    if let Some(tls) = &config.tls
2164        && tls.enabled
2165    {
2166        if tls.insecure || !tls.verify_peer {
2167            // log-policy: handler-owned
2168            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2169            builder = builder.danger_accept_invalid_certs(true);
2170        }
2171
2172        if let Some(ca_path) = &tls.ca_cert_path {
2173            // Audit 2026-08-31, F2-7: a configured CA that fails to load must
2174            // never degrade silently to system roots. Loud warn (config error
2175            // class: fail-fast would break existing deployments relying on the
2176            // fallback; the warning is the operator signal).
2177            match std::fs::read(ca_path) {
2178                Ok(ca_bytes) => {
2179                    match reqwest::Certificate::from_pem(&ca_bytes)
2180                        .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2181                    {
2182                        Ok(ca_cert) => {
2183                            builder = builder.add_root_certificate(ca_cert);
2184                        }
2185                        Err(e) => {
2186                            // log-policy: handler-owned
2187                            tracing::warn!(
2188                                error = %e,
2189                                "configured CA certificate failed to parse — falling back to system roots"
2190                            );
2191                        }
2192                    }
2193                }
2194                Err(e) => {
2195                    // log-policy: handler-owned
2196                    tracing::warn!(
2197                        error = %e,
2198                        "configured CA certificate file unreadable — falling back to system roots"
2199                    );
2200                }
2201            }
2202        }
2203
2204        // mTLS identity: BOTH files must load and parse, or the identity is
2205        // absent. A partial failure previously meant silently downgrading to
2206        // non-mTLS — now loud.
2207        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2208            match (std::fs::read(cert_path), std::fs::read(key_path)) {
2209                (Ok(cert_bytes), Ok(key_bytes)) => {
2210                    let mut identity_pem = cert_bytes;
2211                    identity_pem.extend_from_slice(&key_bytes);
2212                    match reqwest::Identity::from_pem(&identity_pem) {
2213                        Ok(identity) => {
2214                            builder = builder.identity(identity);
2215                        }
2216                        Err(e) => {
2217                            // log-policy: handler-owned
2218                            tracing::warn!(
2219                                error = %e,
2220                                "configured mTLS identity failed to parse — client certificate NOT used"
2221                            );
2222                        }
2223                    }
2224                }
2225                (cert_r, key_r) => {
2226                    // log-policy: handler-owned
2227                    tracing::warn!(
2228                        cert_ok = cert_r.is_ok(),
2229                        key_ok = key_r.is_ok(),
2230                        "configured mTLS cert/key file unreadable — client certificate NOT used"
2231                    );
2232                }
2233            }
2234        }
2235    }
2236
2237    builder
2238        .build()
2239        .expect("reqwest::Client::build() with valid config should not fail") // allow-unwrap
2240}
2241
2242#[cfg(test)]
2243pub(crate) fn build_client_call_count() -> u64 {
2244    BUILD_CLIENT_CALLS.with(|c| c.get())
2245}
2246
2247impl HttpComponent {
2248    pub fn new() -> Self {
2249        let config = HttpConfig::default();
2250        Self {
2251            client: build_client(&config, None),
2252            config,
2253            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2254                PINNED_CLIENT_TTL,
2255                PINNED_CLIENT_MAX_ENTRIES,
2256            )),
2257        }
2258    }
2259
2260    pub fn with_config(config: HttpConfig) -> Self {
2261        Self {
2262            client: build_client(&config, None),
2263            config,
2264            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2265                PINNED_CLIENT_TTL,
2266                PINNED_CLIENT_MAX_ENTRIES,
2267            )),
2268        }
2269    }
2270
2271    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2272        match config {
2273            Some(cfg) => Self::with_config(cfg),
2274            None => Self::new(),
2275        }
2276    }
2277}
2278
2279impl Default for HttpComponent {
2280    fn default() -> Self {
2281        Self::new()
2282    }
2283}
2284
2285impl Component for HttpComponent {
2286    fn scheme(&self) -> &str {
2287        "http"
2288    }
2289
2290    fn metadata(&self) -> ComponentMetadata {
2291        HttpEndpointConfig::metadata()
2292    }
2293
2294    fn create_endpoint(
2295        &self,
2296        uri: &str,
2297        ctx: &dyn camel_component_api::ComponentContext,
2298    ) -> Result<Box<dyn Endpoint>, CamelError> {
2299        self.config.validate()?;
2300        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2301        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2302        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2303            server_config.host.clone(),
2304            server_config.port,
2305        )));
2306        self.pinned_cache
2307            .wire(HttpComponentKind::Http, ctx.metrics());
2308        Ok(Box::new(HttpEndpoint {
2309            uri: uri.to_string(),
2310            config,
2311            server_config,
2312            client: self.client.clone(),
2313            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2314            http_config: self.config.clone(),
2315        }))
2316    }
2317}
2318
2319pub struct HttpsComponent {
2320    config: HttpConfig,
2321    pinned_cache: std::sync::Arc<PinnedClientCache>,
2322    client: reqwest::Client,
2323}
2324
2325impl HttpsComponent {
2326    pub fn new() -> Self {
2327        let config = HttpConfig::default();
2328        Self {
2329            client: build_client(&config, None),
2330            config,
2331            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2332                PINNED_CLIENT_TTL,
2333                PINNED_CLIENT_MAX_ENTRIES,
2334            )),
2335        }
2336    }
2337
2338    pub fn with_config(config: HttpConfig) -> Self {
2339        Self {
2340            client: build_client(&config, None),
2341            config,
2342            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2343                PINNED_CLIENT_TTL,
2344                PINNED_CLIENT_MAX_ENTRIES,
2345            )),
2346        }
2347    }
2348
2349    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2350        match config {
2351            Some(cfg) => Self::with_config(cfg),
2352            None => Self::new(),
2353        }
2354    }
2355}
2356
2357impl Default for HttpsComponent {
2358    fn default() -> Self {
2359        Self::new()
2360    }
2361}
2362
2363impl Component for HttpsComponent {
2364    fn scheme(&self) -> &str {
2365        "https"
2366    }
2367
2368    fn metadata(&self) -> ComponentMetadata {
2369        // HTTPS shares the same URI option surface and capabilities as HTTP.
2370        // Only the scheme and description differ.
2371        let mut meta = HttpEndpointConfig::metadata();
2372        meta.scheme = "https".to_string();
2373        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2374        meta
2375    }
2376
2377    fn create_endpoint(
2378        &self,
2379        uri: &str,
2380        ctx: &dyn camel_component_api::ComponentContext,
2381    ) -> Result<Box<dyn Endpoint>, CamelError> {
2382        self.config.validate()?;
2383        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2384        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2385        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2386            server_config.host.clone(),
2387            server_config.port,
2388        )));
2389        self.pinned_cache
2390            .wire(HttpComponentKind::Https, ctx.metrics());
2391        Ok(Box::new(HttpEndpoint {
2392            uri: uri.to_string(),
2393            config,
2394            server_config,
2395            client: self.client.clone(),
2396            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2397            http_config: self.config.clone(),
2398        }))
2399    }
2400}
2401
2402// ---------------------------------------------------------------------------
2403// HttpEndpoint
2404// ---------------------------------------------------------------------------
2405
2406struct HttpEndpoint {
2407    uri: String,
2408    config: HttpEndpointConfig,
2409    server_config: HttpServerConfig,
2410    client: reqwest::Client,
2411    pinned_cache: std::sync::Arc<PinnedClientCache>,
2412    http_config: HttpConfig,
2413}
2414
2415impl Endpoint for HttpEndpoint {
2416    fn uri(&self) -> &str {
2417        &self.uri
2418    }
2419
2420    fn create_consumer(
2421        &self,
2422        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2423    ) -> Result<Box<dyn Consumer>, CamelError> {
2424        // Scheme/config consistency check (spec §5) — uses parsed scheme
2425        // from HttpServerConfig, not a fragile port-443 heuristic.
2426        let scheme_is_https = self.server_config.scheme == "https";
2427        let has_tls = self.server_config.tls_config.is_some();
2428
2429        if scheme_is_https && !has_tls {
2430            return Err(CamelError::EndpointCreationFailed(
2431                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2432            ));
2433        }
2434        if !scheme_is_https && has_tls {
2435            return Err(CamelError::EndpointCreationFailed(
2436                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2437            ));
2438        }
2439        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2440    }
2441
2442    fn create_producer(
2443        &self,
2444        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2445        _ctx: &ProducerContext,
2446    ) -> Result<BoxProcessor, CamelError> {
2447        let producer = HttpProducer {
2448            config: Arc::new(self.config.clone()),
2449            client: self.client.clone(),
2450            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2451            http_config: Arc::new(self.http_config.clone()),
2452            runtime: rt,
2453        };
2454        if let Some(ref provider) = self.config.token_provider {
2455            let layer = BearerTokenLayer::new(Arc::clone(provider));
2456            Ok(BoxProcessor::new(layer.layer(producer)))
2457        } else {
2458            Ok(BoxProcessor::new(producer))
2459        }
2460    }
2461}
2462
2463// ---------------------------------------------------------------------------
2464// HttpProducer
2465// ---------------------------------------------------------------------------
2466
2467#[derive(Clone)]
2468struct HttpProducer {
2469    config: Arc<HttpEndpointConfig>,
2470    client: reqwest::Client,
2471    pinned_cache: std::sync::Arc<PinnedClientCache>,
2472    http_config: Arc<HttpConfig>,
2473    /// Runtime observability handle powering the component-ops facade at
2474    /// the request boundary (`("http","request")`, dashboard-observability
2475    /// Task 4.3). The retained `e:http:accept*` labels are consumer-side
2476    /// (server accept loop) — different boundary, no collision with
2477    /// `e:http:request`.
2478    runtime: Arc<dyn RuntimeObservability>,
2479}
2480
2481impl HttpProducer {
2482    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2483        if let Some(ref method) = config.http_method {
2484            return method.to_uppercase();
2485        }
2486        if let Some(method) = exchange
2487            .input
2488            .header("CamelHttpMethod")
2489            .and_then(|v| v.as_str())
2490        {
2491            return method.to_uppercase();
2492        }
2493        if !exchange.input.body.is_empty() {
2494            return "POST".to_string();
2495        }
2496        "GET".to_string()
2497    }
2498
2499    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2500        // bridgeEndpoint=true: exchange URL headers (CamelHttpUri,
2501        // CamelHttpPath, CamelHttpQuery) are ignored per Apache Camel
2502        // bridging semantics. The endpoint's own query still rides: the
2503        // same raw-preserving, consumed-option-filtered query as the
2504        // non-bridge path (bridgeEndpoint itself is a consumed option),
2505        // with programmatic query_params appending absent keys after the
2506        // raw base. This check MUST come before the CamelHttpUri override
2507        // so bridging wins over that header.
2508        if config.bridge_endpoint {
2509            let Some(query) = resolve_endpoint_query(config)? else {
2510                return Ok(config.base_url.clone());
2511            };
2512            // Validation only (rc-ph7z2): a malformed base still errors
2513            // through the redacted-diagnostic path below. The parsed value
2514            // is NEVER re-emitted — assembly is verbatim string
2515            // composition, authored bytes end-to-end: no WHATWG
2516            // normalization (dot-segment collapse, default-port strip,
2517            // scheme/host lowercasing), matching every other arm (Papal
2518            // Direction A).
2519            let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2520                CamelError::ProcessorError(format!(
2521                    "invalid base URL '{}': {e}",
2522                    redact_url_for_diagnostics(&config.base_url)
2523                ))
2524            })?;
2525            let mut url = config.base_url.clone();
2526            url.push('?');
2527            url.push_str(&query);
2528            return Ok(url);
2529        }
2530
2531        if let Some(uri) = exchange
2532            .input
2533            .header("CamelHttpUri")
2534            .and_then(|v| v.as_str())
2535        {
2536            // Host fence (allowedUriHosts): opt-in, fail-closed. Evaluated
2537            // on the raw override before any path/query assembly; a
2538            // rejection renders the URL only through the diagnostics
2539            // redaction path (ADR-0051).
2540            if let Some(fence) = &config.allowed_uri_hosts
2541                && !uri_host_allowed(uri, fence)?
2542            {
2543                return Err(CamelError::ProcessorError(format!(
2544                    "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2545                    redact_url_for_diagnostics(uri)
2546                )));
2547            }
2548            // The override replaces the base URL; its own query is the
2549            // higher-precedence source for composition (ADR-0071) — the
2550            // endpoint base query does not ride an override. Split at the
2551            // first `?` so CamelHttpPath applies to the path component
2552            // and the queries merge at pair level, never a second `?`
2553            // marker.
2554            let (base, override_query) = match uri.split_once('?') {
2555                Some((base, query)) => (base, Some(query)),
2556                None => (uri, None),
2557            };
2558            // Resolve-time span validation for the override URI's own query
2559            // (rc-m4xk1): a forbidden byte is a resolve error naming the
2560            // byte, never a verbatim ride that later surfaces as a reqwest
2561            // send error. Covers both downstream arms — the verbatim push
2562            // and merge_header_query, which validates only the header side.
2563            if let Some(query) = override_query {
2564                for (_key, span) in raw_query_pairs(query)? {
2565                    validate_raw_query_span(span)?;
2566                }
2567            }
2568            let mut url = base.to_string();
2569            if let Some(path) = exchange
2570                .input
2571                .header("CamelHttpPath")
2572                .and_then(|v| v.as_str())
2573            {
2574                if !url.ends_with('/') && !path.starts_with('/') {
2575                    url.push('/');
2576                }
2577                url.push_str(path);
2578            }
2579            if let Some(query) = exchange
2580                .input
2581                .header("CamelHttpQuery")
2582                .and_then(|v| v.as_str())
2583            {
2584                if let Some(merged) = merge_header_query(override_query, query)? {
2585                    url.push('?');
2586                    url.push_str(&merged);
2587                }
2588                return Ok(url);
2589            }
2590            if let Some(query) = override_query {
2591                url.push('?');
2592                url.push_str(query);
2593            }
2594            return Ok(url);
2595        }
2596
2597        let mut url = config.base_url.clone();
2598
2599        if let Some(path) = exchange
2600            .input
2601            .header("CamelHttpPath")
2602            .and_then(|v| v.as_str())
2603        {
2604            if !url.ends_with('/') && !path.starts_with('/') {
2605                url.push('/');
2606            }
2607            url.push_str(path);
2608        }
2609
2610        if let Some(query) = exchange
2611            .input
2612            .header("CamelHttpQuery")
2613            .and_then(|v| v.as_str())
2614        {
2615            // Compose: the endpoint query (raw-preserving,
2616            // consumed-option-filtered) comes first and wins collisions;
2617            // header pairs append verbatim for absent keys (ADR-0071).
2618            // An empty header leaves the endpoint query unchanged.
2619            if let Some(merged) =
2620                merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2621            {
2622                url.push('?');
2623                url.push_str(&merged);
2624            }
2625            return Ok(url);
2626        }
2627
2628        if let Some(query) = resolve_endpoint_query(config)? {
2629            url.push('?');
2630            url.push_str(&query);
2631        }
2632
2633        Ok(url)
2634    }
2635
2636    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2637        status >= range.0 && status <= range.1
2638    }
2639}
2640
2641/// One allowlist entry of the `CamelHttpUri` host fence (`allowedUriHosts`
2642/// endpoint option). DNS hosts are stored ASCII-lowercased; IPv6 literals
2643/// in bracketed canonical form (the `url` crate's host serialization). A
2644/// `port` of `None` is a host-only entry and permits any port.
2645#[derive(Clone, Debug, PartialEq, Eq)]
2646pub struct AllowedUriHost {
2647    /// Canonical host: lowercased DNS name or bracketed IPv6 literal.
2648    pub host: String,
2649    /// `Some` pins the entry to one effective port; `None` permits any.
2650    pub port: Option<u16>,
2651}
2652
2653/// Parse the `allowedUriHosts` option value: comma-separated `host` or
2654/// `host:port` entries. Bracketed IPv6 is supported (`[::1]:8443`, bare
2655/// `[::1]` host-only). Empty segments are dropped. Segments are parsed
2656/// through the `url` crate (with an `http://` scheme injected) so DNS
2657/// names are lowercased and ports range-checked; anything it rejects is a
2658/// malformed entry. A value yielding zero valid entries is also an error.
2659/// Both failure modes fail endpoint creation (fail-closed).
2660fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2661    let mut entries = Vec::new();
2662    for segment in raw.split(',') {
2663        let segment = segment.trim();
2664        if segment.is_empty() {
2665            continue;
2666        }
2667        let parsed = url::Url::parse(&format!("http://{segment}"))
2668            .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2669        // A segment carrying a path or userinfo is a typo'd entry — the
2670        // spec's "any other malformed entry" clause. Silently narrowing it
2671        // to its hostname would widen or skew the fence.
2672        if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2673            return Err(invalid_allowed_uri_host_entry(segment));
2674        }
2675        let Some(host) = parsed.host_str() else {
2676            return Err(invalid_allowed_uri_host_entry(segment));
2677        };
2678        entries.push(AllowedUriHost {
2679            host: host.to_string(),
2680            port: parsed.port(),
2681        });
2682    }
2683    if entries.is_empty() {
2684        return Err(CamelError::InvalidUri(
2685            "allowedUriHosts declares no valid host entries".to_string(),
2686        ));
2687    }
2688    Ok(entries)
2689}
2690
2691fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2692    CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2693}
2694
2695/// Whether `url_str` matches the fence. Parse failure or a host-less URL
2696/// is fail-closed (`Ok(false)`). DNS hosts compare case-insensitively
2697/// (both sides are lowercased by the `url` crate); IPv6 compares in
2698/// bracketed canonical form. A host-only entry permits any port; a
2699/// `host:port` entry matches only the effective port — the explicit port
2700/// or the scheme default (443 for https, 80 for http).
2701pub(crate) fn uri_host_allowed(
2702    url_str: &str,
2703    fence: &[AllowedUriHost],
2704) -> Result<bool, CamelError> {
2705    let Ok(parsed) = url::Url::parse(url_str) else {
2706        return Ok(false);
2707    };
2708    let Some(host) = parsed.host_str() else {
2709        return Ok(false);
2710    };
2711    let effective_port = parsed.port().or(match parsed.scheme() {
2712        "https" => Some(443_u16),
2713        "http" => Some(80),
2714        _ => None,
2715    });
2716    Ok(fence.iter().any(|entry| {
2717        entry.host == host
2718            && match entry.port {
2719                None => true,
2720                Some(port) => effective_port == Some(port),
2721            }
2722    }))
2723}
2724
2725/// Serialize the outbound query for the endpoint base.
2726///
2727/// Authored raw pairs come first, byte-for-byte minus consumed option keys
2728/// (order, separators and authored escapes — including `RAW(...)` text —
2729/// preserved); then programmatic `query_params` entries whose key is absent
2730/// from the authored pairs, in declaration order with minimal RFC-3986
2731/// encoding (`%20`, never `+`). Authored keys always win — no duplication,
2732/// no override.
2733///
2734/// Returns `Ok(None)` when no query component is emitted: no pairs at all,
2735/// or a non-empty raw query whose every pair was consumed. A bare `?`
2736/// marker (`raw_query == Some("")`) always emits the query component.
2737fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2738    let mut parts: Vec<String> = Vec::new();
2739    let mut authored_keys = std::collections::HashSet::new();
2740
2741    if let Some(raw) = config.raw_query.as_deref() {
2742        for (key, span) in raw_query_pairs(raw)? {
2743            authored_keys.insert(key.clone());
2744            if is_consumed_option(&key) {
2745                continue;
2746            }
2747            validate_raw_query_span(span)?;
2748            parts.push(span.to_string());
2749        }
2750    }
2751
2752    for (key, value) in &config.query_params {
2753        if !authored_keys.contains(key.as_str()) {
2754            parts.push(format!(
2755                "{}={}",
2756                encode_query_component(key),
2757                encode_query_component(value)
2758            ));
2759        }
2760    }
2761
2762    if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2763        return Ok(None);
2764    }
2765    Ok(Some(parts.join("&")))
2766}
2767
2768/// Compose the outbound query when a `CamelHttpQuery` exchange header is
2769/// present (ADR-0071). `higher_precedence` — the endpoint query in the
2770/// base arm, the override URI's own query in the override arm — comes
2771/// first and wins any key collision; header pairs append verbatim for
2772/// absent keys only. An empty header leaves the higher-precedence query
2773/// unchanged (no additional `?` marker). Header spans are validated, not
2774/// re-encoded: a byte forbidden in a query component is a resolve error
2775/// naming the byte (Wave-A law).
2776fn merge_header_query(
2777    higher_precedence: Option<&str>,
2778    header_query: &str,
2779) -> Result<Option<String>, CamelError> {
2780    if header_query.is_empty() {
2781        return Ok(higher_precedence.map(str::to_string));
2782    }
2783    let mut parts: Vec<String> = Vec::new();
2784    let mut higher_keys = std::collections::HashSet::new();
2785    for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2786        higher_keys.insert(key);
2787        parts.push(span.to_string());
2788    }
2789    for (key, span) in raw_query_pairs(header_query)? {
2790        validate_raw_query_span(span)?;
2791        if !higher_keys.contains(key.as_str()) {
2792            parts.push(span.to_string());
2793        }
2794    }
2795    if parts.is_empty() {
2796        return Ok(None);
2797    }
2798    Ok(Some(parts.join("&")))
2799}
2800
2801/// Bytes that may appear unescaped in a URI query component. RFC 3986
2802/// (`query = *( pchar / "/" / "?" )`) admits unreserved, sub-delims, `:`,
2803/// `@`, `/`, `?`, and `%` — with ONE deliberate exclusion from the RFC set:
2804/// the apostrophe (`'`, 0x27). reqwest's WHATWG URL parser re-encodes 0x27
2805/// to `%27` in the special-query percent-encode set (http/https), so an
2806/// authored apostrophe can never ride the wire verbatim; admitting it would
2807/// silently normalize authored bytes (rc-nmupb). Authors write `%27`
2808/// explicitly when they mean the byte on the wire. The WHATWG set's other
2809/// extras (`"`, `` ` ``, `<`, `>`) are already rejected here — they are not
2810/// RFC 3986 query-legal bytes, so no special exclusion is needed for them.
2811fn is_legal_query_byte(byte: u8) -> bool {
2812    matches!(byte,
2813        b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
2814        | b'-' | b'.' | b'_' | b'~'
2815        | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
2816        | b':' | b'@' | b'/' | b'?'
2817        | b'%')
2818}
2819
2820/// Reject an authored raw pair carrying a byte that is not legal in a query
2821/// component (e.g. literal space, `#`, non-ASCII). The serializer never
2822/// silently re-encodes operator-authored bytes: "byte-for-byte" is bounded
2823/// to wire-legal bytes, and the check fires before the resolved string
2824/// reaches any consumer (SSRF pre-check, diagnostics redaction).
2825fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
2826    for &byte in span.as_bytes() {
2827        if !is_legal_query_byte(byte) {
2828            return Err(CamelError::ProcessorError(format!(
2829                "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
2830            )));
2831        }
2832    }
2833    Ok(())
2834}
2835
2836/// Minimal RFC-3986 percent-encoding for one programmatic query component:
2837/// unreserved bytes pass through, every other byte encodes as uppercase
2838/// hex. A space encodes as `%20`, never `+`.
2839fn encode_query_component(component: &str) -> String {
2840    const HEX: &[u8; 16] = b"0123456789ABCDEF";
2841    let mut out = String::with_capacity(component.len());
2842    for &byte in component.as_bytes() {
2843        match byte {
2844            b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
2845                out.push(byte as char);
2846            }
2847            _ => {
2848                out.push('%');
2849                out.push(HEX[(byte >> 4) as usize] as char);
2850                out.push(HEX[(byte & 0x0f) as usize] as char);
2851            }
2852        }
2853    }
2854    out
2855}
2856
2857/// Mask `user:pass@` userinfo in a base-URL string for the
2858/// `HttpEndpointConfig` Debug surface (rc-dhkeo, ADR-0051
2859/// redact-by-construction): byte-preserving string surgery — a
2860/// `url::Url` roundtrip would WHATWG-normalize the rendered bytes. The
2861/// camel grammar path may carry userinfo-style bytes
2862/// (`http://user:pass@h/p`); they must never render in diagnostics.
2863/// Returns the input unchanged when the authority carries no `@`.
2864fn mask_base_url_userinfo(raw: &str) -> String {
2865    let Some(scheme_end) = raw.find("://") else {
2866        return raw.to_string();
2867    };
2868    let after_scheme = &raw[scheme_end + 3..];
2869    // The authority ends at the first path/query/fragment introducer.
2870    let authority_end = after_scheme
2871        .find(['/', '?', '#'])
2872        .unwrap_or(after_scheme.len());
2873    let authority = &after_scheme[..authority_end];
2874    // rfind: when multiple `@` ride the authority, mask through the last —
2875    // over-masking is safe, under-masking is not.
2876    let Some(at) = authority.rfind('@') else {
2877        return raw.to_string();
2878    };
2879    let mut out = String::with_capacity(raw.len());
2880    out.push_str(&raw[..scheme_end + 3]);
2881    out.push_str("***@");
2882    out.push_str(&authority[at + 1..]);
2883    out.push_str(&after_scheme[authority_end..]);
2884    out
2885}
2886
2887/// Redact credentials from a URL before it reaches logs or error values
2888/// (ADR-0051 redact-by-construction). Masks userinfo (`user:pass@`) and the
2889/// query string (which commonly carries API keys/tokens). Host and path stay
2890/// visible for diagnosability. Fail-closed: when the parse fails and the
2891/// `//`-authority window contains `@`, only the `[redacted]` sentinel is
2892/// returned; otherwise the raw string stays visible with the query dropped
2893/// and the result capped at 256 bytes on a UTF-8 char boundary.
2894pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
2895    const MAX_URL_LOG_LEN: usize = 256;
2896    match url::Url::parse(raw) {
2897        Ok(mut u) => {
2898            if !u.username().is_empty() || u.password().is_some() {
2899                let _ = u.set_username("***");
2900                let _ = u.set_password(None);
2901            }
2902            if u.query().is_some() {
2903                u.set_query(None);
2904                // Mark that a query was present without echoing it.
2905                let mut s = u.to_string();
2906                if let Some(stripped) = s.strip_suffix('?') {
2907                    s = stripped.to_string();
2908                }
2909                s.push_str("?[redacted]");
2910                truncate_utf8_safe(&mut s, MAX_URL_LOG_LEN);
2911                return s;
2912            }
2913            let mut s = u.to_string();
2914            truncate_utf8_safe(&mut s, MAX_URL_LOG_LEN);
2915            s
2916        }
2917        Err(_) => {
2918            // Fail closed: an unparseable string with `@` inside its
2919            // authority window may carry credentials the parser never
2920            // validated, so nothing of it is rendered.
2921            if let Some(start) = raw.find("//").map(|idx| idx + 2) {
2922                let end = raw[start..]
2923                    .find(['/', '?', '#'])
2924                    .map_or(raw.len(), |offset| start + offset);
2925                if raw[start..end].contains('@') {
2926                    return "[redacted]".to_string();
2927                }
2928            }
2929            let mut s = raw.to_string();
2930            if let Some(query_start) = raw.find('?') {
2931                s.truncate(query_start);
2932                s.push_str("?[redacted]");
2933            }
2934            truncate_utf8_safe(&mut s, MAX_URL_LOG_LEN);
2935            s
2936        }
2937    }
2938}
2939
2940/// Truncate `s` to at most `max` bytes, walking the cut down to the nearest
2941/// UTF-8 char boundary so a multibyte character straddling the cap cannot
2942/// panic.
2943fn truncate_utf8_safe(s: &mut String, max: usize) {
2944    if s.len() <= max {
2945        return;
2946    }
2947    let mut cut = max;
2948    while !s.is_char_boundary(cut) {
2949        cut -= 1;
2950    }
2951    s.truncate(cut);
2952}
2953
2954/// Maximum bytes of an upstream error response body embedded into
2955/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
2956/// malicious or compromised upstream), so it is truncated and lossy-decoded to
2957/// bound log injection / DLQ payload size.
2958const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
2959
2960fn truncate_error_body(body: &[u8]) -> String {
2961    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
2962        String::from_utf8_lossy(body).into_owned()
2963    } else {
2964        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
2965        s.push_str("...[truncated]");
2966        s
2967    }
2968}
2969
2970impl HttpProducer {
2971    /// Whether the HTTP method is entity-enclosing (may carry a request
2972    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
2973    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
2974    /// §9.3.1/§9.3.2).
2975    fn is_entity_enclosing(method: &str) -> bool {
2976        matches!(method, "POST" | "PUT" | "PATCH")
2977    }
2978}
2979
2980impl Service<Exchange> for HttpProducer {
2981    type Response = Exchange;
2982    type Error = CamelError;
2983    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2984
2985    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2986        Poll::Ready(Ok(()))
2987    }
2988
2989    fn call(&mut self, exchange: Exchange) -> Self::Future {
2990        let config = self.config.clone();
2991        let shared_client = self.client.clone();
2992        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2993        let http_config = self.http_config.clone();
2994        let component_metrics = self.runtime.component_metrics();
2995
2996        Box::pin(async move {
2997            let mut exchange = exchange;
2998            let outcome = async {
2999                let method_str = HttpProducer::resolve_method(&exchange, &config);
3000                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
3001                // and PATCH may carry a request body. Any other resolved method
3002                // drops the exchange body before the request is built (Apache
3003                // Camel `HttpMethods` parity).
3004                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3005                let url = HttpProducer::resolve_url(&exchange, &config)?;
3006
3007                // SECURITY: Validate URL for SSRF
3008                ssrf::validate_url_for_ssrf(&url, &config)?;
3009
3010                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
3011                // (L-H2). When the URL uses a domain name and SSRF protection is active,
3012                // reuse the endpoint's cached DNS-pinned client for that validated
3013                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
3014                // repeated requests keep one connection pool without re-resolving DNS.
3015                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
3016                // URLs use the endpoint's unpinned shared client.
3017                let resolved =
3018                    ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
3019                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3020                    pinned_cache
3021                        .get_or_build(host.as_str(), addrs, || {
3022                            build_client(&http_config, Some((host.as_str(), addrs)))
3023                        })
3024                        .await
3025                } else {
3026                    shared_client.clone()
3027                };
3028
3029                debug!(
3030                    correlation_id = %exchange.correlation_id(),
3031                    method = %method_str,
3032                    url = %redact_url_for_diagnostics(&url),
3033                    "HTTP request"
3034                );
3035
3036                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3037                    CamelError::ProcessorError(format!(
3038                        "Invalid HTTP method '{}': {}",
3039                        method_str, e
3040                    ))
3041                })?;
3042
3043                // Collect headers for potential redirect replay
3044                let mut collected_headers: Vec<(
3045                    reqwest::header::HeaderName,
3046                    reqwest::header::HeaderValue,
3047                )> = Vec::new();
3048
3049                if let Some(user_agent) = &config.user_agent
3050                    && !config.bridge_endpoint
3051                {
3052                    match constructed_header("user-agent", user_agent) {
3053                        Ok((_, val)) => {
3054                            collected_headers.push((reqwest::header::USER_AGENT, val));
3055                        }
3056                        Err(drop) => debug!(
3057                            correlation_id = %exchange.correlation_id(),
3058                            header = %drop.name,
3059                            "outbound header dropped: {}",
3060                            drop.reason
3061                        ),
3062                    }
3063                }
3064
3065                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
3066                #[cfg(feature = "otel")]
3067                let should_inject_otel = !config.bridge_endpoint;
3068                #[cfg(feature = "otel")]
3069                if should_inject_otel {
3070                    let mut otel_headers = HashMap::new();
3071                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3072                    for (k, v) in otel_headers {
3073                        match constructed_header(&k, &v) {
3074                            Ok((name, val)) => collected_headers.push((name, val)),
3075                            Err(drop) => debug!(
3076                                correlation_id = %exchange.correlation_id(),
3077                                header = %drop.name,
3078                                "outbound header dropped: {}",
3079                                drop.reason
3080                            ),
3081                        }
3082                    }
3083                }
3084
3085                let conn_tokens = header_policy::connection_tokens(
3086                    exchange
3087                        .input
3088                        .headers
3089                        .iter()
3090                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3091                        .filter_map(|(_, v)| v.as_str()),
3092                );
3093
3094                let outbound = select_outbound_headers(
3095                    &exchange.input.headers,
3096                    &config.skip_request_headers,
3097                    &conn_tokens,
3098                );
3099                for drop in &outbound.drops {
3100                    if let Some(value_kind) = drop.value_kind {
3101                        debug!(
3102                            correlation_id = %exchange.correlation_id(),
3103                            header = %drop.name,
3104                            value_kind = value_kind,
3105                            "outbound header dropped: {}",
3106                            drop.reason
3107                        );
3108                    } else {
3109                        debug!(
3110                            correlation_id = %exchange.correlation_id(),
3111                            header = %drop.name,
3112                            "outbound header dropped: {}",
3113                            drop.reason
3114                        );
3115                    }
3116                }
3117                collected_headers.extend(outbound.accepted);
3118
3119                // Auth headers
3120                if !config.bridge_endpoint {
3121                    match &config.auth {
3122                        HttpAuth::None => {}
3123                        HttpAuth::Basic { username, password } => {
3124                            use base64::Engine;
3125                            // allow-secret: credentials combined for base64 Basic auth header
3126                            let credentials = format!("{username}:{password}");
3127                            let encoded =
3128                                base64::engine::general_purpose::STANDARD.encode(credentials);
3129                            // Base64 output is always header-safe; the guard is kept
3130                            // for uniformity with Bearer.
3131                            match constructed_header("authorization", &format!("Basic {encoded}")) {
3132                                Ok((_, val)) => {
3133                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3134                                }
3135                                Err(drop) => debug!(
3136                                    correlation_id = %exchange.correlation_id(),
3137                                    header = %drop.name,
3138                                    "outbound header dropped: {}",
3139                                    drop.reason
3140                                ),
3141                            }
3142                        }
3143                        HttpAuth::Bearer { token } => {
3144                            // allow-secret: Bearer token in Authorization header
3145                            let bearer = format!("Bearer {token}");
3146                            match constructed_header("authorization", &bearer) {
3147                                Ok((_, val)) => {
3148                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3149                                }
3150                                Err(drop) => debug!(
3151                                    correlation_id = %exchange.correlation_id(),
3152                                    header = %drop.name,
3153                                    "outbound header dropped: {}",
3154                                    drop.reason
3155                                ),
3156                            }
3157                        }
3158                    }
3159
3160                    if config.connection_close {
3161                        collected_headers.push((
3162                            reqwest::header::CONNECTION,
3163                            reqwest::header::HeaderValue::from_static("close"),
3164                        ));
3165                    }
3166                }
3167
3168                // Materialize body
3169                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3170                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3171                    if suppress_body {
3172                        // A stream body dropped under a non-entity-enclosing
3173                        // method always warns (its emptiness is unknowable) and
3174                        // stays consumed (mem::take). The stream attach arm below
3175                        // still runs its outer flag check, but the inner `if let
3176                        // Body::Stream` re-match fails on the now-Empty body, so
3177                        // no stream is attached and no AlreadyConsumed error can
3178                        // fire.
3179                        std::mem::take(&mut exchange.input.body);
3180                        // log-policy: handler-owned
3181                        tracing::warn!(
3182                            correlation_id = %exchange.correlation_id(),
3183                            method = %method_str,
3184                            "dropping request body for non-entity-enclosing HTTP method"
3185                        );
3186                    }
3187                    None // Streams can't be replayed on redirect
3188                } else {
3189                    let body = std::mem::take(&mut exchange.input.body);
3190                    let bytes = body.into_bytes(config.max_body_size).await?;
3191                    if bytes.is_empty() {
3192                        // Empty body: nothing to send and nothing to warn about.
3193                        None
3194                    } else if suppress_body {
3195                        // log-policy: handler-owned
3196                        tracing::warn!(
3197                            correlation_id = %exchange.correlation_id(),
3198                            method = %method_str,
3199                            "dropping request body for non-entity-enclosing HTTP method"
3200                        );
3201                        None
3202                    } else {
3203                        Some(bytes.to_vec())
3204                    }
3205                };
3206
3207                let response = if config.follow_redirects && !is_stream_body {
3208                    // Use manual redirect loop with per-hop SSRF validation.
3209                    // `client` is the pinned-or-shared binding for the initial
3210                    // request (a hostname initial request keeps its DNS-pinned
3211                    // client); `shared_client` is the unpinned endpoint client
3212                    // reused by IP-literal redirect hops.
3213                    ssrf::send_with_ssrf_safe_redirects(
3214                        &client,
3215                        &shared_client,
3216                        &pinned_cache,
3217                        &http_config,
3218                        &config,
3219                        method,
3220                        &url,
3221                        collected_headers,
3222                        materialized_body,
3223                        config.max_redirects,
3224                        config.response_timeout,
3225                    )
3226                    .await?
3227                } else {
3228                    // Direct send (no redirect following, or streaming body)
3229                    let mut request = client.request(method, &url);
3230
3231                    if let Some(timeout) = config.response_timeout {
3232                        request = request.timeout(timeout);
3233                    }
3234
3235                    for (name, value) in &collected_headers {
3236                        request = request.header(name, value);
3237                    }
3238
3239                    if is_stream_body {
3240                        if let Body::Stream(ref s) = exchange.input.body {
3241                            let mut stream_lock = s.stream.lock().await;
3242                            if let Some(stream) = stream_lock.take() {
3243                                request = request.body(reqwest::Body::wrap_stream(stream));
3244                            } else {
3245                                return Err(CamelError::AlreadyConsumed);
3246                            }
3247                        }
3248                    } else if let Some(ref body_bytes) = materialized_body {
3249                        request = request.body(body_bytes.clone());
3250                    }
3251
3252                    request.send().await.map_err(|e| {
3253                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3254                    })?
3255                };
3256
3257                let status_code = response.status().as_u16();
3258                let status_text = response
3259                    .status()
3260                    .canonical_reason()
3261                    .unwrap_or("Unknown")
3262                    .to_string();
3263
3264                for (key, value) in response.headers() {
3265                    if config
3266                        .skip_response_headers
3267                        .iter()
3268                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3269                    {
3270                        continue;
3271                    }
3272                    if let Ok(val_str) = value.to_str() {
3273                        exchange.input.set_header(
3274                            title_case_header(key.as_str()),
3275                            serde_json::Value::String(val_str.to_string()),
3276                        );
3277                    }
3278                }
3279
3280                exchange.input.set_header(
3281                    "CamelHttpResponseCode",
3282                    serde_json::Value::Number(status_code.into()),
3283                );
3284                exchange.input.set_header(
3285                    "CamelHttpResponseText",
3286                    serde_json::Value::String(status_text.clone()),
3287                );
3288
3289                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
3290                let read_timeout = Duration::from_millis(config.read_timeout_ms);
3291                let response_body = tokio::time::timeout(read_timeout, async {
3292                    // Check Content-Length header before allocating
3293                    if let Some(content_len) = response.content_length()
3294                        && content_len > config.max_response_bytes as u64
3295                    {
3296                        return Err(CamelError::ProcessorError(format!(
3297                            "Response body too large: {} bytes exceeds limit of {} bytes",
3298                            content_len, config.max_response_bytes
3299                        )));
3300                    }
3301                    // Use bytes_stream() for lazy streaming with size guard
3302                    use futures::TryStreamExt;
3303                    let mut stream = response.bytes_stream();
3304                    let mut total: usize = 0;
3305                    let mut collected = Vec::new();
3306                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
3307                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3308                    })? {
3309                        total += chunk.len();
3310                        if total > config.max_response_bytes {
3311                            return Err(CamelError::ProcessorError(format!(
3312                                "Response body too large: {} bytes exceeds limit of {} bytes",
3313                                total, config.max_response_bytes
3314                            )));
3315                        }
3316                        collected.push(chunk);
3317                    }
3318                    let mut result = bytes::BytesMut::with_capacity(total);
3319                    for chunk in collected {
3320                        result.extend_from_slice(&chunk);
3321                    }
3322                    Ok::<bytes::Bytes, CamelError>(result.freeze())
3323                })
3324                .await
3325                .map_err(|_| {
3326                    CamelError::ProcessorError(format!(
3327                        "Read timeout after {}ms",
3328                        config.read_timeout_ms
3329                    ))
3330                })??;
3331
3332                if config.throw_exception_on_failure
3333                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3334                {
3335                    return Err(CamelError::HttpOperationFailed {
3336                        method: method_str,
3337                        // ADR-0051 redact-by-construction: never embed
3338                        // userinfo/query credentials in the error value.
3339                        url: redact_url_for_diagnostics(&url),
3340                        status_code,
3341                        status_text,
3342                        response_body: Some(truncate_error_body(&response_body)),
3343                    });
3344                }
3345
3346                if !response_body.is_empty() {
3347                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3348                }
3349
3350                debug!(
3351                    correlation_id = %exchange.correlation_id(),
3352                    status = status_code,
3353                    url = %redact_url_for_diagnostics(&url),
3354                    "HTTP response"
3355                );
3356                Ok(exchange)
3357            }
3358            .await;
3359            // ("http","request") facade (dashboard-observability 4.3): the
3360            // request boundary is the full client round-trip — SSRF checks,
3361            // send, response read, and (with throwExceptionOnFailure) the
3362            // status gate. http runs no retry_async and the producer
3363            // previously emitted nothing, so no label collides with
3364            // e:http:request.
3365            component_metrics.observe("http", "request", outcome.is_err());
3366            outcome
3367        })
3368    }
3369}
3370
3371/// Serializes tests that mutate or depend on the global `ServerRegistry`.
3372///
3373/// `ServerRegistry::global()` is a process-wide singleton that persists
3374/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
3375/// with another test that has a live server on a fixed port (e.g. 9991),
3376/// the registry entry is removed while the OS socket is still bound, so
3377/// the next `get_or_spawn` call on that port fails with "Address already
3378/// in use". Holding this mutex for the full body of each affected test
3379/// prevents the race without requiring `--test-threads=1`.
3380#[cfg(test)]
3381pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3382
3383/// Poison-recovering acquire of REGISTRY_TEST_MUTEX (httpflake).
3384///
3385/// The mutex guards test SERIALIZATION only - the registry own data is
3386/// protected by its inner lock - so a sibling test that panics while
3387/// holding the guard must not poison the mutex and cascade failures
3388/// into every other holder. Recovery via into_inner is therefore safe
3389/// and keeps one failing test failing as ONE test.
3390#[cfg(test)]
3391pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3392    REGISTRY_TEST_MUTEX
3393        .lock()
3394        .unwrap_or_else(|poisoned| poisoned.into_inner())
3395}
3396
3397/// Map a pipeline error to an HTTP reply.
3398///
3399/// Extracted from the inline `match` in `dispatch_handler` for unit
3400/// testability (rc-1dk4). Client-fault errors map to their 4xx codes
3401/// with a structured JSON error body: `TypeConversionFailed`/
3402/// `ValidationError` → 400, `UnsupportedMediaType` → 415 (media
3403/// negotiation gate, REST lowering), `NotAcceptable` → 406 (same
3404/// gate); `Unauthenticated`/`Unauthorized` keep their `401`/`403`
3405/// mappings; all other errors map to `500 Internal Server Error`.
3406fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3407    match e {
3408        CamelError::Unauthenticated(msg) => {
3409            tracing::warn!(error = %msg, path = %path, "Authentication failed");
3410            HttpReply {
3411                status: 401,
3412                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3413                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3414            }
3415        }
3416        CamelError::Unauthorized(msg) => {
3417            tracing::warn!(error = %msg, path = %path, "Authorization failed");
3418            HttpReply {
3419                status: 403,
3420                headers: vec![],
3421                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3422            }
3423        }
3424        CamelError::TypeConversionFailed(msg) => {
3425            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3426            json_error_reply(400, "bad_request", msg)
3427        }
3428        CamelError::ValidationError(msg) => {
3429            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3430            json_error_reply(400, "validation_error", msg)
3431        }
3432        CamelError::ConsumerStopping => {
3433            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3434            HttpReply {
3435                status: 503,
3436                headers: vec![],
3437                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3438            }
3439        }
3440        CamelError::UnsupportedMediaType { consumed, declared } => {
3441            tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3442            json_error_reply(
3443                415,
3444                "unsupported_media_type",
3445                format!("consumed {consumed}, declared {declared}"),
3446            )
3447        }
3448        CamelError::NotAcceptable { accept, produced } => {
3449            tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3450            json_error_reply(
3451                406,
3452                "not_acceptable",
3453                format!("accept {accept}, produced {produced}"),
3454            )
3455        }
3456        e => {
3457            // log-policy: handler-owned
3458            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3459            HttpReply {
3460                status: 500,
3461                headers: vec![],
3462                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3463            }
3464        }
3465    }
3466}
3467
3468/// Build a JSON error reply with the given status, error code, and message.
3469///
3470/// Shared by the `TypeConversionFailed`/`ValidationError` (400),
3471/// `UnsupportedMediaType` (415), and `NotAcceptable` (406) arms of
3472/// `pipeline_error_to_reply` so the four replies cannot drift apart. The
3473/// `unwrap_or_else(|_| "{}".to_string())` fallback keeps the reply valid
3474/// JSON even if serialization fails.
3475fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3476    let body = serde_json::to_string(&serde_json::json!({
3477        "error": code,
3478        "message": message,
3479    }))
3480    .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3481    HttpReply {
3482        status,
3483        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3484        body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3485    }
3486}
3487
3488/// Lowercase kind name for a JSON value, used in drop diagnostics so log
3489/// readers see *why* a header had no scalar string form without the value
3490/// itself ever entering diagnostics.
3491const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3492    match v {
3493        serde_json::Value::Null => "null",
3494        serde_json::Value::Bool(_) => "bool",
3495        serde_json::Value::Number(_) => "number",
3496        serde_json::Value::String(_) => "string",
3497        serde_json::Value::Array(_) => "array",
3498        serde_json::Value::Object(_) => "object",
3499    }
3500}
3501
3502/// Scalar string form of a JSON value: strings pass through, `Number` and
3503/// `Bool` are stringified, everything else has no single-value form.
3504/// Shared by the consumer reply finaliser and the producer outbound filter
3505/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
3506fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3507    match v {
3508        serde_json::Value::String(s) => Some(s.clone()),
3509        serde_json::Value::Number(n) => Some(n.to_string()),
3510        serde_json::Value::Bool(b) => Some(b.to_string()),
3511        _ => None,
3512    }
3513}
3514
3515/// Select the HTTP response headers emitted by the consumer reply finaliser
3516/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
3517/// `dispatch_handler` for unit testability.
3518///
3519/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
3520/// server-owned headers, plus `content-length`/`content-type` (re-derived),
3521/// and any header named by a `Connection` token. Scalar non-string values
3522/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3523/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
3524/// and arrays have no single-value form and are dropped. Every drop is
3525/// logged at DEBUG with the header name and reason — names only, never
3526/// values, so credentials cannot leak into diagnostics (ADR-0051).
3527/// Appends a single `Content-Type` from `user_content_type` falling back to
3528/// `inferred_content_type` when either is present.
3529fn select_response_headers(
3530    headers: &HashMap<String, serde_json::Value>,
3531    user_content_type: Option<String>,
3532    inferred_content_type: Option<String>,
3533) -> Vec<(String, String)> {
3534    let conn_tokens = header_policy::connection_tokens(
3535        headers
3536            .iter()
3537            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3538            .filter_map(|(_, v)| v.as_str()),
3539    );
3540    let mut selected: Vec<(String, String)> = Vec::new();
3541    for (k, v) in headers {
3542        if k.starts_with("Camel") {
3543            debug!(header = %k, "reply header dropped: Camel namespace");
3544            continue;
3545        }
3546        if header_policy::excluded_response(k, &conn_tokens) {
3547            debug!(header = %k, "reply header dropped: emission policy");
3548            continue;
3549        }
3550        match scalar_string_form(v) {
3551            Some(s) => selected.push((k.clone(), s)),
3552            None => debug!(
3553                header = %k,
3554                value_kind = json_value_kind(v),
3555                "reply header dropped: no scalar string form"
3556            ),
3557        }
3558    }
3559    if let Some(ct) = user_content_type.or(inferred_content_type) {
3560        selected.push(("Content-Type".to_string(), ct));
3561    }
3562    selected
3563}
3564
3565/// One outbound header drop: the exchange header name, a stable reason
3566/// string, and — when the drop was caused by the value having no scalar
3567/// string form — the JSON value kind. Names and kinds only, never values
3568/// (ADR-0051).
3569#[derive(Debug)]
3570struct OutboundHeaderDrop<'a> {
3571    name: &'a str,
3572    reason: &'static str,
3573    value_kind: Option<&'static str>,
3574}
3575
3576/// Outbound exchange-header selection result: headers accepted for the
3577/// wire plus drop records for call-site DEBUG logging.
3578struct OutboundHeaderSelection<'a> {
3579    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3580    drops: Vec<OutboundHeaderDrop<'a>>,
3581}
3582
3583/// Select the exchange headers the HTTP producer forwards on the outbound
3584/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
3585/// `HttpProducer::call` for unit testability.
3586///
3587/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
3588/// hop-by-hop/framing and connection-token-named headers excluded by the
3589/// outbound emission policy, and headers whose name or stringified value
3590/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
3591/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3592/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
3593/// and arrays have no single-value form and are dropped. Drops are returned
3594/// rather than logged so the call site can attach the correlation id; log
3595/// consumers see names and kinds only, never values (ADR-0051).
3596fn select_outbound_headers<'a>(
3597    headers: &'a HashMap<String, serde_json::Value>,
3598    skip_request_headers: &[String],
3599    conn_tokens: &[String],
3600) -> OutboundHeaderSelection<'a> {
3601    let mut accepted = Vec::new();
3602    let mut drops = Vec::new();
3603    for (key, value) in headers {
3604        if key.starts_with("Camel") {
3605            drops.push(OutboundHeaderDrop {
3606                name: key,
3607                reason: "Camel namespace",
3608                value_kind: None,
3609            });
3610            continue;
3611        }
3612        if skip_request_headers
3613            .iter()
3614            .any(|h| h.eq_ignore_ascii_case(key))
3615        {
3616            drops.push(OutboundHeaderDrop {
3617                name: key,
3618                reason: "skip_request_headers",
3619                value_kind: None,
3620            });
3621            continue;
3622        }
3623        if header_policy::excluded_outbound(key, conn_tokens) {
3624            drops.push(OutboundHeaderDrop {
3625                name: key,
3626                reason: "outbound emission policy",
3627                value_kind: None,
3628            });
3629            continue;
3630        }
3631        let Some(val_str) = scalar_string_form(value) else {
3632            drops.push(OutboundHeaderDrop {
3633                name: key,
3634                reason: "no scalar string form",
3635                value_kind: Some(json_value_kind(value)),
3636            });
3637            continue;
3638        };
3639        match constructed_header(key, &val_str) {
3640            Ok((name, val)) => accepted.push((name, val)),
3641            Err(drop) => drops.push(drop),
3642        }
3643    }
3644    OutboundHeaderSelection { accepted, drops }
3645}
3646
3647/// Construct a wire-ready `(HeaderName, HeaderValue)` pair for one outbound
3648/// header, or a drop record when the name or value fails construction
3649/// (rc-jbs1v). Drop records carry name and reason only, never values
3650/// (ADR-0051).
3651fn constructed_header<'a>(
3652    name: &'a str,
3653    value: &str,
3654) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3655    let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3656        Ok(header_name) => header_name,
3657        Err(_) => {
3658            return Err(OutboundHeaderDrop {
3659                name,
3660                reason: "invalid header name",
3661                value_kind: None,
3662            });
3663        }
3664    };
3665    let header_value = match reqwest::header::HeaderValue::from_str(value) {
3666        Ok(header_value) => header_value,
3667        Err(_) => {
3668            return Err(OutboundHeaderDrop {
3669                name,
3670                reason: "invalid header value",
3671                value_kind: None,
3672            });
3673        }
3674    };
3675    Ok((header_name, header_value))
3676}
3677
3678#[cfg(test)]
3679mod tests {
3680    use camel_component_api::test_support::NoopRuntimeObservability;
3681
3682    // Producer/consumer tests drive the component-ops facade on every
3683    // call (dashboard-observability 4.3), so even non-observability tests
3684    // must supply a collector-returning runtime — Noop everywhere.
3685    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3686        std::sync::Arc::new(NoopRuntimeObservability)
3687    }
3688    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3689        std::sync::Arc::new(NoopRuntimeObservability)
3690    }
3691    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3692        std::sync::Arc::new(NoopRuntimeObservability)
3693    }
3694
3695    use super::*;
3696    use crate::rest_match::PathSegment;
3697    use camel_component_api::{Message, NoOpComponentContext};
3698    use std::sync::Arc;
3699    use std::time::Duration;
3700
3701    fn test_producer_ctx() -> ProducerContext {
3702        ProducerContext::new()
3703    }
3704
3705    // -----------------------------------------------------------------------
3706    // Security: credential redaction (audit 2026-08-31, finding F3-1)
3707    // -----------------------------------------------------------------------
3708
3709    #[test]
3710    fn redact_url_masks_userinfo_and_query() {
3711        let redacted =
3712            redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
3713        assert!(
3714            !redacted.contains("secretpass"),
3715            "password must be masked: {redacted}"
3716        );
3717        assert!(
3718            !redacted.contains("token=abc123"),
3719            "query must be masked: {redacted}"
3720        );
3721        assert!(
3722            !redacted.contains("user@"),
3723            "username must be masked: {redacted}"
3724        );
3725        assert!(
3726            redacted.contains("internal.example"),
3727            "host stays visible: {redacted}"
3728        );
3729        assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
3730    }
3731
3732    #[test]
3733    fn redact_url_keeps_clean_urls_visible() {
3734        let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
3735        assert_eq!(redacted, "https://api.example.com/v1/items");
3736    }
3737
3738    #[test]
3739    fn redact_url_masks_password_only_userinfo() {
3740        let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
3741        assert!(
3742            !redacted.contains("pwsecret"),
3743            "password-only userinfo leaked: {redacted}"
3744        );
3745        assert_eq!(redacted, "http://***@host.example/");
3746
3747        let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
3748        assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
3749        assert_eq!(redacted, "http://***@host.example/api");
3750
3751        let redacted = redact_url_for_diagnostics("http://host.example/api");
3752        assert_eq!(redacted, "http://host.example/api");
3753    }
3754
3755    #[test]
3756    fn redact_url_truncates_unparseable() {
3757        let long = "x".repeat(1000);
3758        let redacted = redact_url_for_diagnostics(&long);
3759        assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
3760    }
3761
3762    #[test]
3763    fn redact_url_suppresses_unparseable_authority_credentials() {
3764        let fixtures = [
3765            "http://u:secretpw@/x",
3766            "http://u:secretpw@host:99999/x",
3767            "http://u:secretpw@host:99999",
3768            "//u:secretpw@h/x",
3769        ];
3770        for fixture in fixtures {
3771            assert!(
3772                url::Url::parse(fixture).is_err(),
3773                "fixture must be unparseable: {fixture}"
3774            );
3775            let redacted = redact_url_for_diagnostics(fixture);
3776            assert_eq!(
3777                redacted, "[redacted]",
3778                "credential-bearing authority must be suppressed: {fixture}"
3779            );
3780        }
3781    }
3782
3783    #[test]
3784    fn redact_url_bd_repro_never_leaks_credentials() {
3785        let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
3786        assert!(
3787            !redacted.contains("user:pa%ss"),
3788            "bd rc-2i5c5 repro leaked userinfo: {redacted}"
3789        );
3790        assert!(
3791            !redacted.contains("pa%ss"),
3792            "bd rc-2i5c5 repro leaked password: {redacted}"
3793        );
3794    }
3795
3796    #[test]
3797    fn redact_url_unparseable_query_redacted_short_and_long() {
3798        let short = "http://host:99999/path?token=shortsecret";
3799        assert!(
3800            url::Url::parse(short).is_err(),
3801            "fixture must be unparseable: {short}"
3802        );
3803        let redacted = redact_url_for_diagnostics(short);
3804        assert_eq!(
3805            redacted, "http://host:99999/path?[redacted]",
3806            "short unparseable query must end with the suffix: {redacted}"
3807        );
3808
3809        let mut long = String::from("http://host:99999/");
3810        long.push_str(&"a".repeat(300));
3811        long.push_str("?token=longsecret");
3812        assert!(
3813            url::Url::parse(&long).is_err(),
3814            "fixture must be unparseable: {long}"
3815        );
3816        let redacted = redact_url_for_diagnostics(&long);
3817        assert!(
3818            !redacted.contains("longsecret"),
3819            "long unparseable query leaked a query byte: {redacted}"
3820        );
3821        assert!(
3822            redacted.len() <= 256,
3823            "long unparseable query must be capped: {} bytes",
3824            redacted.len()
3825        );
3826    }
3827
3828    #[test]
3829    fn redact_url_unparseable_utf8_straddle_no_panic() {
3830        let fixture = format!("a{}", "é".repeat(200));
3831        let redacted = redact_url_for_diagnostics(&fixture);
3832        assert!(
3833            redacted.len() <= 256,
3834            "straddle fixture must be capped: {} bytes",
3835            redacted.len()
3836        );
3837        assert!(
3838            redacted.len() >= 253,
3839            "straddle fixture must not over-truncate: {} bytes",
3840            redacted.len()
3841        );
3842        assert!(
3843            fixture.is_char_boundary(redacted.len()),
3844            "cut must land on a UTF-8 char boundary: {} bytes",
3845            redacted.len()
3846        );
3847    }
3848
3849    #[test]
3850    fn redact_url_at_sign_outside_authority_window_visible() {
3851        let at_sign_in_path = "http://host:99999/x@y";
3852        assert!(
3853            url::Url::parse(at_sign_in_path).is_err(),
3854            "fixture must be unparseable: {at_sign_in_path}"
3855        );
3856        assert_eq!(
3857            redact_url_for_diagnostics(at_sign_in_path),
3858            at_sign_in_path,
3859            "at-sign in path must not be suppressed"
3860        );
3861        // mailto parses as a cannot-be-a-base URL (no is_err precondition).
3862        assert_eq!(
3863            redact_url_for_diagnostics("mailto:user@example.com"),
3864            "mailto:user@example.com",
3865            "at-sign in mailto must round-trip byte-identically"
3866        );
3867    }
3868
3869    #[test]
3870    fn truncate_error_body_caps_attacker_body() {
3871        let big = vec![b'A'; 10 * 1024 * 1024];
3872        let truncated = truncate_error_body(&big);
3873        assert!(
3874            truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
3875            "body must be capped near {} bytes, got {}",
3876            MAX_ERROR_RESPONSE_BODY_BYTES,
3877            truncated.len()
3878        );
3879        assert!(truncated.ends_with("...[truncated]"));
3880    }
3881
3882    #[test]
3883    fn truncate_error_body_keeps_small_body() {
3884        assert_eq!(truncate_error_body(b"boom"), "boom");
3885    }
3886
3887    #[test]
3888    fn test_http_config_defaults() {
3889        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
3890        assert_eq!(config.base_url, "http://localhost:8080/api");
3891        assert!(config.http_method.is_none());
3892        assert!(config.throw_exception_on_failure);
3893        assert_eq!(config.ok_status_code_range, (200, 299));
3894        assert!(config.response_timeout.is_none());
3895        assert!(matches!(config.auth, HttpAuth::None));
3896        assert!(!config.bridge_endpoint);
3897        assert!(!config.connection_close);
3898    }
3899
3900    #[test]
3901    fn test_http_config_scheme() {
3902        // UriConfig trait method returns "http" as primary scheme
3903        assert_eq!(HttpEndpointConfig::scheme(), "http");
3904    }
3905
3906    #[test]
3907    fn test_http_config_from_components() {
3908        // Test from_components directly (trait method)
3909        let components = camel_component_api::UriComponents {
3910            scheme: "https".to_string(),
3911            path: "//api.example.com/v1".to_string(),
3912            params: std::collections::HashMap::from([(
3913                "httpMethod".to_string(),
3914                "POST".to_string(),
3915            )]),
3916            raw_query: None,
3917        };
3918        let config = HttpEndpointConfig::from_components(components).unwrap();
3919        assert_eq!(config.base_url, "https://api.example.com/v1");
3920        assert_eq!(config.http_method, Some("POST".to_string()));
3921    }
3922
3923    #[test]
3924    fn test_http_config_with_options() {
3925        let config = HttpEndpointConfig::from_uri(
3926            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
3927        ).unwrap();
3928        assert_eq!(config.base_url, "https://api.example.com/v1");
3929        assert_eq!(config.http_method, Some("PUT".to_string()));
3930        assert!(!config.throw_exception_on_failure);
3931        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
3932    }
3933
3934    #[test]
3935    fn test_http_endpoint_config_auth_and_headers_options() {
3936        let config = HttpEndpointConfig::from_uri(
3937            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
3938        )
3939        .unwrap();
3940
3941        assert!(matches!(
3942            config.auth,
3943            HttpAuth::Basic { username, password } if username == "u" && password == "p"
3944        ));
3945        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
3946        assert!(config.bridge_endpoint);
3947        assert!(config.connection_close);
3948        assert_eq!(
3949            config.skip_request_headers,
3950            vec!["authorization".to_string(), "x-secret".to_string()]
3951        );
3952        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
3953    }
3954
3955    #[test]
3956    fn test_http_endpoint_config_bearer_auth() {
3957        let config = HttpEndpointConfig::from_uri(
3958            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
3959        )
3960        .unwrap();
3961        assert!(matches!(
3962            config.auth,
3963            HttpAuth::Bearer { token } if token == "t"
3964        ));
3965    }
3966
3967    #[test]
3968    fn rejects_cookie_handling_inmemory() {
3969        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
3970        match result {
3971            Err(CamelError::InvalidUri(msg)) => {
3972                assert!(
3973                    msg.contains("cookieHandling is not supported"),
3974                    "expected rejection message, got: {msg}"
3975                );
3976            }
3977            other => panic!("expected InvalidUri error, got: {other:?}"),
3978        }
3979    }
3980
3981    #[test]
3982    fn rejects_cookie_handling_disabled() {
3983        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
3984        match result {
3985            Err(CamelError::InvalidUri(msg)) => {
3986                assert!(
3987                    msg.contains("cookieHandling is not supported"),
3988                    "expected rejection message, got: {msg}"
3989                );
3990            }
3991            other => panic!("expected InvalidUri error, got: {other:?}"),
3992        }
3993    }
3994
3995    #[test]
3996    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
3997        let config = HttpConfig::default()
3998            .with_response_timeout_ms(999)
3999            .with_allow_internal(true)
4000            .with_blocked_hosts(vec!["evil.com".to_string()])
4001            .with_max_body_size(12345);
4002        let endpoint =
4003            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4004        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4005        assert!(endpoint.allow_internal);
4006        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4007        assert_eq!(endpoint.max_body_size, 12345);
4008    }
4009
4010    #[test]
4011    fn test_from_uri_with_defaults_uri_overrides_config() {
4012        let config = HttpConfig::default()
4013            .with_response_timeout_ms(999)
4014            .with_allow_internal(true)
4015            .with_blocked_hosts(vec!["evil.com".to_string()])
4016            .with_max_body_size(12345);
4017        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4018            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4019            &config,
4020        )
4021        .unwrap();
4022        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4023        assert!(!endpoint.allow_internal);
4024        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4025        assert_eq!(endpoint.max_body_size, 99);
4026    }
4027
4028    #[test]
4029    fn test_http_config_ok_status_range() {
4030        let config =
4031            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4032        assert_eq!(config.ok_status_code_range, (200, 204));
4033    }
4034
4035    #[test]
4036    fn test_http_config_wrong_scheme() {
4037        let result = HttpEndpointConfig::from_uri("file:/tmp");
4038        assert!(result.is_err());
4039    }
4040
4041    #[test]
4042    fn test_http_component_scheme() {
4043        let component = HttpComponent::new();
4044        assert_eq!(component.scheme(), "http");
4045    }
4046
4047    #[test]
4048    fn test_https_component_scheme() {
4049        let component = HttpsComponent::new();
4050        assert_eq!(component.scheme(), "https");
4051    }
4052
4053    #[test]
4054    fn test_http_endpoint_creates_consumer() {
4055        let component = HttpComponent::new();
4056        let ctx = NoOpComponentContext;
4057        let endpoint = component
4058            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
4059            .unwrap();
4060        assert!(endpoint.create_consumer(rt()).is_ok());
4061    }
4062
4063    #[test]
4064    fn test_https_endpoint_creates_consumer_errors_without_tls() {
4065        let component = HttpsComponent::new();
4066        let ctx = NoOpComponentContext;
4067        let endpoint = component
4068            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
4069            .unwrap();
4070        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
4071        assert!(endpoint.create_consumer(rt()).is_err());
4072    }
4073
4074    #[test]
4075    fn test_http_endpoint_creates_producer() {
4076        let ctx = test_producer_ctx();
4077        let component = HttpComponent::new();
4078        let endpoint_ctx = NoOpComponentContext;
4079        let endpoint = component
4080            .create_endpoint("http://localhost/api", &endpoint_ctx)
4081            .unwrap();
4082        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
4083    }
4084
4085    // -----------------------------------------------------------------------
4086    // Producer tests
4087    // -----------------------------------------------------------------------
4088
4089    #[tokio::test]
4090    async fn test_producer_with_token_provider() {
4091        use camel_auth::oauth2::TokenProvider;
4092        use tower::ServiceExt;
4093
4094        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
4095            Arc::new(std::sync::Mutex::new(None));
4096        let captured_clone = Arc::clone(&captured_auth);
4097
4098        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4099        let port = listener.local_addr().unwrap().port();
4100
4101        let _handle = tokio::spawn(async move {
4102            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4103            if let Ok((mut stream, _)) = listener.accept().await {
4104                let mut buf = vec![0u8; 8192];
4105                let n = stream.read(&mut buf).await.unwrap_or(0);
4106                let request = String::from_utf8_lossy(&buf[..n]).to_string();
4107                let auth = request
4108                    .lines()
4109                    .find(|l| l.to_lowercase().starts_with("authorization:"))
4110                    .map(|l| {
4111                        l.split(':')
4112                            .nth(1)
4113                            .map(|s| s.trim().to_string())
4114                            .unwrap_or_default()
4115                    });
4116                *captured_clone.lock().unwrap() = auth;
4117                let body = r#"{"echo":"ok"}"#;
4118                let resp = format!(
4119                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4120                    body.len(),
4121                    body
4122                );
4123                let _ = stream.write_all(resp.as_bytes()).await;
4124            }
4125        });
4126
4127        #[derive(Debug)]
4128        struct StaticProvider;
4129        #[async_trait::async_trait]
4130        impl TokenProvider for StaticProvider {
4131            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
4132                Ok("injected-token".into())
4133            }
4134        }
4135
4136        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
4137        let ctx = test_producer_ctx();
4138        let component = HttpComponent::new();
4139        let endpoint_ctx = NoOpComponentContext;
4140        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
4141        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4142
4143        let exchange = Exchange::new(Message::new("hello"));
4144
4145        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
4146        let mut layered = layer.layer(producer);
4147        let result = layered.ready().await.unwrap().call(exchange).await;
4148        assert!(result.is_ok(), "producer call failed: {:?}", result);
4149
4150        tokio::time::sleep(Duration::from_millis(100)).await;
4151        let auth = captured_auth.lock().unwrap().take();
4152        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4153    }
4154
4155    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4156        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4157        let addr = listener.local_addr().unwrap();
4158        let url = format!("http://127.0.0.1:{}", addr.port());
4159
4160        let handle = tokio::spawn(async move {
4161            loop {
4162                if let Ok((mut stream, _)) = listener.accept().await {
4163                    tokio::spawn(async move {
4164                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4165                        let mut buf = vec![0u8; 4096];
4166                        let n = stream.read(&mut buf).await.unwrap_or(0);
4167                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
4168
4169                        let method = request.split_whitespace().next().unwrap_or("GET");
4170
4171                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4172                        let response = format!(
4173                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4174                            body.len(),
4175                            body
4176                        );
4177                        let _ = stream.write_all(response.as_bytes()).await;
4178                    });
4179                }
4180            }
4181        });
4182
4183        (url, handle)
4184    }
4185
4186    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4187        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4188        let addr = listener.local_addr().unwrap();
4189        let url = format!("http://127.0.0.1:{}", addr.port());
4190
4191        let handle = tokio::spawn(async move {
4192            loop {
4193                if let Ok((mut stream, _)) = listener.accept().await {
4194                    let status = status;
4195                    tokio::spawn(async move {
4196                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4197                        let mut buf = vec![0u8; 4096];
4198                        let _ = stream.read(&mut buf).await;
4199
4200                        let status_text = match status {
4201                            404 => "Not Found",
4202                            500 => "Internal Server Error",
4203                            _ => "Error",
4204                        };
4205                        let body = "error body";
4206                        let response = format!(
4207                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4208                            status,
4209                            status_text,
4210                            body.len(),
4211                            body
4212                        );
4213                        let _ = stream.write_all(response.as_bytes()).await;
4214                    });
4215                }
4216            }
4217        });
4218
4219        (url, handle)
4220    }
4221
4222    async fn start_request_capturing_server() -> (
4223        String,
4224        Arc<std::sync::Mutex<Option<String>>>,
4225        tokio::task::JoinHandle<()>,
4226    ) {
4227        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4228        let port = listener.local_addr().unwrap().port();
4229        let url = format!("http://127.0.0.1:{port}");
4230        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
4231        let captured_clone = Arc::clone(&captured);
4232        let handle = tokio::spawn(async move {
4233            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4234            if let Ok((mut stream, _)) = listener.accept().await {
4235                let mut buf = vec![0u8; 16384];
4236                let n = stream.read(&mut buf).await.unwrap_or(0);
4237                let request = String::from_utf8_lossy(&buf[..n]).to_string();
4238                if request.contains("\r\n\r\n") {
4239                    *captured_clone.lock().unwrap() = Some(request);
4240                }
4241                let body = r#"{"echo":"ok"}"#;
4242                let resp = format!(
4243                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4244                    body.len(),
4245                    body
4246                );
4247                let _ = stream.write_all(resp.as_bytes()).await;
4248            }
4249        });
4250        (url, captured, handle)
4251    }
4252
4253    #[tokio::test]
4254    async fn test_http_producer_get_request() {
4255        use tower::ServiceExt;
4256
4257        let (url, _handle) = start_test_server().await;
4258        let ctx = test_producer_ctx();
4259
4260        let component = HttpComponent::new();
4261        let endpoint_ctx = NoOpComponentContext;
4262        let endpoint = component
4263            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4264            .unwrap();
4265        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4266
4267        let exchange = Exchange::new(Message::default());
4268        let result = producer.oneshot(exchange).await.unwrap();
4269
4270        let status = result
4271            .input
4272            .header("CamelHttpResponseCode")
4273            .and_then(|v| v.as_u64())
4274            .unwrap();
4275        assert_eq!(status, 200);
4276
4277        assert!(!result.input.body.is_empty());
4278    }
4279
4280    #[tokio::test]
4281    async fn producer_excludes_host_and_framing() {
4282        use tower::ServiceExt;
4283
4284        let (url, captured, _handle) = start_request_capturing_server().await;
4285        let ctx = test_producer_ctx();
4286        let component = HttpComponent::new();
4287        let endpoint_ctx = NoOpComponentContext;
4288        let endpoint = component
4289            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4290            .unwrap();
4291        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4292
4293        let mut exchange = Exchange::new(Message::default());
4294        exchange.input.set_header("Host", "localhost");
4295        exchange.input.set_header("Content-Length", "42");
4296        exchange.input.set_header("Connection", "keep-alive");
4297        exchange.input.set_header("Upgrade", "h2c");
4298
4299        let result = producer.oneshot(exchange).await;
4300        assert!(result.is_ok(), "producer call failed: {:?}", result);
4301
4302        tokio::time::sleep(Duration::from_millis(100)).await;
4303        let request = captured
4304            .lock()
4305            .unwrap()
4306            .take()
4307            .expect("no outbound request captured");
4308        let lower = request.to_ascii_lowercase();
4309        assert!(
4310            !lower.contains("\r\nhost: localhost"),
4311            "forwarded Host: localhost must be stripped\n{request}"
4312        );
4313        assert!(
4314            !lower.contains("content-length: 42"),
4315            "exchange Content-Length must not be copied\n{request}"
4316        );
4317        assert!(
4318            !lower.lines().any(|l| l.starts_with("connection:")),
4319            "Connection header must not be forwarded\n{request}"
4320        );
4321        assert!(
4322            !lower.lines().any(|l| l.starts_with("upgrade:")),
4323            "Upgrade header must not be forwarded\n{request}"
4324        );
4325        let host_header = lower
4326            .lines()
4327            .find(|l| l.starts_with("host:"))
4328            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
4329            .expect("outbound Host header must be set by reqwest");
4330        assert!(
4331            host_header.starts_with("127.0.0.1:"),
4332            "outbound Host '{host_header}' must match the capture-server address"
4333        );
4334    }
4335
4336    #[tokio::test]
4337    async fn producer_forwards_request_only_headers() {
4338        use tower::ServiceExt;
4339
4340        let (url, captured, _handle) = start_request_capturing_server().await;
4341        let ctx = test_producer_ctx();
4342        let component = HttpComponent::new();
4343        let endpoint_ctx = NoOpComponentContext;
4344        let endpoint = component
4345            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4346            .unwrap();
4347        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4348
4349        let mut exchange = Exchange::new(Message::default());
4350        exchange.input.set_header("Accept", "application/json");
4351        exchange.input.set_header("User-Agent", "myclient/1.0");
4352
4353        let result = producer.oneshot(exchange).await;
4354        assert!(result.is_ok(), "producer call failed: {:?}", result);
4355
4356        tokio::time::sleep(Duration::from_millis(100)).await;
4357        let request = captured
4358            .lock()
4359            .unwrap()
4360            .take()
4361            .expect("no outbound request captured");
4362        let lower = request.to_ascii_lowercase();
4363        assert!(
4364            lower.contains("accept: application/json"),
4365            "request-only Accept header must be forwarded\n{request}"
4366        );
4367        assert!(
4368            lower.contains("user-agent: myclient/1.0"),
4369            "request-only User-Agent header must be forwarded\n{request}"
4370        );
4371    }
4372
4373    // -----------------------------------------------------------------------
4374    // Configured-header construction failures are surfaced, never silent
4375    // (rc-jbs1v)
4376    // -----------------------------------------------------------------------
4377
4378    /// Build an endpoint whose URI parses normally but whose `user_agent`
4379    /// and `auth` are then overridden programmatically, so CRLF-bearing
4380    /// test values never pass through URI parsing.
4381    fn endpoint_with_config_overrides(
4382        base_url: &str,
4383        user_agent: Option<String>,
4384        auth: HttpAuth,
4385    ) -> HttpEndpoint {
4386        let uri = format!("{base_url}/api/test?allowInternal=true");
4387        let mut config =
4388            HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
4389        config.user_agent = user_agent;
4390        config.auth = auth;
4391        HttpEndpoint {
4392            uri: uri.clone(),
4393            config,
4394            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
4395            client: reqwest::Client::new(),
4396            pinned_cache: Arc::new(PinnedClientCache::new(
4397                PINNED_CLIENT_TTL,
4398                PINNED_CLIENT_MAX_ENTRIES,
4399            )),
4400            http_config: HttpConfig::default(),
4401        }
4402    }
4403
4404    /// A configured user-agent / bearer token that fails `HeaderValue`
4405    /// construction must be dropped with a DEBUG record (name + reason
4406    /// only, never the value — ADR-0051) and reach the wire absent, while
4407    /// a valid config passes through unchanged.
4408    #[tracing_test::traced_test]
4409    #[tokio::test]
4410    async fn producer_invalid_configured_headers_surfaced() {
4411        use tower::ServiceExt;
4412
4413        let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
4414        let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
4415        let ctx = test_producer_ctx();
4416
4417        let bad_producer = endpoint_with_config_overrides(
4418            &bad_url,
4419            Some("bad\r\nua".to_string()),
4420            HttpAuth::Bearer {
4421                token: "tok\r\nen".to_string(),
4422            },
4423        )
4424        .create_producer(rt(), &ctx)
4425        .unwrap();
4426        let ok_producer = endpoint_with_config_overrides(
4427            &ok_url,
4428            Some("httpsweep-ok/1".to_string()),
4429            HttpAuth::Bearer {
4430                token: "valid-token".to_string(),
4431            },
4432        )
4433        .create_producer(rt(), &ctx)
4434        .unwrap();
4435
4436        let bad_exchange = Exchange::new(Message::default());
4437        let ok_exchange = Exchange::new(Message::default());
4438        let bad_cid = bad_exchange.correlation_id().to_string();
4439        let ok_cid = ok_exchange.correlation_id().to_string();
4440
4441        let bad_result = bad_producer.oneshot(bad_exchange).await;
4442        assert!(
4443            bad_result.is_ok(),
4444            "invalid-config producer call failed: {bad_result:?}"
4445        );
4446        let ok_result = ok_producer.oneshot(ok_exchange).await;
4447        assert!(
4448            ok_result.is_ok(),
4449            "valid-config producer call failed: {ok_result:?}"
4450        );
4451
4452        tokio::time::sleep(Duration::from_millis(100)).await;
4453        let bad_request = bad_captured
4454            .lock()
4455            .unwrap()
4456            .take()
4457            .expect("no outbound request captured");
4458        let ok_request = ok_captured
4459            .lock()
4460            .unwrap()
4461            .take()
4462            .expect("no outbound request captured");
4463
4464        // Invalid config: neither header reaches the wire. Value-absence,
4465        // not "any UA" — reqwest may inject a default user-agent.
4466        let bad_lower = bad_request.to_ascii_lowercase();
4467        assert!(
4468            !bad_lower.lines().any(|l| l.starts_with("authorization:")),
4469            "invalid Bearer token must not reach the wire\n{bad_request}"
4470        );
4471        assert!(
4472            !bad_request.contains("bad\r\nua"),
4473            "invalid configured user-agent must not reach the wire\n{bad_request}"
4474        );
4475
4476        logs_assert(|lines: &[&str]| {
4477            let drops: Vec<&&str> = lines
4478                .iter()
4479                .filter(|l| {
4480                    l.contains("outbound header dropped")
4481                        && l.contains(&format!("correlation_id={bad_cid}"))
4482                })
4483                .collect();
4484            if drops.len() != 2 {
4485                return Err(format!(
4486                    "expected exactly 2 drop records for {bad_cid}, found {}",
4487                    drops.len()
4488                ));
4489            }
4490            let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
4491            let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
4492            let reason_ok = drops
4493                .iter()
4494                .all(|l| l.contains("outbound header dropped: invalid header value"));
4495            match (has_ua, has_auth, reason_ok) {
4496                (true, true, true) => Ok(()),
4497                _ => Err(format!(
4498                    "drop records mismatched: user-agent={has_ua} \
4499                     authorization={has_auth} reason-ok={reason_ok}"
4500                )),
4501            }
4502        });
4503        logs_assert(|lines: &[&str]| {
4504            if lines
4505                .iter()
4506                .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
4507            {
4508                Err("sentinel CRLF values leaked into logs".to_string())
4509            } else {
4510                Ok(())
4511            }
4512        });
4513
4514        // Valid config: both headers reach the wire exactly as configured,
4515        // with zero drop records.
4516        let ok_lower = ok_request.to_ascii_lowercase();
4517        assert!(
4518            ok_lower.contains("user-agent: httpsweep-ok/1"),
4519            "valid configured user-agent must reach the wire\n{ok_request}"
4520        );
4521        assert!(
4522            ok_lower.contains("authorization: bearer valid-token"),
4523            "valid Bearer token must reach the wire\n{ok_request}"
4524        );
4525        logs_assert(|lines: &[&str]| {
4526            let hits = lines
4527                .iter()
4528                .filter(|l| {
4529                    l.contains("outbound header dropped")
4530                        && l.contains(&format!("correlation_id={ok_cid}"))
4531                })
4532                .count();
4533            match hits {
4534                0 => Ok(()),
4535                n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
4536            }
4537        });
4538    }
4539
4540    #[tokio::test]
4541    async fn producer_honours_skip_request_headers() {
4542        use tower::ServiceExt;
4543
4544        let (url, captured, _handle) = start_request_capturing_server().await;
4545        let ctx = test_producer_ctx();
4546        let component = HttpComponent::new();
4547        let endpoint_ctx = NoOpComponentContext;
4548        let endpoint = component
4549            .create_endpoint(
4550                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
4551                &endpoint_ctx,
4552            )
4553            .unwrap();
4554        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4555
4556        let mut exchange = Exchange::new(Message::default());
4557        exchange.input.set_header("Authorization", "Bearer x");
4558
4559        let result = producer.oneshot(exchange).await;
4560        assert!(result.is_ok(), "producer call failed: {:?}", result);
4561
4562        tokio::time::sleep(Duration::from_millis(100)).await;
4563        let request = captured
4564            .lock()
4565            .unwrap()
4566            .take()
4567            .expect("no outbound request captured");
4568        assert!(
4569            !request.to_ascii_lowercase().contains("authorization"),
4570            "Authorization must be stripped by skipRequestHeaders\n{request}"
4571        );
4572    }
4573
4574    #[tokio::test]
4575    async fn producer_stringifies_scalar_header_values_on_wire() {
4576        use tower::ServiceExt;
4577
4578        let (url, captured, _handle) = start_request_capturing_server().await;
4579        let ctx = test_producer_ctx();
4580        let component = HttpComponent::new();
4581        let endpoint_ctx = NoOpComponentContext;
4582        let endpoint = component
4583            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4584            .unwrap();
4585        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4586
4587        let mut exchange = Exchange::new(Message::default());
4588        exchange.input.set_header("X-Retries", serde_json::json!(3));
4589        exchange
4590            .input
4591            .set_header("X-Enabled", serde_json::json!(true));
4592        exchange
4593            .input
4594            .set_header("X-Obj", serde_json::json!({"a": 1}));
4595
4596        let result = producer.oneshot(exchange).await;
4597        assert!(result.is_ok(), "producer call failed: {:?}", result);
4598
4599        tokio::time::sleep(Duration::from_millis(100)).await;
4600        let request = captured
4601            .lock()
4602            .unwrap()
4603            .take()
4604            .expect("no outbound request captured");
4605        let lower = request.to_ascii_lowercase();
4606        assert!(
4607            lower.contains("x-retries: 3"),
4608            "numeric header must reach the wire stringified\n{request}"
4609        );
4610        assert!(
4611            lower.contains("x-enabled: true"),
4612            "bool header must reach the wire stringified\n{request}"
4613        );
4614        assert!(
4615            !lower.contains("x-obj:"),
4616            "object header has no single-value form and must not reach the wire\n{request}"
4617        );
4618    }
4619
4620    #[tokio::test]
4621    async fn test_http_producer_post_with_body() {
4622        use tower::ServiceExt;
4623
4624        let (url, _handle) = start_test_server().await;
4625        let ctx = test_producer_ctx();
4626
4627        let component = HttpComponent::new();
4628        let endpoint_ctx = NoOpComponentContext;
4629        let endpoint = component
4630            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
4631            .unwrap();
4632        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4633
4634        let exchange = Exchange::new(Message::new("request body"));
4635        let result = producer.oneshot(exchange).await.unwrap();
4636
4637        let status = result
4638            .input
4639            .header("CamelHttpResponseCode")
4640            .and_then(|v| v.as_u64())
4641            .unwrap();
4642        assert_eq!(status, 200);
4643    }
4644
4645    #[tokio::test]
4646    async fn test_http_producer_method_from_header() {
4647        use tower::ServiceExt;
4648
4649        let (url, _handle) = start_test_server().await;
4650        let ctx = test_producer_ctx();
4651
4652        let component = HttpComponent::new();
4653        let endpoint_ctx = NoOpComponentContext;
4654        let endpoint = component
4655            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4656            .unwrap();
4657        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4658
4659        let mut exchange = Exchange::new(Message::default());
4660        exchange.input.set_header(
4661            "CamelHttpMethod",
4662            serde_json::Value::String("DELETE".to_string()),
4663        );
4664
4665        let result = producer.oneshot(exchange).await.unwrap();
4666        let status = result
4667            .input
4668            .header("CamelHttpResponseCode")
4669            .and_then(|v| v.as_u64())
4670            .unwrap();
4671        assert_eq!(status, 200);
4672    }
4673
4674    #[tokio::test]
4675    async fn test_http_producer_forced_method() {
4676        use tower::ServiceExt;
4677
4678        let (url, _handle) = start_test_server().await;
4679        let ctx = test_producer_ctx();
4680
4681        let component = HttpComponent::new();
4682        let endpoint_ctx = NoOpComponentContext;
4683        let endpoint = component
4684            .create_endpoint(
4685                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
4686                &endpoint_ctx,
4687            )
4688            .unwrap();
4689        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4690
4691        let exchange = Exchange::new(Message::default());
4692        let result = producer.oneshot(exchange).await.unwrap();
4693
4694        let status = result
4695            .input
4696            .header("CamelHttpResponseCode")
4697            .and_then(|v| v.as_u64())
4698            .unwrap();
4699        assert_eq!(status, 200);
4700    }
4701
4702    #[tokio::test]
4703    async fn test_http_producer_throw_exception_on_failure() {
4704        use tower::ServiceExt;
4705
4706        let (url, _handle) = start_status_server(404).await;
4707        let ctx = test_producer_ctx();
4708
4709        let component = HttpComponent::new();
4710        let endpoint_ctx = NoOpComponentContext;
4711        let endpoint = component
4712            .create_endpoint(
4713                &format!("{url}/not-found?allowInternal=true"),
4714                &endpoint_ctx,
4715            )
4716            .unwrap();
4717        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4718
4719        let exchange = Exchange::new(Message::default());
4720        let result = producer.oneshot(exchange).await;
4721        assert!(result.is_err());
4722
4723        match result.unwrap_err() {
4724            CamelError::HttpOperationFailed { status_code, .. } => {
4725                assert_eq!(status_code, 404);
4726            }
4727            e => panic!("Expected HttpOperationFailed, got: {e}"),
4728        }
4729    }
4730
4731    #[tokio::test]
4732    async fn test_http_producer_no_throw_on_failure() {
4733        use tower::ServiceExt;
4734
4735        let (url, _handle) = start_status_server(500).await;
4736        let ctx = test_producer_ctx();
4737
4738        let component = HttpComponent::new();
4739        let endpoint_ctx = NoOpComponentContext;
4740        let endpoint = component
4741            .create_endpoint(
4742                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
4743                &endpoint_ctx,
4744            )
4745            .unwrap();
4746        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4747
4748        let exchange = Exchange::new(Message::default());
4749        let result = producer.oneshot(exchange).await.unwrap();
4750
4751        let status = result
4752            .input
4753            .header("CamelHttpResponseCode")
4754            .and_then(|v| v.as_u64())
4755            .unwrap();
4756        assert_eq!(status, 500);
4757    }
4758
4759    #[tokio::test]
4760    async fn test_http_producer_uri_override() {
4761        use tower::ServiceExt;
4762
4763        let (url, _handle) = start_test_server().await;
4764        let ctx = test_producer_ctx();
4765
4766        let component = HttpComponent::new();
4767        let endpoint_ctx = NoOpComponentContext;
4768        let endpoint = component
4769            .create_endpoint(
4770                "http://localhost:1/does-not-exist?allowInternal=true",
4771                &endpoint_ctx,
4772            )
4773            .unwrap();
4774        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4775
4776        let mut exchange = Exchange::new(Message::default());
4777        exchange.input.set_header(
4778            "CamelHttpUri",
4779            serde_json::Value::String(format!("{url}/api")),
4780        );
4781
4782        let result = producer.oneshot(exchange).await.unwrap();
4783        let status = result
4784            .input
4785            .header("CamelHttpResponseCode")
4786            .and_then(|v| v.as_u64())
4787            .unwrap();
4788        assert_eq!(status, 200);
4789    }
4790
4791    #[tokio::test]
4792    async fn test_http_producer_response_headers_mapped() {
4793        use tower::ServiceExt;
4794
4795        let (url, _handle) = start_test_server().await;
4796        let ctx = test_producer_ctx();
4797
4798        let component = HttpComponent::new();
4799        let endpoint_ctx = NoOpComponentContext;
4800        let endpoint = component
4801            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4802            .unwrap();
4803        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4804
4805        let exchange = Exchange::new(Message::default());
4806        let result = producer.oneshot(exchange).await.unwrap();
4807
4808        assert!(
4809            result.input.header("Content-Type").is_some(),
4810            "Response should have Content-Type header"
4811        );
4812        assert!(result.input.header("CamelHttpResponseText").is_some());
4813    }
4814
4815    // -----------------------------------------------------------------------
4816    // Bug fix tests: Client configuration per-endpoint
4817    // -----------------------------------------------------------------------
4818
4819    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
4820        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4821        let addr = listener.local_addr().unwrap();
4822        let url = format!("http://127.0.0.1:{}", addr.port());
4823
4824        let handle = tokio::spawn(async move {
4825            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4826            loop {
4827                if let Ok((mut stream, _)) = listener.accept().await {
4828                    tokio::spawn(async move {
4829                        let mut buf = vec![0u8; 4096];
4830                        let n = stream.read(&mut buf).await.unwrap_or(0);
4831                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
4832
4833                        // Check if this is a request to /final
4834                        if request.contains("GET /final") {
4835                            let body = r#"{"status":"final"}"#;
4836                            let response = format!(
4837                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4838                                body.len(),
4839                                body
4840                            );
4841                            let _ = stream.write_all(response.as_bytes()).await;
4842                        } else {
4843                            // Redirect to /final
4844                            // Connection: close stops the client pooling the
4845                            // connection the server drops right after this
4846                            // response (pooled-race, rc-u3aw class).
4847                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
4848                            let _ = stream.write_all(response.as_bytes()).await;
4849                        }
4850                    });
4851                }
4852            }
4853        });
4854
4855        (url, handle)
4856    }
4857
4858    struct CapturedRequest {
4859        method: String,
4860        path: String,
4861        body: Vec<u8>,
4862        content_length: Option<String>,
4863        transfer_encoding: Option<String>,
4864    }
4865
4866    /// Parse a request head plus its Content-Length-driven body from a freshly
4867    /// accepted connection. Returns `None` if the client closes before sending
4868    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
4869    /// keep-alive connections and never sends FIN) and does NOT rely on a
4870    /// single fixed-size read (a segmented small body would flake).
4871    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
4872        use tokio::io::AsyncReadExt;
4873
4874        // Read the request head (up to and including the terminating CRLF CRLF).
4875        let mut buf: Vec<u8> = Vec::new();
4876        let mut chunk = [0u8; 4096];
4877        let head_end: usize;
4878        loop {
4879            let n = stream.read(&mut chunk).await.unwrap_or(0);
4880            if n == 0 {
4881                return None;
4882            }
4883            buf.extend_from_slice(&chunk[..n]);
4884            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
4885                head_end = pos + 4;
4886                break;
4887            }
4888        }
4889
4890        // Parse the request head.
4891        let head = String::from_utf8_lossy(&buf[..head_end]);
4892        let mut lines = head.split("\r\n");
4893        let request_line = lines.next().unwrap_or("");
4894        let mut parts = request_line.split_whitespace();
4895        let method = parts.next().unwrap_or("").to_string();
4896        let path = parts.next().unwrap_or("").to_string();
4897
4898        let mut content_length: Option<String> = None;
4899        let mut transfer_encoding: Option<String> = None;
4900        for line in lines {
4901            if let Some((name, value)) = line.split_once(':') {
4902                let name = name.trim().to_ascii_lowercase();
4903                let value = value.trim().to_string();
4904                if name == "content-length" {
4905                    content_length = Some(value);
4906                } else if name == "transfer-encoding" {
4907                    transfer_encoding = Some(value);
4908                }
4909            }
4910        }
4911
4912        // Content-Length-driven exact read. A missing header means a 0-length body.
4913        let body_len: usize = content_length
4914            .as_deref()
4915            .and_then(|v| v.parse::<usize>().ok())
4916            .unwrap_or(0);
4917
4918        let mut body: Vec<u8> = buf[head_end..].to_vec();
4919        while body.len() < body_len {
4920            let n = stream.read(&mut chunk).await.unwrap_or(0);
4921            if n == 0 {
4922                break;
4923            }
4924            body.extend_from_slice(&chunk[..n]);
4925        }
4926        body.truncate(body_len);
4927
4928        Some(CapturedRequest {
4929            method,
4930            path,
4931            body,
4932            content_length,
4933            transfer_encoding,
4934        })
4935    }
4936
4937    /// A raw-TCP capture server. Each connection parses the request head, then
4938    /// performs a Content-Length-driven exact read of the body (see
4939    /// [`capture_request`]). Each connection is dropped after the response so
4940    /// every hop opens a fresh connection.
4941    async fn start_capture_server() -> (
4942        String,
4943        tokio::task::JoinHandle<()>,
4944        Arc<Mutex<Vec<CapturedRequest>>>,
4945    ) {
4946        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4947        let addr = listener.local_addr().unwrap();
4948        let url = format!("http://127.0.0.1:{}", addr.port());
4949
4950        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4951        let captured_for_return = Arc::clone(&captured);
4952
4953        let handle = tokio::spawn(async move {
4954            use tokio::io::AsyncWriteExt;
4955            loop {
4956                if let Ok((mut stream, _)) = listener.accept().await {
4957                    let captured = Arc::clone(&captured);
4958                    tokio::spawn(async move {
4959                        let Some(req) = capture_request(&mut stream).await else {
4960                            return;
4961                        };
4962                        captured.lock().unwrap().push(req);
4963
4964                        // 200 OK with Content-Length: 0 and no body, then drop
4965                        // the stream so the client opens a fresh connection.
4966                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
4967                        let _ = stream.write_all(response.as_bytes()).await;
4968                    });
4969                }
4970            }
4971        });
4972
4973        (url, handle, captured_for_return)
4974    }
4975
4976    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
4977    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
4978    /// whose `/final` path answers `200 OK` with an empty body. Every hop
4979    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
4980    /// the connection after responding so each hop is a fresh connection.
4981    async fn start_redirect_capture_server() -> (
4982        String,
4983        tokio::task::JoinHandle<()>,
4984        Arc<Mutex<Vec<CapturedRequest>>>,
4985    ) {
4986        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4987        let addr = listener.local_addr().unwrap();
4988        let url = format!("http://127.0.0.1:{}", addr.port());
4989
4990        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4991        let captured_for_return = Arc::clone(&captured);
4992
4993        let handle = tokio::spawn(async move {
4994            use tokio::io::AsyncWriteExt;
4995            loop {
4996                if let Ok((mut stream, _)) = listener.accept().await {
4997                    let captured = Arc::clone(&captured);
4998                    tokio::spawn(async move {
4999                        let Some(req) = capture_request(&mut stream).await else {
5000                            return;
5001                        };
5002                        let path = req.path.clone();
5003                        captured.lock().unwrap().push(req);
5004
5005                        let (status_line, location) = match path.as_str() {
5006                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5007                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5008                            "/final" => ("HTTP/1.1 200 OK", None),
5009                            _ => ("HTTP/1.1 404 Not Found", None),
5010                        };
5011
5012                        let response = match location {
5013                            // Connection: close stops the client pooling the
5014                            // connection this handler drops right after the
5015                            // response (pooled-race, rc-u3aw class).
5016                            Some(loc) => format!(
5017                                "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5018                            ),
5019                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5020                        };
5021                        let _ = stream.write_all(response.as_bytes()).await;
5022                    });
5023                }
5024            }
5025        });
5026
5027        (url, handle, captured_for_return)
5028    }
5029
5030    #[tokio::test]
5031    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5032        use tower::ServiceExt;
5033
5034        let (url, _handle, captured) = start_capture_server().await;
5035        let ctx = test_producer_ctx();
5036
5037        let component = HttpComponent::with_config(HttpConfig::default());
5038        let endpoint_ctx = NoOpComponentContext;
5039        let endpoint = component
5040            .create_endpoint(
5041                &format!("{url}?httpMethod=GET&allowInternal=true"),
5042                &endpoint_ctx,
5043            )
5044            .unwrap();
5045        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5046
5047        let mut exchange = Exchange::new(Message::default());
5048        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5049
5050        let result = producer.oneshot(exchange).await.unwrap();
5051
5052        let status = result
5053            .input
5054            .header("CamelHttpResponseCode")
5055            .and_then(|v| v.as_u64())
5056            .unwrap();
5057        assert_eq!(status, 200);
5058
5059        let captured = captured.lock().unwrap();
5060        assert_eq!(captured.len(), 1, "expected exactly one captured request");
5061        let req = &captured[0];
5062        assert_eq!(req.method, "GET");
5063        // `httpMethod`/`allowInternal` are URI options, not request-target
5064        // query params, so the origin-form target is just "/".
5065        assert_eq!(req.path, "/");
5066        assert!(req.body.is_empty(), "GET must not carry a body");
5067        assert!(
5068            req.content_length.is_none(),
5069            "suppressed request must not carry Content-Length"
5070        );
5071        assert!(
5072            req.transfer_encoding.is_none(),
5073            "suppressed request must not carry Transfer-Encoding"
5074        );
5075
5076        // The exchange body is consumed by the producer (std::mem::take).
5077        assert!(
5078            result.input.body.is_empty(),
5079            "exchange body must be consumed"
5080        );
5081    }
5082
5083    #[tokio::test]
5084    async fn test_head_with_body_suppressed_via_header() {
5085        use tower::ServiceExt;
5086
5087        let (url, _handle, captured) = start_capture_server().await;
5088        let ctx = test_producer_ctx();
5089
5090        let component = HttpComponent::with_config(HttpConfig::default());
5091        let endpoint_ctx = NoOpComponentContext;
5092        let endpoint = component
5093            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5094            .unwrap();
5095        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5096
5097        let mut exchange = Exchange::new(Message::default());
5098        exchange.input.set_header(
5099            "CamelHttpMethod",
5100            serde_json::Value::String("HEAD".to_string()),
5101        );
5102        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5103
5104        let result = producer.oneshot(exchange).await.unwrap();
5105        let status = result
5106            .input
5107            .header("CamelHttpResponseCode")
5108            .and_then(|v| v.as_u64())
5109            .unwrap();
5110        assert_eq!(status, 200);
5111
5112        let captured = captured.lock().unwrap();
5113        assert_eq!(captured.len(), 1);
5114        let req = &captured[0];
5115        assert_eq!(req.method, "HEAD");
5116        assert!(req.body.is_empty(), "HEAD must not carry a body");
5117    }
5118
5119    #[tokio::test]
5120    async fn test_delete_options_trace_with_body_suppressed() {
5121        use tower::ServiceExt;
5122
5123        let (url, _handle, captured) = start_capture_server().await;
5124        let ctx = test_producer_ctx();
5125        let component = HttpComponent::with_config(HttpConfig::default());
5126        let endpoint_ctx = NoOpComponentContext;
5127
5128        for method in ["DELETE", "OPTIONS", "TRACE"] {
5129            let endpoint = component
5130                .create_endpoint(
5131                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5132                    &endpoint_ctx,
5133                )
5134                .unwrap();
5135            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5136
5137            let mut exchange = Exchange::new(Message::default());
5138            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5139
5140            let result = producer.oneshot(exchange).await.unwrap();
5141            let status = result
5142                .input
5143                .header("CamelHttpResponseCode")
5144                .and_then(|v| v.as_u64())
5145                .unwrap();
5146            assert_eq!(status, 200, "method {method} should succeed");
5147        }
5148
5149        let captured = captured.lock().unwrap();
5150        assert_eq!(captured.len(), 3, "expected three captured requests");
5151        for method in ["DELETE", "OPTIONS", "TRACE"] {
5152            let req = captured
5153                .iter()
5154                .find(|r| r.method == method)
5155                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5156            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5157        }
5158    }
5159
5160    #[tokio::test]
5161    async fn test_post_put_patch_with_body_still_sent() {
5162        use tower::ServiceExt;
5163
5164        let (url, _handle, captured) = start_capture_server().await;
5165        let ctx = test_producer_ctx();
5166        let component = HttpComponent::with_config(HttpConfig::default());
5167        let endpoint_ctx = NoOpComponentContext;
5168
5169        for method in ["POST", "PUT", "PATCH"] {
5170            let endpoint = component
5171                .create_endpoint(
5172                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5173                    &endpoint_ctx,
5174                )
5175                .unwrap();
5176            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5177
5178            let payload = format!("body-for-{method}");
5179            let mut exchange = Exchange::new(Message::default());
5180            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5181
5182            let result = producer.oneshot(exchange).await.unwrap();
5183            let status = result
5184                .input
5185                .header("CamelHttpResponseCode")
5186                .and_then(|v| v.as_u64())
5187                .unwrap();
5188            assert_eq!(status, 200, "method {method} should succeed");
5189        }
5190
5191        let captured = captured.lock().unwrap();
5192        assert_eq!(captured.len(), 3, "expected three captured requests");
5193        for method in ["POST", "PUT", "PATCH"] {
5194            let req = captured
5195                .iter()
5196                .find(|r| r.method == method)
5197                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5198            let expected = format!("body-for-{method}");
5199            assert!(!req.body.is_empty(), "{method} must still carry its body");
5200            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5201        }
5202    }
5203
5204    /// A GET with a stream body must not attach the stream: the entity-enclosing
5205    /// gate drops the stream (mem::take) before the request is built, leaving
5206    /// the exchange body Empty instead of a partially-consumed Body::Stream.
5207    #[tokio::test]
5208    async fn test_stream_body_under_get_not_attached() {
5209        use tower::ServiceExt;
5210
5211        let (url, _handle, captured) = start_capture_server().await;
5212        let ctx = test_producer_ctx();
5213
5214        let component = HttpComponent::with_config(HttpConfig::default());
5215        let endpoint_ctx = NoOpComponentContext;
5216        let endpoint = component
5217            .create_endpoint(
5218                &format!("{url}?httpMethod=GET&allowInternal=true"),
5219                &endpoint_ctx,
5220            )
5221            .unwrap();
5222        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5223
5224        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5225            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
5226        let stream = Box::pin(futures::stream::iter(chunks));
5227        let mut exchange = Exchange::new(Message::default());
5228        exchange.input.body = Body::Stream(StreamBody {
5229            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5230            metadata: StreamMetadata::default(),
5231        });
5232
5233        let result = producer.oneshot(exchange).await.unwrap();
5234
5235        let status = result
5236            .input
5237            .header("CamelHttpResponseCode")
5238            .and_then(|v| v.as_u64())
5239            .unwrap();
5240        assert_eq!(status, 200);
5241
5242        let captured = captured.lock().unwrap();
5243        assert_eq!(captured.len(), 1, "expected exactly one captured request");
5244        assert!(
5245            captured[0].body.is_empty(),
5246            "GET must not carry a stream body"
5247        );
5248        assert!(
5249            captured[0].transfer_encoding.is_none(),
5250            "suppressed request must not carry Transfer-Encoding"
5251        );
5252        assert!(
5253            captured[0].content_length.is_none(),
5254            "suppressed request must not carry Content-Length"
5255        );
5256        assert!(
5257            result.input.body.is_empty(),
5258            "exchange body must be consumed to Empty, not left as a stream"
5259        );
5260    }
5261
5262    /// A suppressed body must never be replayed across 307/308 redirect hops:
5263    /// the gate empties `materialized_body` before the redirect loop runs, so
5264    /// neither the first hop nor the final hop carries the body.
5265    #[tokio::test]
5266    async fn test_redirect_hops_never_replay_suppressed_body() {
5267        use tower::ServiceExt;
5268
5269        let (url, _handle, captured) = start_redirect_capture_server().await;
5270        let ctx = test_producer_ctx();
5271
5272        let component =
5273            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5274        let endpoint_ctx = NoOpComponentContext;
5275
5276        for path in ["/hop307", "/hop308"] {
5277            let endpoint = component
5278                .create_endpoint(
5279                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
5280                    &endpoint_ctx,
5281                )
5282                .unwrap();
5283            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5284
5285            let mut exchange = Exchange::new(Message::default());
5286            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5287
5288            let result = producer.oneshot(exchange).await.unwrap();
5289            let status = result
5290                .input
5291                .header("CamelHttpResponseCode")
5292                .and_then(|v| v.as_u64())
5293                .unwrap();
5294            assert_eq!(
5295                status, 200,
5296                "redirect chain for {path} should end at /final"
5297            );
5298        }
5299
5300        // Two chains (307 and 308), each with two hops (redirect + final).
5301        let captured = captured.lock().unwrap();
5302        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
5303        for req in captured.iter() {
5304            assert!(
5305                req.body.is_empty(),
5306                "hop {} {} must not carry a body",
5307                req.method,
5308                req.path
5309            );
5310        }
5311    }
5312
5313    /// The warn! emitted on a suppressed body renders three distinguishable
5314    /// substrings in the log line (tracing-subscriber default field format):
5315    ///   - the message:       "dropping request body ..."
5316    ///   - `method = %method_str`            → `method=GET`
5317    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
5318    /// The closure matches all three so exactly one warn per suppressed
5319    /// request is required (the "HTTP request" debug! also carries
5320    /// `method=GET` and the same `correlation_id=`, but not the message).
5321    #[tracing_test::traced_test]
5322    #[tokio::test]
5323    async fn test_suppressed_body_logs_exactly_one_warn() {
5324        use tower::ServiceExt;
5325
5326        let (url, _handle, _captured) = start_capture_server().await;
5327        let ctx = test_producer_ctx();
5328
5329        let component = HttpComponent::with_config(HttpConfig::default());
5330        let endpoint_ctx = NoOpComponentContext;
5331        let endpoint = component
5332            .create_endpoint(
5333                &format!("{url}?httpMethod=GET&allowInternal=true"),
5334                &endpoint_ctx,
5335            )
5336            .unwrap();
5337        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5338
5339        let mut exchange = Exchange::new(Message::default());
5340        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5341        let correlation_id = exchange.correlation_id().to_string();
5342
5343        let result = producer.oneshot(exchange).await.unwrap();
5344        let status = result
5345            .input
5346            .header("CamelHttpResponseCode")
5347            .and_then(|v| v.as_u64())
5348            .unwrap();
5349        assert_eq!(status, 200);
5350
5351        logs_assert(|lines: &[&str]| {
5352            let hits = lines
5353                .iter()
5354                .filter(|l| {
5355                    l.contains("dropping request body")
5356                        && l.contains("method=GET")
5357                        && l.contains(&format!("correlation_id={correlation_id}"))
5358                })
5359                .count();
5360            match hits {
5361                1 => Ok(()),
5362                n => Err(format!("expected exactly one body-drop warn, found {n}")),
5363            }
5364        });
5365    }
5366
5367    #[tracing_test::traced_test]
5368    #[tokio::test]
5369    async fn test_empty_body_get_emits_no_warn() {
5370        use tower::ServiceExt;
5371
5372        let (url, _handle, _captured) = start_capture_server().await;
5373        let ctx = test_producer_ctx();
5374
5375        let component = HttpComponent::with_config(HttpConfig::default());
5376        let endpoint_ctx = NoOpComponentContext;
5377        let endpoint = component
5378            .create_endpoint(
5379                &format!("{url}?httpMethod=GET&allowInternal=true"),
5380                &endpoint_ctx,
5381            )
5382            .unwrap();
5383        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5384
5385        let exchange = Exchange::new(Message::default());
5386        let result = producer.oneshot(exchange).await.unwrap();
5387        let status = result
5388            .input
5389            .header("CamelHttpResponseCode")
5390            .and_then(|v| v.as_u64())
5391            .unwrap();
5392        assert_eq!(status, 200);
5393
5394        logs_assert(|lines: &[&str]| {
5395            let hits = lines
5396                .iter()
5397                .filter(|l| l.contains("dropping request body"))
5398                .count();
5399            match hits {
5400                0 => Ok(()),
5401                n => Err(format!("expected no body-drop warn, found {n}")),
5402            }
5403        });
5404    }
5405
5406    #[tokio::test]
5407    async fn test_follow_redirects_false_does_not_follow() {
5408        use tower::ServiceExt;
5409
5410        let (url, _handle) = start_redirect_server().await;
5411        let ctx = test_producer_ctx();
5412
5413        let component =
5414            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
5415        let endpoint_ctx = NoOpComponentContext;
5416        let endpoint = component
5417            .create_endpoint(
5418                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
5419                &endpoint_ctx,
5420            )
5421            .unwrap();
5422        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5423
5424        let exchange = Exchange::new(Message::default());
5425        let result = producer.oneshot(exchange).await.unwrap();
5426
5427        // Should get 302, NOT follow redirect to 200
5428        let status = result
5429            .input
5430            .header("CamelHttpResponseCode")
5431            .and_then(|v| v.as_u64())
5432            .unwrap();
5433        assert_eq!(
5434            status, 302,
5435            "Should NOT follow redirect when followRedirects=false"
5436        );
5437    }
5438
5439    #[tokio::test]
5440    async fn test_follow_redirects_true_follows_redirect() {
5441        use tower::ServiceExt;
5442
5443        let (url, _handle) = start_redirect_server().await;
5444        let ctx = test_producer_ctx();
5445
5446        let component =
5447            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5448        let endpoint_ctx = NoOpComponentContext;
5449        let endpoint = component
5450            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5451            .unwrap();
5452        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5453
5454        let exchange = Exchange::new(Message::default());
5455        let result = producer.oneshot(exchange).await.unwrap();
5456
5457        // Should follow redirect and get 200
5458        let status = result
5459            .input
5460            .header("CamelHttpResponseCode")
5461            .and_then(|v| v.as_u64())
5462            .unwrap();
5463        assert_eq!(
5464            status, 200,
5465            "Should follow redirect when followRedirects=true"
5466        );
5467    }
5468
5469    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
5470    /// This verifies the manual redirect loop executes correctly.
5471    #[tokio::test]
5472    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
5473        use tower::ServiceExt;
5474
5475        // Use the existing redirect server which redirects to /final on the same server
5476        let (url, _handle) = start_redirect_server().await;
5477        let ctx = test_producer_ctx();
5478
5479        let component =
5480            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5481        let endpoint_ctx = NoOpComponentContext;
5482        let endpoint = component
5483            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5484            .unwrap();
5485        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5486
5487        let exchange = Exchange::new(Message::default());
5488        let result = producer.oneshot(exchange).await;
5489
5490        // With allowInternal=true, the redirect should succeed
5491        assert!(
5492            result.is_ok(),
5493            "Redirect should succeed with allowInternal=true, got: {:?}",
5494            result
5495        );
5496        let exchange = result.unwrap();
5497        let status = exchange
5498            .input
5499            .header("CamelHttpResponseCode")
5500            .and_then(|v| v.as_u64())
5501            .unwrap();
5502        assert_eq!(status, 200, "Should follow redirect to /final");
5503    }
5504
5505    /// With allowInternal=true, redirects to private IPs should be followed.
5506    #[tokio::test]
5507    async fn test_redirect_to_private_ip_allowed_when_configured() {
5508        use tower::ServiceExt;
5509
5510        // Start a server that redirects to /final on the same server (127.0.0.1)
5511        let (url, _handle) = start_redirect_server().await;
5512        let ctx = test_producer_ctx();
5513
5514        let component =
5515            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5516        let endpoint_ctx = NoOpComponentContext;
5517        let endpoint = component
5518            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5519            .unwrap();
5520        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5521
5522        let exchange = Exchange::new(Message::default());
5523        let result = producer.oneshot(exchange).await.unwrap();
5524
5525        let status = result
5526            .input
5527            .header("CamelHttpResponseCode")
5528            .and_then(|v| v.as_u64())
5529            .unwrap();
5530        assert_eq!(
5531            status, 200,
5532            "Should follow redirect to private IP when allowInternal=true"
5533        );
5534    }
5535
5536    /// Integration test: with allowInternal=false (default), a redirect to a
5537    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
5538    #[tokio::test]
5539    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
5540        use tower::ServiceExt;
5541
5542        // Server that redirects to the AWS metadata endpoint (link-local private IP)
5543        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5544        let addr = listener.local_addr().unwrap();
5545        let url = format!("http://127.0.0.1:{}", addr.port());
5546
5547        let handle = tokio::spawn(async move {
5548            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5549            loop {
5550                if let Ok((mut stream, _)) = listener.accept().await {
5551                    tokio::spawn(async move {
5552                        let mut buf = vec![0u8; 4096];
5553                        let _ = stream.read(&mut buf).await;
5554                        // Always redirect to the metadata endpoint
5555                        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";
5556                        let _ = stream.write_all(response.as_bytes()).await;
5557                    });
5558                }
5559            }
5560        });
5561
5562        let ctx = test_producer_ctx();
5563        let component =
5564            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5565        let endpoint_ctx = NoOpComponentContext;
5566        // allowInternal=false is the default — do NOT set it
5567        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
5568        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5569
5570        let exchange = Exchange::new(Message::default());
5571        let result = producer.oneshot(exchange).await;
5572
5573        // Must be an error — SSRF guard blocks the redirect target
5574        assert!(
5575            result.is_err(),
5576            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
5577        );
5578        let err = result.unwrap_err().to_string();
5579        assert!(
5580            err.contains("blocked IP")
5581                || err.contains("private IP")
5582                || err.contains("SSRF")
5583                || err.contains("not allowed"),
5584            "Error should mention SSRF/IP blocking, got: {err}"
5585        );
5586
5587        handle.abort();
5588    }
5589
5590    /// Integration test: exceeding maxRedirects produces a clear error.
5591    #[tokio::test]
5592    async fn test_too_many_redirects_returns_error() {
5593        use tower::ServiceExt;
5594
5595        // Server that always redirects to itself (infinite loop)
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 _ = stream.read(&mut buf).await;
5607                        // Always redirect to /loop
5608                        // Connection: close stops the client pooling the
5609                        // connection the server drops right after this
5610                        // response (pooled-race, rc-u3aw).
5611                        let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5612                        let _ = stream.write_all(response.as_bytes()).await;
5613                    });
5614                }
5615            }
5616        });
5617
5618        let ctx = test_producer_ctx();
5619        let component =
5620            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5621        let endpoint_ctx = NoOpComponentContext;
5622        let endpoint = component
5623            .create_endpoint(
5624                &format!("{url}?allowInternal=true&maxRedirects=2"),
5625                &endpoint_ctx,
5626            )
5627            .unwrap();
5628        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5629
5630        let exchange = Exchange::new(Message::default());
5631        let result = producer.oneshot(exchange).await;
5632
5633        // With the fix, exceeding max redirects returns the redirect response
5634        // as-is instead of erroring. The 302 redirect response is returned
5635        // after followRedirects exhausts the allowed redirect count (2).
5636        // Disable throwExceptionOnFailure to inspect the raw response status.
5637        //
5638        // Old behavior: Err("Too many redirects (max 2)")
5639        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
5640        match result {
5641            Err(e) => {
5642                // If throw_exception_on_failure is on, we get HttpOperationFailed
5643                let msg = e.to_string();
5644                assert!(
5645                    msg.contains("HTTP operation failed") || msg.contains("302"),
5646                    "expected redirect-after-exhaustion error, got: {msg}"
5647                );
5648            }
5649            Ok(ex) => {
5650                let response_code = ex
5651                    .input
5652                    .header("CamelHttpResponseCode")
5653                    .and_then(|v| v.as_u64());
5654                assert_eq!(
5655                    response_code,
5656                    Some(302),
5657                    "expected 302 after exhausting redirects"
5658                );
5659            }
5660        }
5661
5662        handle.abort();
5663    }
5664
5665    #[tokio::test]
5666    async fn test_query_params_forwarded_to_http_request() {
5667        use tower::ServiceExt;
5668
5669        let (url, _handle) = start_test_server().await;
5670        let ctx = test_producer_ctx();
5671
5672        let component = HttpComponent::new();
5673        let endpoint_ctx = NoOpComponentContext;
5674        // apiKey is NOT a Camel option, should be forwarded as query param
5675        let endpoint = component
5676            .create_endpoint(
5677                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
5678                &endpoint_ctx,
5679            )
5680            .unwrap();
5681        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5682
5683        let exchange = Exchange::new(Message::default());
5684        let result = producer.oneshot(exchange).await.unwrap();
5685
5686        // The test server returns the request info in response
5687        // We just verify it succeeds (the query param was sent)
5688        let status = result
5689            .input
5690            .header("CamelHttpResponseCode")
5691            .and_then(|v| v.as_u64())
5692            .unwrap();
5693        assert_eq!(status, 200);
5694    }
5695
5696    #[test]
5697    fn test_non_camel_query_params_are_forwarded() {
5698        // Authored pairs ride raw_query (the sole carrier); query_params is
5699        // programmatic-only (http-query-wire-fidelity).
5700        let config = HttpEndpointConfig::from_uri(
5701            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
5702        )
5703        .unwrap();
5704
5705        // apiKey and token are NOT camel-http options: the authored bytes
5706        // (including the interleaved httpMethod) ride raw_query verbatim.
5707        assert_eq!(
5708            config.raw_query.as_deref(),
5709            Some("apiKey=secret123&httpMethod=GET&token=abc456")
5710        );
5711        assert!(config.query_params.is_empty());
5712    }
5713
5714    #[test]
5715    fn test_authored_query_bytes_survive_resolve_url() {
5716        let config =
5717            HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
5718        let exchange = Exchange::new(Message::default());
5719
5720        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
5721
5722        // Authored bytes ride verbatim: `%20` stays `%20` (never re-encoded
5723        // to `+` or double-encoded) and `+` stays `+`.
5724        assert!(url.contains("q=hello%20world"), "url was: {url}");
5725        assert!(url.contains("tag=a+b"), "url was: {url}");
5726    }
5727
5728    // -----------------------------------------------------------------------
5729    // Timeout tests (HTTP-004)
5730    // -----------------------------------------------------------------------
5731
5732    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
5733        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5734        let addr = listener.local_addr().unwrap();
5735        let url = format!("http://127.0.0.1:{}", addr.port());
5736
5737        let handle = tokio::spawn(async move {
5738            loop {
5739                if let Ok((mut stream, _)) = listener.accept().await {
5740                    let delay = delay_ms;
5741                    tokio::spawn(async move {
5742                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5743                        let mut buf = vec![0u8; 4096];
5744                        let _ = stream.read(&mut buf).await;
5745                        // Send headers immediately (no Content-Length → chunked)
5746                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
5747                        let _ = stream.write_all(headers.as_bytes()).await;
5748                        // Delay before sending body chunk
5749                        tokio::time::sleep(Duration::from_millis(delay)).await;
5750                        let body = r#"{"status":"slow"}"#;
5751                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
5752                        let _ = stream.write_all(chunk.as_bytes()).await;
5753                    });
5754                }
5755            }
5756        });
5757
5758        (url, handle)
5759    }
5760
5761    #[tokio::test]
5762    async fn test_http_producer_timeout() {
5763        use tower::ServiceExt;
5764
5765        // Server delays 500ms, client timeout is 100ms → should timeout
5766        let (url, _handle) = start_slow_server(500).await;
5767        let ctx = test_producer_ctx();
5768
5769        let component = HttpComponent::with_config(
5770            HttpConfig::default()
5771                .with_read_timeout_ms(100)
5772                .with_response_timeout_ms(30_000), // generous response timeout
5773        );
5774        let endpoint_ctx = NoOpComponentContext;
5775        let endpoint = component
5776            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
5777            .unwrap();
5778        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5779
5780        let exchange = Exchange::new(Message::default());
5781        let result = producer.oneshot(exchange).await;
5782
5783        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
5784        let err = result.unwrap_err().to_string();
5785        assert!(
5786            err.contains("Read timeout") || err.contains("timeout"),
5787            "Error should mention timeout, got: {}",
5788            err
5789        );
5790    }
5791
5792    #[tokio::test]
5793    async fn test_http_producer_no_timeout_when_fast() {
5794        use tower::ServiceExt;
5795
5796        let (url, _handle) = start_test_server().await;
5797        let ctx = test_producer_ctx();
5798
5799        let component =
5800            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
5801        let endpoint_ctx = NoOpComponentContext;
5802        let endpoint = component
5803            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5804            .unwrap();
5805        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5806
5807        let exchange = Exchange::new(Message::default());
5808        let result = producer.oneshot(exchange).await.unwrap();
5809
5810        let status = result
5811            .input
5812            .header("CamelHttpResponseCode")
5813            .and_then(|v| v.as_u64())
5814            .unwrap();
5815        assert_eq!(status, 200);
5816    }
5817
5818    // -----------------------------------------------------------------------
5819    // SSRF Protection tests
5820    // -----------------------------------------------------------------------
5821
5822    #[tokio::test]
5823    async fn test_http_producer_blocks_metadata_endpoint() {
5824        use tower::ServiceExt;
5825
5826        let ctx = test_producer_ctx();
5827        let component = HttpComponent::new();
5828        let endpoint_ctx = NoOpComponentContext;
5829        let endpoint = component
5830            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
5831            .unwrap();
5832        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5833
5834        let mut exchange = Exchange::new(Message::default());
5835        exchange.input.set_header(
5836            "CamelHttpUri",
5837            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
5838        );
5839
5840        let result = producer.oneshot(exchange).await;
5841        assert!(result.is_err(), "Should block AWS metadata endpoint");
5842
5843        let err = result.unwrap_err();
5844        assert!(
5845            err.to_string().contains("Private IP"),
5846            "Error should mention private IP blocking, got: {}",
5847            err
5848        );
5849    }
5850
5851    #[test]
5852    fn test_ssrf_config_defaults() {
5853        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
5854        assert!(
5855            !config.allow_internal,
5856            "Private IPs should be blocked by default"
5857        );
5858        assert!(
5859            config.blocked_hosts.is_empty(),
5860            "Blocked hosts should be empty by default"
5861        );
5862    }
5863
5864    #[test]
5865    fn test_ssrf_config_allow_internal() {
5866        let config =
5867            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
5868        assert!(
5869            config.allow_internal,
5870            "Private IPs should be allowed when explicitly set"
5871        );
5872    }
5873
5874    #[test]
5875    fn test_ssrf_config_blocked_hosts() {
5876        let config = HttpEndpointConfig::from_uri(
5877            "http://example.com/api?blockedHosts=evil.com,malware.net",
5878        )
5879        .unwrap();
5880        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
5881    }
5882
5883    #[tokio::test]
5884    async fn test_http_producer_blocks_localhost() {
5885        use tower::ServiceExt;
5886
5887        let ctx = test_producer_ctx();
5888        let component = HttpComponent::new();
5889        let endpoint_ctx = NoOpComponentContext;
5890        let endpoint = component
5891            .create_endpoint("http://example.com/api", &endpoint_ctx)
5892            .unwrap();
5893        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5894
5895        let mut exchange = Exchange::new(Message::default());
5896        exchange.input.set_header(
5897            "CamelHttpUri",
5898            serde_json::Value::String("http://localhost:8080/internal".to_string()),
5899        );
5900
5901        let result = producer.oneshot(exchange).await;
5902        assert!(result.is_err(), "Should block localhost");
5903    }
5904
5905    #[tokio::test]
5906    async fn test_http_producer_blocks_loopback_ip() {
5907        use tower::ServiceExt;
5908
5909        let ctx = test_producer_ctx();
5910        let component = HttpComponent::new();
5911        let endpoint_ctx = NoOpComponentContext;
5912        let endpoint = component
5913            .create_endpoint("http://example.com/api", &endpoint_ctx)
5914            .unwrap();
5915        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5916
5917        let mut exchange = Exchange::new(Message::default());
5918        exchange.input.set_header(
5919            "CamelHttpUri",
5920            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
5921        );
5922
5923        let result = producer.oneshot(exchange).await;
5924        assert!(result.is_err(), "Should block loopback IP");
5925    }
5926
5927    #[tokio::test]
5928    async fn test_http_producer_allows_private_ip_when_enabled() {
5929        use tower::ServiceExt;
5930
5931        let ctx = test_producer_ctx();
5932        let component = HttpComponent::new();
5933        let endpoint_ctx = NoOpComponentContext;
5934        // With allowInternal=true, the validation should pass
5935        // (actual connection will fail, but that's expected)
5936        let endpoint = component
5937            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
5938            .unwrap();
5939        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5940
5941        let exchange = Exchange::new(Message::default());
5942
5943        // The request will fail because we can't connect, but it should NOT fail
5944        // due to SSRF protection
5945        let result = producer.oneshot(exchange).await;
5946        // We expect connection error, not SSRF error
5947        if let Err(ref e) = result {
5948            let err_str = e.to_string();
5949            assert!(
5950                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
5951                "Should not be SSRF error, got: {}",
5952                err_str
5953            );
5954        }
5955    }
5956
5957    // -----------------------------------------------------------------------
5958    // HttpServerConfig tests
5959    // -----------------------------------------------------------------------
5960
5961    #[test]
5962    fn test_http_server_config_parse() {
5963        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
5964        assert_eq!(cfg.host, "0.0.0.0");
5965        assert_eq!(cfg.port, 8080);
5966        assert_eq!(cfg.path, "/orders");
5967        assert_eq!(cfg.max_inflight_requests, 1024);
5968    }
5969
5970    #[test]
5971    fn test_http_server_config_scheme() {
5972        // UriConfig trait method returns "http" as primary scheme
5973        assert_eq!(HttpServerConfig::scheme(), "http");
5974    }
5975
5976    #[test]
5977    fn test_http_server_config_from_components() {
5978        // Test from_components directly (trait method)
5979        let components = camel_component_api::UriComponents {
5980            scheme: "https".to_string(),
5981            path: "//0.0.0.0:8443/api".to_string(),
5982            params: std::collections::HashMap::from([
5983                ("maxRequestBody".to_string(), "5242880".to_string()),
5984                ("maxInflightRequests".to_string(), "7".to_string()),
5985            ]),
5986            raw_query: None,
5987        };
5988        let cfg = HttpServerConfig::from_components(components).unwrap();
5989        assert_eq!(cfg.host, "0.0.0.0");
5990        assert_eq!(cfg.port, 8443);
5991        assert_eq!(cfg.path, "/api");
5992        assert_eq!(cfg.max_request_body, 5242880);
5993        assert_eq!(cfg.max_inflight_requests, 7);
5994    }
5995
5996    #[test]
5997    fn test_http_server_config_default_path() {
5998        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
5999        assert_eq!(cfg.path, "/");
6000    }
6001
6002    #[test]
6003    fn test_http_server_config_wrong_scheme() {
6004        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6005    }
6006
6007    #[test]
6008    fn test_http_server_config_invalid_port() {
6009        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6010    }
6011
6012    #[test]
6013    fn test_http_server_config_default_port_by_scheme() {
6014        // HTTP without explicit port should default to 80
6015        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
6016        assert_eq!(cfg_http.port, 80);
6017
6018        // HTTPS without explicit port should default to 443
6019        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
6020        assert_eq!(cfg_https.port, 443);
6021    }
6022
6023    #[test]
6024    fn test_request_envelope_and_reply_are_send() {
6025        fn assert_send<T: Send>() {}
6026        assert_send::<RequestEnvelope>();
6027        assert_send::<HttpReply>();
6028    }
6029
6030    // -----------------------------------------------------------------------
6031    // ServerRegistry tests
6032    // -----------------------------------------------------------------------
6033
6034    #[test]
6035    fn test_server_registry_global_is_singleton() {
6036        let r1 = ServerRegistry::global();
6037        let r2 = ServerRegistry::global();
6038        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
6039    }
6040
6041    #[allow(clippy::await_holding_lock)]
6042    #[tokio::test]
6043    async fn test_concurrent_get_or_spawn_returns_same_registry() {
6044        let _guard = lock_registry_test_mutex();
6045        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6046        let port = listener.local_addr().unwrap().port();
6047        drop(listener);
6048
6049        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
6050            Arc::new(std::sync::Mutex::new(Vec::new()));
6051
6052        let mut handles = Vec::new();
6053        for _ in 0..4 {
6054            let results = results.clone();
6055            handles.push(tokio::spawn(async move {
6056                let registry = ServerRegistry::global()
6057                    .get_or_spawn(
6058                        "127.0.0.1",
6059                        port,
6060                        2 * 1024 * 1024,
6061                        10 * 1024 * 1024,
6062                        1024,
6063                        test_rt(),
6064                        "test-route".into(),
6065                        None,
6066                    )
6067                    .await
6068                    .unwrap();
6069                results.lock().unwrap().push(registry);
6070            }));
6071        }
6072
6073        for h in handles {
6074            h.await.unwrap();
6075        }
6076
6077        let registries = results.lock().unwrap();
6078        assert_eq!(registries.len(), 4);
6079        for i in 1..registries.len() {
6080            assert!(
6081                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
6082                "all concurrent callers should get same route registry"
6083            );
6084        }
6085    }
6086
6087    #[test]
6088    fn test_server_registry_distinguishes_host_and_port() {
6089        let _guard = lock_registry_test_mutex();
6090        let rt = tokio::runtime::Runtime::new().expect("runtime");
6091        rt.block_on(async {
6092            let registry = ServerRegistry::global();
6093            // Use two distinct host values with same configured port key.
6094            // Port 0 is acceptable here because the registry key uses the configured
6095            // tuple, not the OS-assigned ephemeral port.
6096            let d1 = registry
6097                .get_or_spawn(
6098                    "127.0.0.1",
6099                    0,
6100                    1024 * 1024,
6101                    10 * 1024 * 1024,
6102                    1024,
6103                    test_rt(),
6104                    "test-route-1".into(),
6105                    None,
6106                )
6107                .await;
6108            let d2 = registry
6109                .get_or_spawn(
6110                    "0.0.0.0",
6111                    0,
6112                    1024 * 1024,
6113                    10 * 1024 * 1024,
6114                    1024,
6115                    test_rt(),
6116                    "test-route-2".into(),
6117                    None,
6118                )
6119                .await;
6120            assert!(d1.is_ok());
6121            assert!(d2.is_ok());
6122            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
6123        });
6124    }
6125
6126    #[allow(clippy::await_holding_lock)]
6127    #[tokio::test]
6128    async fn test_shared_server_max_request_body_policy_is_deterministic() {
6129        let _guard = lock_registry_test_mutex();
6130        let registry = ServerRegistry::global();
6131        // First registration: maxRequestBody = 1 MB
6132        let d1 = registry
6133            .get_or_spawn(
6134                "127.0.0.1",
6135                9991,
6136                1024 * 1024,
6137                10 * 1024 * 1024,
6138                1024,
6139                test_rt(),
6140                "test-route".into(),
6141                None,
6142            )
6143            .await;
6144        assert!(d1.is_ok());
6145
6146        // Second registration on same (host,port): maxRequestBody = 2 MB
6147        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
6148        let d2 = registry
6149            .get_or_spawn(
6150                "127.0.0.1",
6151                9991,
6152                2 * 1024 * 1024,
6153                10 * 1024 * 1024,
6154                1024,
6155                test_rt(),
6156                "test-route-2".into(),
6157                None,
6158            )
6159            .await;
6160        assert!(d2.is_err());
6161        let err = d2.unwrap_err();
6162        assert!(
6163            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6164            "Expected incompatible maxRequestBody error, got: {}",
6165            err
6166        );
6167    }
6168
6169    #[test]
6170    fn test_server_registry_reset_clears_entries() {
6171        let _guard = lock_registry_test_mutex();
6172        let rt = tokio::runtime::Runtime::new().expect("runtime");
6173        rt.block_on(async {
6174            // Register something on a unique port
6175            let d1 = ServerRegistry::global()
6176                .get_or_spawn(
6177                    "127.0.0.1",
6178                    9992,
6179                    1024 * 1024,
6180                    10 * 1024 * 1024,
6181                    1024,
6182                    test_rt(),
6183                    "test-route".into(),
6184                    None,
6185                )
6186                .await;
6187            assert!(d1.is_ok());
6188
6189            // Verify entry exists
6190            let guard = ServerRegistry::global().inner.lock().expect("lock");
6191            assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
6192            drop(guard);
6193
6194            // Reset
6195            ServerRegistry::reset();
6196
6197            // Verify cleared
6198            let guard = ServerRegistry::global().inner.lock().expect("lock");
6199            assert!(
6200                guard.entries.is_empty(),
6201                "registry should be empty after reset, has {} entries",
6202                guard.entries.len()
6203            );
6204        });
6205    }
6206
6207    #[allow(clippy::await_holding_lock)]
6208    #[tokio::test]
6209    async fn registry_rejects_tls_on_plain_port() {
6210        // httpflake: this reset previously ran WITHOUT the registry test
6211        // mutex, so it could wipe another test's freshly staged entry
6212        // mid-window (traced 2026-09-14) — spec law: every reset caller
6213        // holds REGISTRY_TEST_MUTEX.
6214        let _guard = lock_registry_test_mutex();
6215        ServerRegistry::reset();
6216        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6217
6218        // First route: plain HTTP
6219        let _r1 = ServerRegistry::global()
6220            .get_or_spawn(
6221                "127.0.0.1",
6222                0,
6223                1024,
6224                1024,
6225                16,
6226                Arc::clone(&rt),
6227                "route-1".into(),
6228                None, // plain
6229            )
6230            .await;
6231
6232        // Second route: TLS on same port → must fail
6233        let result = ServerRegistry::global()
6234            .get_or_spawn(
6235                "127.0.0.1",
6236                0,
6237                1024,
6238                1024,
6239                16,
6240                Arc::clone(&rt),
6241                "route-2".into(),
6242                Some(crate::config::ServerTlsConfig {
6243                    cert_path: "/x.pem".into(),
6244                    key_path: "/y.pem".into(),
6245                }),
6246            )
6247            .await;
6248        assert!(result.is_err(), "must reject TLS on plain port");
6249    }
6250
6251    // -----------------------------------------------------------------------
6252    // D-L10: HTTP monitor_axum_task refcounted shutdown
6253    // -----------------------------------------------------------------------
6254
6255    #[allow(clippy::await_holding_lock)]
6256    #[tokio::test]
6257    async fn test_unregister_last_http_route_keeps_server_alive() {
6258        let _guard = lock_registry_test_mutex();
6259        ServerRegistry::reset();
6260        let registry = ServerRegistry::global();
6261
6262        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6263        let port = listener.local_addr().unwrap().port();
6264        drop(listener); // Release — ServerRegistry will rebind
6265        let rt = test_rt();
6266
6267        // Register 2 routes on the same (host, port) — OnceCell returns the
6268        // same ServerHandle.
6269        let _r1 = registry
6270            .get_or_spawn(
6271                "127.0.0.1",
6272                port,
6273                1024 * 1024,
6274                10 * 1024 * 1024,
6275                16,
6276                rt.clone(),
6277                "test-route-1".into(),
6278                None,
6279            )
6280            .await
6281            .unwrap();
6282        let _r2 = registry
6283            .get_or_spawn(
6284                "127.0.0.1",
6285                port,
6286                1024 * 1024,
6287                10 * 1024 * 1024,
6288                16,
6289                rt,
6290                "test-route-2".into(),
6291                None,
6292            )
6293            .await
6294            .unwrap();
6295
6296        let key = ("127.0.0.1".to_string(), port);
6297        let cell = {
6298            let guard = registry.inner.lock().expect("lock");
6299            guard.entries.get(&key).expect("entry should exist").clone()
6300        };
6301
6302        // Unregister first route -> monitor still alive (count = 1).
6303        registry.unregister("127.0.0.1", port).await;
6304        {
6305            let handle = cell
6306                .get()
6307                .expect("handle should still exist after first unregister");
6308            assert!(
6309                !handle.monitor_task.is_finished(),
6310                "monitor task should still be alive after first unregister"
6311            );
6312        }
6313
6314        // Unregister second route -> server stays alive (process-lifetime).
6315        registry.unregister("127.0.0.1", port).await;
6316        tokio::time::sleep(Duration::from_millis(20)).await;
6317        {
6318            let handle = cell
6319                .get()
6320                .expect("handle should still exist after last unregister");
6321            assert!(
6322                !handle.monitor_task.is_finished(),
6323                "monitor task should still be alive — server is process-lifetime"
6324            );
6325        }
6326
6327        // Entry stays in registry for potential restart.
6328        {
6329            let guard = registry.inner.lock().expect("lock");
6330            assert!(
6331                guard.entries.contains_key(&key),
6332                "entry should remain in registry — server kept alive for restart"
6333            );
6334        }
6335    }
6336
6337    // -----------------------------------------------------------------------
6338    // Staged listeners (itest-bound-ports Task 1)
6339    // -----------------------------------------------------------------------
6340
6341    /// CLONE-FIXTURE: bind a std listener on `127.0.0.1:0`, retain a blocking
6342    /// std clone (`probe`) so the port stays reserved, and hand the original
6343    /// socket to tokio as a non-blocking listener. `tokio::net::TcpListener`
6344    /// has no `try_clone`, so clones come from the std handle.
6345    async fn clone_fixture_listener() -> (
6346        tokio::net::TcpListener,
6347        std::net::TcpListener,
6348        std::net::SocketAddr,
6349    ) {
6350        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
6351        let probe = l.try_clone().expect("clone probe");
6352        l.set_nonblocking(true).expect("set_nonblocking");
6353        let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
6354        let addr = listener.local_addr().expect("local_addr");
6355        (listener, probe, addr)
6356    }
6357
6358    /// Default-limit constants the existing registry tests in this file use.
6359    fn staged_limits() -> (usize, usize, usize) {
6360        (1024 * 1024, 10 * 1024 * 1024, 1024)
6361    }
6362
6363    #[allow(clippy::await_holding_lock)]
6364    #[tokio::test]
6365    async fn staged_listener_first_spawn_serves_without_second_bind() {
6366        let _guard = lock_registry_test_mutex();
6367        ServerRegistry::reset();
6368        let registry = ServerRegistry::global();
6369        let (listener, _probe, addr) = clone_fixture_listener().await;
6370        let port = addr.port();
6371        registry
6372            .stage_listener(listener)
6373            .await
6374            .expect("stage listener");
6375
6376        let (max_req, max_res, max_inflight) = staged_limits();
6377        let routes = registry
6378            .get_or_spawn(
6379                "127.0.0.1",
6380                port,
6381                max_req,
6382                max_res,
6383                max_inflight,
6384                test_rt(),
6385                "staged-first-spawn".into(),
6386                None,
6387            )
6388            .await
6389            .expect("spawn from staged listener must succeed");
6390
6391        assert_eq!(
6392            registry.bound_addr("127.0.0.1", port),
6393            Some(addr),
6394            "served socket must be the staged listener's addr"
6395        );
6396        // The probe clone shares the socket, so service is proven by an HTTP
6397        // response, not by accepting on the probe.
6398        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
6399            .await
6400            .expect("http request against staged listener must connect");
6401        assert!(
6402            resp.status().as_u16() >= 200,
6403            "any status proves the staged socket serves"
6404        );
6405        drop(routes);
6406    }
6407
6408    #[allow(clippy::await_holding_lock)]
6409    #[tokio::test]
6410    async fn staged_entry_reused_by_second_caller() {
6411        let _guard = lock_registry_test_mutex();
6412        ServerRegistry::reset();
6413        let registry = ServerRegistry::global();
6414        let (listener, _probe, addr) = clone_fixture_listener().await;
6415        let port = addr.port();
6416        registry
6417            .stage_listener(listener)
6418            .await
6419            .expect("stage listener");
6420
6421        let (max_req, max_res, max_inflight) = staged_limits();
6422        let first = registry
6423            .get_or_spawn(
6424                "127.0.0.1",
6425                port,
6426                max_req,
6427                max_res,
6428                max_inflight,
6429                test_rt(),
6430                "staged-reuse-1".into(),
6431                None,
6432            )
6433            .await
6434            .expect("first spawn from staged listener");
6435        let second = registry
6436            .get_or_spawn(
6437                "127.0.0.1",
6438                port,
6439                max_req,
6440                max_res,
6441                max_inflight,
6442                test_rt(),
6443                "staged-reuse-2".into(),
6444                None,
6445            )
6446            .await
6447            .expect("second caller must reuse the entry");
6448        assert_eq!(
6449            registry.bound_addr("127.0.0.1", port),
6450            Some(addr),
6451            "entry reused — bound addr unchanged, no second bind"
6452        );
6453        drop(first);
6454        drop(second);
6455    }
6456
6457    #[allow(clippy::await_holding_lock)]
6458    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6459    async fn staged_race_two_callers_single_resolver() {
6460        let _guard = lock_registry_test_mutex();
6461        ServerRegistry::reset();
6462        let registry = ServerRegistry::global();
6463        let (listener, _probe, addr) = clone_fixture_listener().await;
6464        let port = addr.port();
6465        registry
6466            .stage_listener(listener)
6467            .await
6468            .expect("stage listener");
6469
6470        // Two racing callers for the exact staged key: the staged listener
6471        // must be consumed by the single cell-init winner and served to
6472        // both — never leave the winner binding a port the loser still
6473        // holds (EADDRINUSE).
6474        let (max_req, max_res, max_inflight) = staged_limits();
6475        let (first, second) = tokio::join!(
6476            registry.get_or_spawn(
6477                "127.0.0.1",
6478                port,
6479                max_req,
6480                max_res,
6481                max_inflight,
6482                test_rt(),
6483                "staged-race-1".into(),
6484                None,
6485            ),
6486            registry.get_or_spawn(
6487                "127.0.0.1",
6488                port,
6489                max_req,
6490                max_res,
6491                max_inflight,
6492                test_rt(),
6493                "staged-race-2".into(),
6494                None,
6495            ),
6496        );
6497        let first = first.expect("first racing caller must succeed");
6498        let second = second.expect("second racing caller must succeed");
6499        assert_eq!(
6500            registry.bound_addr("127.0.0.1", port),
6501            Some(addr),
6502            "single entry must be served from the staged socket — no EADDRINUSE path"
6503        );
6504        drop(first);
6505        drop(second);
6506    }
6507
6508    #[allow(clippy::await_holding_lock)]
6509    #[tokio::test]
6510    async fn unstaged_spawn_binds_legacy() {
6511        let _guard = lock_registry_test_mutex();
6512        ServerRegistry::reset();
6513        let registry = ServerRegistry::global();
6514        // Fresh port P2: reserve then release — the legacy path rebinds.
6515        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
6516        let port = probe.local_addr().expect("local addr").port();
6517        drop(probe);
6518
6519        let (max_req, max_res, max_inflight) = staged_limits();
6520        registry
6521            .get_or_spawn(
6522                "127.0.0.1",
6523                port,
6524                max_req,
6525                max_res,
6526                max_inflight,
6527                test_rt(),
6528                "legacy-bind".into(),
6529                None,
6530            )
6531            .await
6532            .expect("legacy bind spawn");
6533        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
6534            .await
6535            .expect("connect to freshly bound port must succeed");
6536        assert!(resp.status().as_u16() >= 200);
6537        assert_eq!(
6538            registry.bound_addr("127.0.0.1", port),
6539            Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
6540            "bound addr must be the legacy bound (host, port)"
6541        );
6542    }
6543
6544    #[allow(clippy::await_holding_lock)]
6545    #[tokio::test]
6546    async fn wrong_host_staged_port_fails_deterministically() {
6547        let _guard = lock_registry_test_mutex();
6548        ServerRegistry::reset();
6549        let registry = ServerRegistry::global();
6550        let (listener, _probe, addr) = clone_fixture_listener().await;
6551        let port = addr.port();
6552        registry
6553            .stage_listener(listener)
6554            .await
6555            .expect("stage listener under 127.0.0.1");
6556
6557        let (max_req, max_res, max_inflight) = staged_limits();
6558        let err = registry
6559            .get_or_spawn(
6560                "localhost",
6561                port,
6562                max_req,
6563                max_res,
6564                max_inflight,
6565                test_rt(),
6566                "conflict-probe".into(),
6567                None,
6568            )
6569            .await
6570            .expect_err("wrong host on staged port must fail deterministically");
6571        assert!(
6572            err.to_string().contains("staged listener conflict on port"),
6573            "unexpected error: {err}"
6574        );
6575
6576        // Slot untouched by the failed call: the correct host now consumes it.
6577        registry
6578            .get_or_spawn(
6579                "127.0.0.1",
6580                port,
6581                max_req,
6582                max_res,
6583                max_inflight,
6584                test_rt(),
6585                "conflict-after".into(),
6586                None,
6587            )
6588            .await
6589            .expect("correct host must serve the staged listener");
6590        assert_eq!(
6591            registry.bound_addr("127.0.0.1", port),
6592            Some(addr),
6593            "staged slot must be untouched by the conflicting call"
6594        );
6595    }
6596
6597    #[allow(clippy::await_holding_lock)]
6598    #[tokio::test]
6599    async fn duplicate_stage_same_key_rejected() {
6600        let _guard = lock_registry_test_mutex();
6601        ServerRegistry::reset();
6602        let registry = ServerRegistry::global();
6603        let (listener, probe, addr) = clone_fixture_listener().await;
6604        registry
6605            .stage_listener(listener)
6606            .await
6607            .expect("stage listener A");
6608
6609        // Second tokio handle to the SAME socket: clone the std probe handle.
6610        let dup = probe.try_clone().expect("clone2");
6611        dup.set_nonblocking(true).expect("set_nonblocking2");
6612        let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
6613
6614        let err = registry
6615            .stage_listener(b)
6616            .await
6617            .expect_err("duplicate stage must be rejected");
6618        assert!(
6619            err.to_string().contains("listener already staged"),
6620            "unexpected error: {err}"
6621        );
6622
6623        let (max_req, max_res, max_inflight) = staged_limits();
6624        registry
6625            .get_or_spawn(
6626                "127.0.0.1",
6627                addr.port(),
6628                max_req,
6629                max_res,
6630                max_inflight,
6631                test_rt(),
6632                "dup-stage-after".into(),
6633                None,
6634            )
6635            .await
6636            .expect("spawn from first staged listener");
6637        assert_eq!(
6638            registry.bound_addr("127.0.0.1", addr.port()),
6639            Some(addr),
6640            "first staged listener retained"
6641        );
6642    }
6643
6644    #[allow(clippy::await_holding_lock)]
6645    #[tokio::test]
6646    async fn distinct_keys_stage_independently() {
6647        let _guard = lock_registry_test_mutex();
6648        ServerRegistry::reset();
6649        let registry = ServerRegistry::global();
6650        let (l1, _p1, addr1) = clone_fixture_listener().await;
6651        let (l2, _p2, addr2) = clone_fixture_listener().await;
6652        registry.stage_listener(l1).await.expect("stage P1");
6653        registry.stage_listener(l2).await.expect("stage P2");
6654
6655        let (max_req, max_res, max_inflight) = staged_limits();
6656        registry
6657            .get_or_spawn(
6658                "127.0.0.1",
6659                addr1.port(),
6660                max_req,
6661                max_res,
6662                max_inflight,
6663                test_rt(),
6664                "distinct-1".into(),
6665                None,
6666            )
6667            .await
6668            .expect("spawn P1");
6669        registry
6670            .get_or_spawn(
6671                "127.0.0.1",
6672                addr2.port(),
6673                max_req,
6674                max_res,
6675                max_inflight,
6676                test_rt(),
6677                "distinct-2".into(),
6678                None,
6679            )
6680            .await
6681            .expect("spawn P2");
6682        assert_eq!(
6683            registry.bound_addr("127.0.0.1", addr1.port()),
6684            Some(addr1),
6685            "P1 bound addr must be its own listener"
6686        );
6687        assert_eq!(
6688            registry.bound_addr("127.0.0.1", addr2.port()),
6689            Some(addr2),
6690            "P2 bound addr must be its own listener"
6691        );
6692        let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
6693            .await
6694            .expect("connect P1");
6695        assert!(r1.status().as_u16() >= 200);
6696        let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
6697            .await
6698            .expect("connect P2");
6699        assert!(r2.status().as_u16() >= 200);
6700    }
6701
6702    #[allow(clippy::await_holding_lock)]
6703    #[tokio::test]
6704    async fn tls_prebound_listener_served() {
6705        use camel_component_api::test_support::tls;
6706
6707        // Install rustls crypto provider (aws-lc-rs — matches the existing
6708        // TLS registry tests).
6709        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6710
6711        let _guard = lock_registry_test_mutex();
6712        ServerRegistry::reset();
6713        let registry = ServerRegistry::global();
6714        let (listener, _probe, addr) = clone_fixture_listener().await;
6715        let port = addr.port();
6716
6717        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6718        let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
6719        let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
6720        let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
6721
6722        let (max_req, max_res, max_inflight) = staged_limits();
6723        let routes = registry
6724            .get_or_spawn_with_listener(
6725                listener,
6726                max_req,
6727                max_res,
6728                max_inflight,
6729                test_rt(),
6730                "staged-tls".into(),
6731                Some(crate::config::ServerTlsConfig {
6732                    cert_path: cert_path.to_string_lossy().into_owned(),
6733                    key_path: key_path.to_string_lossy().into_owned(),
6734                }),
6735            )
6736            .await
6737            .expect("spawn TLS server from pre-bound listener");
6738
6739        // Client with CA cert — REAL verification (no danger_accept_invalid),
6740        // same helper pattern as the existing TLS registry tests.
6741        let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
6742        let client = reqwest::Client::builder()
6743            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
6744            .build()
6745            .expect("build tls client");
6746
6747        let resp = client
6748            .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
6749            .send()
6750            .await
6751            .expect("TLS handshake + request must succeed");
6752        assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
6753        assert_eq!(
6754            registry.bound_addr("127.0.0.1", port),
6755            Some(addr),
6756            "bound addr equals the pre-bound listener addr"
6757        );
6758        drop(routes);
6759    }
6760
6761    #[allow(clippy::await_holding_lock)]
6762    #[tokio::test]
6763    async fn with_listener_direct_spawn_keyed_by_actual_addr() {
6764        let _guard = lock_registry_test_mutex();
6765        ServerRegistry::reset();
6766        let registry = ServerRegistry::global();
6767        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
6768            .await
6769            .expect("bind un-staged listener");
6770        let addr = listener.local_addr().expect("local addr");
6771        let port = addr.port();
6772
6773        let (max_req, max_res, max_inflight) = staged_limits();
6774        registry
6775            .get_or_spawn_with_listener(
6776                listener,
6777                max_req,
6778                max_res,
6779                max_inflight,
6780                test_rt(),
6781                "with-listener".into(),
6782                None,
6783            )
6784            .await
6785            .expect("direct spawn from un-staged listener");
6786        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
6787            .await
6788            .expect("connect on actual port");
6789        assert!(resp.status().as_u16() >= 200);
6790        assert_eq!(
6791            registry.bound_addr("127.0.0.1", port),
6792            Some(addr),
6793            "registry key is the listener's actual port"
6794        );
6795
6796        registry
6797            .get_or_spawn(
6798                "127.0.0.1",
6799                port,
6800                max_req,
6801                max_res,
6802                max_inflight,
6803                test_rt(),
6804                "with-listener-reuse".into(),
6805                None,
6806            )
6807            .await
6808            .expect("legacy caller must reuse the entry");
6809        assert_eq!(
6810            registry.bound_addr("127.0.0.1", port),
6811            Some(addr),
6812            "entry reused — no second bind"
6813        );
6814    }
6815
6816    // -----------------------------------------------------------------------
6817    // Axum dispatch handler tests
6818    // -----------------------------------------------------------------------
6819
6820    #[tokio::test]
6821    async fn test_dispatch_handler_returns_404_for_unknown_path() {
6822        let registry = HttpRouteRegistry::new();
6823        // Nothing registered in route registry
6824        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6825        let port = listener.local_addr().unwrap().port();
6826        tokio::spawn(run_axum_server(
6827            listener,
6828            registry,
6829            2 * 1024 * 1024,
6830            10 * 1024 * 1024,
6831            Arc::new(tokio::sync::Semaphore::new(1024)),
6832            test_rt(),
6833            "test-route".into(),
6834        ));
6835
6836        // Wait for server to start
6837        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6838
6839        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
6840            .await
6841            .unwrap();
6842        assert_eq!(resp.status().as_u16(), 404);
6843    }
6844
6845    // -----------------------------------------------------------------------
6846    // HttpConsumer tests
6847    // -----------------------------------------------------------------------
6848
6849    #[tokio::test]
6850    async fn test_http_consumer_start_registers_path() {
6851        use camel_component_api::ConsumerContext;
6852
6853        // Get an OS-assigned free port
6854        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6855        let port = listener.local_addr().unwrap().port();
6856        drop(listener); // Release port — ServerRegistry will rebind it
6857
6858        let consumer_cfg = HttpServerConfig {
6859            scheme: "http".to_string(),
6860            host: "127.0.0.1".to_string(),
6861            port,
6862            path: "/ping".to_string(),
6863            max_request_body: 2 * 1024 * 1024,
6864            max_response_body: 10 * 1024 * 1024,
6865            max_inflight_requests: 1024,
6866            method: None,
6867            tls_config: None,
6868        };
6869        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6870
6871        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6872        let token = tokio_util::sync::CancellationToken::new();
6873        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6874
6875        tokio::spawn(async move {
6876            consumer.start(ctx).await.unwrap();
6877        });
6878
6879        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6880
6881        let client = reqwest::Client::new();
6882        let resp_future = client
6883            .post(format!("http://127.0.0.1:{port}/ping"))
6884            .body("hello world")
6885            .send();
6886
6887        let (http_result, _) = tokio::join!(resp_future, async {
6888            if let Some(mut envelope) = rx.recv().await {
6889                // Set a custom status code
6890                envelope.exchange.input.set_header(
6891                    "CamelHttpResponseCode",
6892                    serde_json::Value::Number(201.into()),
6893                );
6894                if let Some(reply_tx) = envelope.reply_tx {
6895                    let _ = reply_tx.send(Ok(envelope.exchange));
6896                }
6897            }
6898        });
6899
6900        let resp = http_result.unwrap();
6901        assert_eq!(resp.status().as_u16(), 201);
6902
6903        token.cancel();
6904    }
6905
6906    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
6907    /// dispatcher's inflight semaphore so the semaphore stays the single
6908    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
6909    #[test]
6910    fn test_envelope_channel_capacity_follows_max_inflight() {
6911        assert_eq!(envelope_channel_capacity(0), 1);
6912        assert_eq!(envelope_channel_capacity(1), 1);
6913        assert_eq!(envelope_channel_capacity(7), 7);
6914        assert_eq!(envelope_channel_capacity(64), 64);
6915        assert_eq!(envelope_channel_capacity(1024), 1024);
6916    }
6917
6918    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
6919    /// configuration. Consumer start must not panic on it (the channel guard)
6920    /// and every request must get 503 from the empty semaphore.
6921    #[tokio::test]
6922    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
6923        use camel_component_api::ConsumerContext;
6924
6925        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6926        let port = listener.local_addr().unwrap().port();
6927        drop(listener);
6928
6929        let consumer_cfg = HttpServerConfig {
6930            scheme: "http".to_string(),
6931            host: "127.0.0.1".to_string(),
6932            port,
6933            path: "/ping".to_string(),
6934            max_request_body: 2 * 1024 * 1024,
6935            max_response_body: 10 * 1024 * 1024,
6936            max_inflight_requests: 0,
6937            method: None,
6938            tls_config: None,
6939        };
6940        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6941
6942        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6943        let token = tokio_util::sync::CancellationToken::new();
6944        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6945
6946        let start_handle = tokio::spawn(async move {
6947            consumer.start(ctx).await.unwrap();
6948        });
6949
6950        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6951
6952        let client = reqwest::Client::new();
6953        let resp = client
6954            .post(format!("http://127.0.0.1:{port}/ping"))
6955            .body("hello world")
6956            .send()
6957            .await
6958            .unwrap();
6959        assert_eq!(resp.status().as_u16(), 503);
6960
6961        token.cancel();
6962        let _ = start_handle.await;
6963    }
6964
6965    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
6966    /// waits for the listener bind before publishing RouteStarted.
6967    #[test]
6968    fn test_http_consumer_startup_mode_is_explicit() {
6969        use camel_component_api::ConsumerStartupMode;
6970        let consumer_cfg = HttpServerConfig {
6971            scheme: "http".to_string(),
6972            host: "127.0.0.1".to_string(),
6973            port: 0,
6974            path: "/x".to_string(),
6975            max_request_body: 2 * 1024 * 1024,
6976            max_response_body: 10 * 1024 * 1024,
6977            max_inflight_requests: 1024,
6978            method: None,
6979            tls_config: None,
6980        };
6981        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
6982        assert_eq!(
6983            consumer.startup_mode(),
6984            ConsumerStartupMode::Explicit,
6985            "HttpConsumer must opt into Explicit startup"
6986        );
6987    }
6988
6989    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
6990    /// + route registration. The StartupSignal resolves Ok only when that
6991    /// happens. Verified here by injecting our own signal pair into the
6992    /// ConsumerContext and asserting the receiver resolves within a bounded
6993    /// window even before any HTTP request is made.
6994    #[allow(clippy::await_holding_lock)]
6995    #[tokio::test]
6996    async fn test_http_consumer_emits_mark_ready_after_bind() {
6997        use camel_component_api::{ConsumerContext, StartupSignal};
6998
6999        let _guard = lock_registry_test_mutex();
7000
7001        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7002        let port = listener.local_addr().unwrap().port();
7003        drop(listener);
7004
7005        let consumer_cfg = HttpServerConfig {
7006            scheme: "http".to_string(),
7007            host: "127.0.0.1".to_string(),
7008            port,
7009            path: "/ready-probe".to_string(),
7010            max_request_body: 2 * 1024 * 1024,
7011            max_response_body: 10 * 1024 * 1024,
7012            max_inflight_requests: 1024,
7013            method: None,
7014            tls_config: None,
7015        };
7016        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7017
7018        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7019        let token = tokio_util::sync::CancellationToken::new();
7020        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
7021
7022        // Inject our own startup signal so we can observe mark_ready.
7023        let (signal, startup_rx) = StartupSignal::pair();
7024        let ctx = ctx.with_startup(signal);
7025
7026        // Spawn start() — it MUST call mark_ready once the listener is bound
7027        // and the path is registered.
7028        tokio::spawn(async move {
7029            let _ = consumer.start(ctx).await;
7030        });
7031
7032        // The receiver MUST resolve Ok within a bounded window — proving
7033        // mark_ready was called by start(). A short timeout catches the
7034        // regression where mark_ready is never called (the old behaviour
7035        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
7036        let result =
7037            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
7038                .await
7039                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
7040        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
7041
7042        // Cancellation tears down the spawned start() loop.
7043        token.cancel();
7044    }
7045
7046    #[tokio::test]
7047    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
7048        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7049
7050        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7051        let port = listener.local_addr().unwrap().port();
7052        drop(listener);
7053
7054        let consumer_cfg = HttpServerConfig {
7055            scheme: "http".to_string(),
7056            host: "127.0.0.1".to_string(),
7057            port,
7058            path: "/saturation".to_string(),
7059            max_request_body: 2 * 1024 * 1024,
7060            max_response_body: 10 * 1024 * 1024,
7061            max_inflight_requests: 1,
7062            method: None,
7063            tls_config: None,
7064        };
7065        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7066
7067        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7068        let token = tokio_util::sync::CancellationToken::new();
7069        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7070        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7071        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7072
7073        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
7074        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
7075
7076        tokio::spawn(async move {
7077            let mut first_seen_tx = Some(first_seen_tx);
7078            let mut unblock_first_rx = Some(unblock_first_rx);
7079
7080            while let Some(envelope) = rx.recv().await {
7081                if let Some(tx) = first_seen_tx.take() {
7082                    let _ = tx.send(());
7083                    if let Some(rx_unblock) = unblock_first_rx.take() {
7084                        let _ = rx_unblock.await;
7085                    }
7086                }
7087
7088                if let Some(reply_tx) = envelope.reply_tx {
7089                    let _ = reply_tx.send(Ok(envelope.exchange));
7090                }
7091            }
7092        });
7093
7094        let client = reqwest::Client::new();
7095        let first_req = {
7096            let client = client.clone();
7097            async move {
7098                client
7099                    .get(format!("http://127.0.0.1:{port}/saturation"))
7100                    .send()
7101                    .await
7102                    .unwrap()
7103            }
7104        };
7105
7106        let first_handle = tokio::spawn(first_req);
7107        first_seen_rx.await.unwrap();
7108
7109        let second_resp = client
7110            .get(format!("http://127.0.0.1:{port}/saturation"))
7111            .send()
7112            .await
7113            .unwrap();
7114
7115        assert_eq!(second_resp.status().as_u16(), 503);
7116
7117        let _ = unblock_first_tx.send(());
7118        let first_resp = first_handle.await.unwrap();
7119        assert_eq!(first_resp.status().as_u16(), 200);
7120
7121        token.cancel();
7122    }
7123
7124    /// Audit 2026-08-31, F2-1: a chunked (no Content-Length) request body must
7125    /// still be capped — the byte limit travels with the stream, so any
7126    /// downstream materialization fails closed past `max_request_body`.
7127    #[tokio::test]
7128    async fn test_http_consumer_chunked_body_is_capped() {
7129        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7130
7131        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7132        let port = listener.local_addr().unwrap().port();
7133        drop(listener);
7134
7135        let consumer_cfg = HttpServerConfig {
7136            scheme: "http".to_string(),
7137            host: "127.0.0.1".to_string(),
7138            port,
7139            path: "/chunked-cap".to_string(),
7140            max_request_body: 1024, // tiny cap for the test
7141            max_response_body: 10 * 1024 * 1024,
7142            max_inflight_requests: 16,
7143            method: None,
7144            tls_config: None,
7145        };
7146        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7147
7148        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7149        let token = tokio_util::sync::CancellationToken::new();
7150        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7151        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7152        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7153
7154        // Chunked body: reqwest streams it without Content-Length.
7155        let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
7156            .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
7157            .collect();
7158        let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
7159
7160        let client = reqwest::Client::new();
7161        let send_fut = client
7162            .post(format!("http://127.0.0.1:{port}/chunked-cap"))
7163            .body(stream_body)
7164            .send();
7165
7166        let (http_result, _) = tokio::join!(send_fut, async {
7167            if let Some(mut envelope) = rx.recv().await {
7168                // The route materializes the body — the cap must fire.
7169                let materialized = envelope
7170                    .exchange
7171                    .input
7172                    .body
7173                    .clone()
7174                    .into_bytes(64 * 1024)
7175                    .await;
7176                assert!(
7177                    materialized.is_err(),
7178                    "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
7179                );
7180                let err = materialized.unwrap_err().to_string();
7181                assert!(
7182                    err.contains("limit") || err.contains("exceeds"),
7183                    "error should mention the limit: {err}"
7184                );
7185                if let Some(reply_tx) = envelope.reply_tx {
7186                    envelope.exchange.input.body =
7187                        camel_component_api::Body::Text("handled".to_string());
7188                    let _ = reply_tx.send(Ok(envelope.exchange));
7189                }
7190            }
7191        });
7192
7193        let resp = http_result.unwrap();
7194        assert_eq!(resp.status().as_u16(), 200);
7195
7196        token.cancel();
7197    }
7198
7199    #[tokio::test]
7200    #[allow(clippy::await_holding_lock)]
7201    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
7202        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7203
7204        let _guard = lock_registry_test_mutex();
7205
7206        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7207        let port = listener.local_addr().unwrap().port();
7208        drop(listener);
7209
7210        let consumer_cfg = HttpServerConfig {
7211            scheme: "http".to_string(),
7212            host: "127.0.0.1".to_string(),
7213            port,
7214            path: "/limit-bytes".to_string(),
7215            max_request_body: 2 * 1024 * 1024,
7216            max_response_body: 16,
7217            max_inflight_requests: 1024,
7218            method: None,
7219            tls_config: None,
7220        };
7221        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7222
7223        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7224        let token = tokio_util::sync::CancellationToken::new();
7225        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7226        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7227        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7228
7229        let client = reqwest::Client::new();
7230        let send_fut = client
7231            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
7232            .send();
7233
7234        let (http_result, _) = tokio::join!(send_fut, async {
7235            if let Some(mut envelope) = rx.recv().await {
7236                envelope.exchange.input.body =
7237                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
7238                if let Some(reply_tx) = envelope.reply_tx {
7239                    let _ = reply_tx.send(Ok(envelope.exchange));
7240                }
7241            }
7242        });
7243
7244        let resp = http_result.unwrap();
7245        assert_eq!(resp.status().as_u16(), 500);
7246        let body = resp.text().await.unwrap();
7247        assert_eq!(body, "Response body exceeds configured limit");
7248        token.cancel();
7249    }
7250
7251    #[tokio::test]
7252    #[allow(clippy::await_holding_lock)]
7253    async fn test_http_consumer_enforces_max_response_body_for_json() {
7254        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7255
7256        let _guard = lock_registry_test_mutex();
7257
7258        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7259        let port = listener.local_addr().unwrap().port();
7260        drop(listener);
7261
7262        let consumer_cfg = HttpServerConfig {
7263            scheme: "http".to_string(),
7264            host: "127.0.0.1".to_string(),
7265            port,
7266            path: "/limit-json".to_string(),
7267            max_request_body: 2 * 1024 * 1024,
7268            max_response_body: 16,
7269            max_inflight_requests: 1024,
7270            method: None,
7271            tls_config: None,
7272        };
7273        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7274
7275        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7276        let token = tokio_util::sync::CancellationToken::new();
7277        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7278        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7279        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7280
7281        let client = reqwest::Client::new();
7282        let send_fut = client
7283            .get(format!("http://127.0.0.1:{port}/limit-json"))
7284            .send();
7285
7286        let (http_result, _) = tokio::join!(send_fut, async {
7287            if let Some(mut envelope) = rx.recv().await {
7288                envelope.exchange.input.body = camel_component_api::Body::Json(
7289                    serde_json::json!({"message":"this response is bigger than sixteen"}),
7290                );
7291                if let Some(reply_tx) = envelope.reply_tx {
7292                    let _ = reply_tx.send(Ok(envelope.exchange));
7293                }
7294            }
7295        });
7296
7297        let resp = http_result.unwrap();
7298        assert_eq!(resp.status().as_u16(), 500);
7299        let body = resp.text().await.unwrap();
7300        assert_eq!(body, "Response body exceeds configured limit");
7301        token.cancel();
7302    }
7303
7304    #[tokio::test]
7305    #[allow(clippy::await_holding_lock)]
7306    async fn test_http_consumer_enforces_max_response_body_for_xml() {
7307        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7308
7309        let _guard = lock_registry_test_mutex();
7310
7311        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7312        let port = listener.local_addr().unwrap().port();
7313        drop(listener);
7314
7315        let consumer_cfg = HttpServerConfig {
7316            scheme: "http".to_string(),
7317            host: "127.0.0.1".to_string(),
7318            port,
7319            path: "/limit-xml".to_string(),
7320            max_request_body: 2 * 1024 * 1024,
7321            max_response_body: 16,
7322            max_inflight_requests: 1024,
7323            method: None,
7324            tls_config: None,
7325        };
7326        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7327
7328        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7329        let token = tokio_util::sync::CancellationToken::new();
7330        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7331        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7332        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7333
7334        let client = reqwest::Client::new();
7335        let send_fut = client
7336            .get(format!("http://127.0.0.1:{port}/limit-xml"))
7337            .send();
7338
7339        let (http_result, _) = tokio::join!(send_fut, async {
7340            if let Some(mut envelope) = rx.recv().await {
7341                envelope.exchange.input.body = camel_component_api::Body::Xml(
7342                    "<root><value>way-too-large</value></root>".into(),
7343                );
7344                if let Some(reply_tx) = envelope.reply_tx {
7345                    let _ = reply_tx.send(Ok(envelope.exchange));
7346                }
7347            }
7348        });
7349
7350        let resp = http_result.unwrap();
7351        assert_eq!(resp.status().as_u16(), 500);
7352        let body = resp.text().await.unwrap();
7353        assert_eq!(body, "Response body exceeds configured limit");
7354        token.cancel();
7355    }
7356
7357    #[tokio::test]
7358    #[allow(clippy::await_holding_lock)]
7359    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
7360        use camel_component_api::{
7361            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
7362        };
7363        use futures::stream;
7364
7365        let _guard = lock_registry_test_mutex();
7366
7367        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
7368        let port = listener.local_addr().unwrap().port();
7369        drop(listener);
7370
7371        let consumer_cfg = HttpServerConfig {
7372            scheme: "http".to_string(),
7373            host: "0.0.0.0".to_string(),
7374            port,
7375            path: "/limit-stream".to_string(),
7376            max_request_body: 2 * 1024 * 1024,
7377            max_response_body: 16,
7378            max_inflight_requests: 1024,
7379            method: None,
7380            tls_config: None,
7381        };
7382        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7383
7384        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7385        let token = tokio_util::sync::CancellationToken::new();
7386        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7387        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7388        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7389
7390        let client = reqwest::Client::new();
7391        let send_fut = client
7392            .get(format!("http://127.0.0.1:{port}/limit-stream"))
7393            .send();
7394
7395        let (http_result, _) = tokio::join!(send_fut, async {
7396            if let Some(mut envelope) = rx.recv().await {
7397                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
7398                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
7399                let stream = Box::pin(stream::iter(chunks));
7400                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
7401                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
7402                    metadata: StreamMetadata {
7403                        size_hint: Some(32),
7404                        content_type: Some("application/octet-stream".into()),
7405                        origin: None,
7406                    },
7407                });
7408                if let Some(reply_tx) = envelope.reply_tx {
7409                    let _ = reply_tx.send(Ok(envelope.exchange));
7410                }
7411            }
7412        });
7413
7414        let resp = http_result.unwrap();
7415        assert_eq!(resp.status().as_u16(), 200);
7416        let body = resp.bytes().await.unwrap();
7417        assert_eq!(body.len(), 32);
7418        token.cancel();
7419    }
7420
7421    // -----------------------------------------------------------------------
7422    // Integration tests
7423    // -----------------------------------------------------------------------
7424
7425    #[tokio::test]
7426    #[allow(clippy::await_holding_lock)]
7427    async fn test_integration_single_consumer_round_trip() {
7428        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7429
7430        // Spawns an HTTP consumer on the global ServerRegistry
7431        // (HttpConsumer::start → get_or_spawn). Serialize against the other
7432        // registry tests so parallel runs do not race on shared global state.
7433        let _guard = lock_registry_test_mutex();
7434
7435        // Get an OS-assigned free port (ephemeral)
7436        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7437        let port = listener.local_addr().unwrap().port();
7438        drop(listener); // Release — ServerRegistry will rebind
7439
7440        let component = HttpComponent::new();
7441        let endpoint_ctx = NoOpComponentContext;
7442        let endpoint = component
7443            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
7444            .unwrap();
7445        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7446
7447        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7448        let token = tokio_util::sync::CancellationToken::new();
7449        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7450
7451        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7452        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7453
7454        let client = reqwest::Client::new();
7455        let send_fut = client
7456            .post(format!("http://127.0.0.1:{port}/echo"))
7457            .header("Content-Type", "text/plain")
7458            .body("ping")
7459            .send();
7460
7461        let (http_result, _) = tokio::join!(send_fut, async {
7462            if let Some(mut envelope) = rx.recv().await {
7463                assert_eq!(
7464                    envelope.exchange.input.header("CamelHttpMethod"),
7465                    Some(&serde_json::Value::String("POST".into()))
7466                );
7467                assert_eq!(
7468                    envelope.exchange.input.header("CamelHttpPath"),
7469                    Some(&serde_json::Value::String("/echo".into()))
7470                );
7471                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7472                if let Some(reply_tx) = envelope.reply_tx {
7473                    let _ = reply_tx.send(Ok(envelope.exchange));
7474                }
7475            }
7476        });
7477
7478        let resp = http_result.unwrap();
7479        assert_eq!(resp.status().as_u16(), 200);
7480        let body = resp.text().await.unwrap();
7481        assert_eq!(body, "pong");
7482
7483        token.cancel();
7484    }
7485
7486    #[tokio::test]
7487    #[allow(clippy::await_holding_lock)]
7488    async fn test_integration_two_consumers_shared_port() {
7489        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7490
7491        let _guard = lock_registry_test_mutex();
7492
7493        // Get an OS-assigned free port (ephemeral)
7494        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7495        let port = listener.local_addr().unwrap().port();
7496        drop(listener);
7497
7498        let component = HttpComponent::new();
7499        let endpoint_ctx = NoOpComponentContext;
7500
7501        // Consumer A: /hello
7502        let endpoint_a = component
7503            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
7504            .unwrap();
7505        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
7506
7507        // Consumer B: /world
7508        let endpoint_b = component
7509            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
7510            .unwrap();
7511        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
7512
7513        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7514        let token_a = tokio_util::sync::CancellationToken::new();
7515        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
7516
7517        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7518        let token_b = tokio_util::sync::CancellationToken::new();
7519        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
7520
7521        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
7522        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
7523        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7524
7525        let client = reqwest::Client::new();
7526
7527        // Request to /hello
7528        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
7529        let (resp_hello, _) = tokio::join!(fut_hello, async {
7530            if let Some(mut envelope) = rx_a.recv().await {
7531                envelope.exchange.input.body =
7532                    camel_component_api::Body::Text("hello-response".to_string());
7533                if let Some(reply_tx) = envelope.reply_tx {
7534                    let _ = reply_tx.send(Ok(envelope.exchange));
7535                }
7536            }
7537        });
7538
7539        // Request to /world
7540        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
7541        let (resp_world, _) = tokio::join!(fut_world, async {
7542            if let Some(mut envelope) = rx_b.recv().await {
7543                envelope.exchange.input.body =
7544                    camel_component_api::Body::Text("world-response".to_string());
7545                if let Some(reply_tx) = envelope.reply_tx {
7546                    let _ = reply_tx.send(Ok(envelope.exchange));
7547                }
7548            }
7549        });
7550
7551        let body_a = resp_hello.unwrap().text().await.unwrap();
7552        let body_b = resp_world.unwrap().text().await.unwrap();
7553
7554        assert_eq!(body_a, "hello-response");
7555        assert_eq!(body_b, "world-response");
7556
7557        token_a.cancel();
7558        token_b.cancel();
7559    }
7560
7561    #[tokio::test]
7562    #[allow(clippy::await_holding_lock)]
7563    async fn test_integration_unregistered_path_returns_404() {
7564        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7565
7566        let _guard = lock_registry_test_mutex();
7567
7568        // Get an OS-assigned free port (ephemeral)
7569        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7570        let port = listener.local_addr().unwrap().port();
7571        drop(listener);
7572
7573        let component = HttpComponent::new();
7574        let endpoint_ctx = NoOpComponentContext;
7575        let endpoint = component
7576            .create_endpoint(
7577                &format!("http://127.0.0.1:{port}/registered"),
7578                &endpoint_ctx,
7579            )
7580            .unwrap();
7581        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7582
7583        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7584        let token = tokio_util::sync::CancellationToken::new();
7585        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7586
7587        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7588
7589        // Wait until the server is actually accepting connections (CI runners can be slow).
7590        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7591        loop {
7592            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
7593                .await
7594                .is_ok()
7595            {
7596                break;
7597            }
7598            if std::time::Instant::now() >= deadline {
7599                panic!("HTTP server did not start within 5s on port {port}");
7600            }
7601            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7602        }
7603
7604        let client = reqwest::Client::new();
7605        let resp = client
7606            .get(format!("http://127.0.0.1:{port}/not-there"))
7607            .send()
7608            .await
7609            .unwrap();
7610        assert_eq!(resp.status().as_u16(), 404);
7611
7612        token.cancel();
7613    }
7614
7615    #[test]
7616    fn test_http_consumer_declares_concurrent() {
7617        use camel_component_api::ConcurrencyModel;
7618
7619        let config = HttpServerConfig {
7620            scheme: "http".to_string(),
7621            host: "127.0.0.1".to_string(),
7622            port: 19999,
7623            path: "/test".to_string(),
7624            max_request_body: 2 * 1024 * 1024,
7625            max_response_body: 10 * 1024 * 1024,
7626            max_inflight_requests: 1024,
7627            method: None,
7628            tls_config: None,
7629        };
7630        let consumer = HttpConsumer::new(config, test_rt());
7631        assert_eq!(
7632            consumer.concurrency_model(),
7633            ConcurrencyModel::Concurrent { max: None }
7634        );
7635    }
7636
7637    #[test]
7638    fn server_config_parses_tls_cert_and_key() {
7639        let cfg = HttpServerConfig::from_uri(
7640            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
7641        )
7642        .unwrap();
7643        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
7644        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
7645    }
7646
7647    #[test]
7648    fn server_config_no_tls_when_params_absent() {
7649        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
7650        assert!(cfg.tls_config.is_none());
7651    }
7652
7653    // -----------------------------------------------------------------------
7654    // HttpReplyBody streaming tests
7655    // -----------------------------------------------------------------------
7656
7657    #[tokio::test]
7658    async fn test_http_reply_body_stream_variant_exists() {
7659        use bytes::Bytes;
7660        use camel_component_api::CamelError;
7661        use futures::stream;
7662
7663        let chunks: Vec<Result<Bytes, CamelError>> =
7664            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
7665        let stream = Box::pin(stream::iter(chunks));
7666        let reply_body = HttpReplyBody::Stream(stream);
7667        // Si compila y el match funciona, el test pasa
7668        match reply_body {
7669            HttpReplyBody::Stream(_) => {}
7670            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
7671        }
7672    }
7673
7674    // -----------------------------------------------------------------------
7675    // OpenTelemetry propagation tests (only compiled with "otel" feature)
7676    // -----------------------------------------------------------------------
7677
7678    #[cfg(feature = "otel")]
7679    mod otel_tests {
7680        use super::*;
7681        use camel_component_api::Message;
7682        use tower::ServiceExt;
7683
7684        #[tokio::test]
7685        async fn test_producer_injects_traceparent_header() {
7686            let (url, _handle) = start_test_server_with_header_capture().await;
7687            let ctx = test_producer_ctx();
7688
7689            let component = HttpComponent::new();
7690            let endpoint_ctx = NoOpComponentContext;
7691            let endpoint = component
7692                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7693                .unwrap();
7694            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7695
7696            // Create exchange with an OTel context by extracting from a traceparent header
7697            let mut exchange = Exchange::new(Message::default());
7698            let mut headers = std::collections::HashMap::new();
7699            headers.insert(
7700                "traceparent".to_string(),
7701                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
7702            );
7703            camel_otel::extract_into_exchange(&mut exchange, &headers);
7704
7705            let result = producer.oneshot(exchange).await.unwrap();
7706
7707            // Verify request succeeded
7708            let status = result
7709                .input
7710                .header("CamelHttpResponseCode")
7711                .and_then(|v| v.as_u64())
7712                .unwrap();
7713            assert_eq!(status, 200);
7714
7715            // The test server echoes back the received traceparent header
7716            let traceparent = result.input.header("X-Received-Traceparent");
7717            assert!(
7718                traceparent.is_some(),
7719                "traceparent header should have been sent"
7720            );
7721
7722            let traceparent_str = traceparent.unwrap().as_str().unwrap();
7723            // Verify format: version-traceid-spanid-flags
7724            let parts: Vec<&str> = traceparent_str.split('-').collect();
7725            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7726            assert_eq!(parts[0], "00", "version should be 00");
7727            assert_eq!(
7728                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7729                "trace-id should match"
7730            );
7731            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
7732            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
7733        }
7734
7735        #[tokio::test]
7736        async fn test_consumer_extracts_traceparent_header() {
7737            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7738
7739            // Get an OS-assigned free port
7740            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7741            let port = listener.local_addr().unwrap().port();
7742            drop(listener);
7743
7744            let component = HttpComponent::new();
7745            let endpoint_ctx = NoOpComponentContext;
7746            let endpoint = component
7747                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7748                .unwrap();
7749            let mut consumer = endpoint.create_consumer(rt()).unwrap();
7750
7751            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7752            let token = tokio_util::sync::CancellationToken::new();
7753            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7754
7755            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7756            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7757
7758            // Send request with traceparent header
7759            let client = reqwest::Client::new();
7760            let send_fut = client
7761                .post(format!("http://127.0.0.1:{port}/trace"))
7762                .header(
7763                    "traceparent",
7764                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7765                )
7766                .body("test")
7767                .send();
7768
7769            let (http_result, _) = tokio::join!(send_fut, async {
7770                if let Some(envelope) = rx.recv().await {
7771                    // Verify the exchange has a valid OTel context by re-injecting it
7772                    // and checking the traceparent matches
7773                    let mut injected_headers = std::collections::HashMap::new();
7774                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7775
7776                    assert!(
7777                        injected_headers.contains_key("traceparent"),
7778                        "Exchange should have traceparent after extraction"
7779                    );
7780
7781                    let traceparent = injected_headers.get("traceparent").unwrap();
7782                    let parts: Vec<&str> = traceparent.split('-').collect();
7783                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7784                    assert_eq!(
7785                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7786                        "Trace ID should match the original traceparent header"
7787                    );
7788
7789                    if let Some(reply_tx) = envelope.reply_tx {
7790                        let _ = reply_tx.send(Ok(envelope.exchange));
7791                    }
7792                }
7793            });
7794
7795            let resp = http_result.unwrap();
7796            assert_eq!(resp.status().as_u16(), 200);
7797
7798            token.cancel();
7799        }
7800
7801        #[tokio::test]
7802        async fn test_consumer_extracts_mixed_case_traceparent_header() {
7803            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7804
7805            // Get an OS-assigned free port
7806            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7807            let port = listener.local_addr().unwrap().port();
7808            drop(listener);
7809
7810            let component = HttpComponent::new();
7811            let endpoint_ctx = NoOpComponentContext;
7812            let endpoint = component
7813                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7814                .unwrap();
7815            let mut consumer = endpoint.create_consumer(rt()).unwrap();
7816
7817            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7818            let token = tokio_util::sync::CancellationToken::new();
7819            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7820
7821            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7822            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7823
7824            // Send request with MIXED-CASE TraceParent header (not lowercase)
7825            let client = reqwest::Client::new();
7826            let send_fut = client
7827                .post(format!("http://127.0.0.1:{port}/trace"))
7828                .header(
7829                    "TraceParent",
7830                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7831                )
7832                .body("test")
7833                .send();
7834
7835            let (http_result, _) = tokio::join!(send_fut, async {
7836                if let Some(envelope) = rx.recv().await {
7837                    // Verify the exchange has a valid OTel context by re-injecting it
7838                    // and checking the traceparent matches
7839                    let mut injected_headers = HashMap::new();
7840                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7841
7842                    assert!(
7843                        injected_headers.contains_key("traceparent"),
7844                        "Exchange should have traceparent after extraction from mixed-case header"
7845                    );
7846
7847                    let traceparent = injected_headers.get("traceparent").unwrap();
7848                    let parts: Vec<&str> = traceparent.split('-').collect();
7849                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7850                    assert_eq!(
7851                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7852                        "Trace ID should match the original mixed-case TraceParent header"
7853                    );
7854
7855                    if let Some(reply_tx) = envelope.reply_tx {
7856                        let _ = reply_tx.send(Ok(envelope.exchange));
7857                    }
7858                }
7859            });
7860
7861            let resp = http_result.unwrap();
7862            assert_eq!(resp.status().as_u16(), 200);
7863
7864            token.cancel();
7865        }
7866
7867        #[tokio::test]
7868        async fn test_producer_no_trace_context_no_crash() {
7869            let (url, _handle) = start_test_server().await;
7870            let ctx = test_producer_ctx();
7871
7872            let component = HttpComponent::new();
7873            let endpoint_ctx = NoOpComponentContext;
7874            let endpoint = component
7875                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7876                .unwrap();
7877            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7878
7879            // Create exchange with default (empty) otel_context - no trace context
7880            let exchange = Exchange::new(Message::default());
7881
7882            // Should succeed without panic
7883            let result = producer.oneshot(exchange).await.unwrap();
7884
7885            // Verify request succeeded
7886            let status = result
7887                .input
7888                .header("CamelHttpResponseCode")
7889                .and_then(|v| v.as_u64())
7890                .unwrap();
7891            assert_eq!(status, 200);
7892        }
7893
7894        /// Test server that captures and echoes back the traceparent header
7895        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
7896            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7897            let addr = listener.local_addr().unwrap();
7898            let url = format!("http://127.0.0.1:{}", addr.port());
7899
7900            let handle = tokio::spawn(async move {
7901                loop {
7902                    if let Ok((mut stream, _)) = listener.accept().await {
7903                        tokio::spawn(async move {
7904                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
7905                            let mut buf = vec![0u8; 8192];
7906                            let n = stream.read(&mut buf).await.unwrap_or(0);
7907                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
7908
7909                            // Extract traceparent header from request
7910                            let traceparent = request
7911                                .lines()
7912                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
7913                                .map(|line| {
7914                                    line.split(':')
7915                                        .nth(1)
7916                                        .map(|s| s.trim().to_string())
7917                                        .unwrap_or_default()
7918                                })
7919                                .unwrap_or_default();
7920
7921                            let body =
7922                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
7923                            let response = format!(
7924                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
7925                                body.len(),
7926                                traceparent,
7927                                body
7928                            );
7929                            let _ = stream.write_all(response.as_bytes()).await;
7930                        });
7931                    }
7932                }
7933            });
7934
7935            (url, handle)
7936        }
7937    }
7938
7939    // -----------------------------------------------------------------------
7940    // Response streaming tests (Eje A - Task 2)
7941    // -----------------------------------------------------------------------
7942
7943    // -----------------------------------------------------------------------
7944    // Request streaming tests (Eje B - Task 3)
7945    // -----------------------------------------------------------------------
7946
7947    #[tokio::test]
7948    async fn test_request_body_arrives_as_stream() {
7949        use camel_component_api::Body;
7950        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7951
7952        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7953        let port = listener.local_addr().unwrap().port();
7954        drop(listener);
7955
7956        let component = HttpComponent::new();
7957        let endpoint_ctx = NoOpComponentContext;
7958        let endpoint = component
7959            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
7960            .unwrap();
7961        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7962
7963        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7964        let token = tokio_util::sync::CancellationToken::new();
7965        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7966
7967        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7968        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7969
7970        let client = reqwest::Client::new();
7971        let send_fut = client
7972            .post(format!("http://127.0.0.1:{port}/upload"))
7973            .body("hello streaming world")
7974            .send();
7975
7976        let (http_result, _) = tokio::join!(send_fut, async {
7977            if let Some(mut envelope) = rx.recv().await {
7978                // Body must be Body::Stream, not Body::Text or Body::Bytes
7979                assert!(
7980                    matches!(envelope.exchange.input.body, Body::Stream(_)),
7981                    "expected Body::Stream, got discriminant {:?}",
7982                    std::mem::discriminant(&envelope.exchange.input.body)
7983                );
7984                // Materialize to verify content
7985                let bytes = envelope
7986                    .exchange
7987                    .input
7988                    .body
7989                    .into_bytes(1024 * 1024)
7990                    .await
7991                    .unwrap();
7992                assert_eq!(&bytes[..], b"hello streaming world");
7993
7994                envelope.exchange.input.body = camel_component_api::Body::Empty;
7995                if let Some(reply_tx) = envelope.reply_tx {
7996                    let _ = reply_tx.send(Ok(envelope.exchange));
7997                }
7998            }
7999        });
8000
8001        let resp = http_result.unwrap();
8002        assert_eq!(resp.status().as_u16(), 200);
8003
8004        token.cancel();
8005    }
8006
8007    // -----------------------------------------------------------------------
8008    // Response streaming tests (Eje A - Task 2)
8009    // -----------------------------------------------------------------------
8010
8011    #[tokio::test]
8012    async fn test_streaming_response_chunked() {
8013        use bytes::Bytes;
8014        use camel_component_api::Body;
8015        use camel_component_api::CamelError;
8016        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8017        use camel_component_api::{StreamBody, StreamMetadata};
8018        use futures::stream;
8019        use std::sync::Arc;
8020        use tokio::sync::Mutex;
8021
8022        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8023        let port = listener.local_addr().unwrap().port();
8024        drop(listener);
8025
8026        let component = HttpComponent::new();
8027        let endpoint_ctx = NoOpComponentContext;
8028        let endpoint = component
8029            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
8030            .unwrap();
8031        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8032
8033        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8034        let token = tokio_util::sync::CancellationToken::new();
8035        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8036
8037        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8038        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8039
8040        let client = reqwest::Client::new();
8041        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
8042
8043        let (http_result, _) = tokio::join!(send_fut, async {
8044            if let Some(mut envelope) = rx.recv().await {
8045                // Respond with Body::Stream
8046                let chunks: Vec<Result<Bytes, CamelError>> =
8047                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
8048                let stream = Box::pin(stream::iter(chunks));
8049                envelope.exchange.input.body = Body::Stream(StreamBody {
8050                    stream: Arc::new(Mutex::new(Some(stream))),
8051                    metadata: StreamMetadata::default(),
8052                });
8053                if let Some(reply_tx) = envelope.reply_tx {
8054                    let _ = reply_tx.send(Ok(envelope.exchange));
8055                }
8056            }
8057        });
8058
8059        let resp = http_result.unwrap();
8060        assert_eq!(resp.status().as_u16(), 200);
8061        let body = resp.text().await.unwrap();
8062        assert_eq!(body, "chunk1chunk2");
8063
8064        token.cancel();
8065    }
8066
8067    // -----------------------------------------------------------------------
8068    // 413 Content-Length limit test (Task 4)
8069    // -----------------------------------------------------------------------
8070
8071    #[tokio::test]
8072    async fn test_413_when_content_length_exceeds_limit() {
8073        use camel_component_api::ConsumerContext;
8074
8075        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8076        let port = listener.local_addr().unwrap().port();
8077        drop(listener);
8078
8079        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
8080        let component = HttpComponent::new();
8081        let endpoint_ctx = NoOpComponentContext;
8082        let endpoint = component
8083            .create_endpoint(
8084                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
8085                &endpoint_ctx,
8086            )
8087            .unwrap();
8088        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8089
8090        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8091        let token = tokio_util::sync::CancellationToken::new();
8092        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8093
8094        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8095        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8096
8097        let client = reqwest::Client::new();
8098        let resp = client
8099            .post(format!("http://127.0.0.1:{port}/upload"))
8100            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
8101            .body("x".repeat(1000))
8102            .send()
8103            .await
8104            .unwrap();
8105
8106        assert_eq!(resp.status().as_u16(), 413);
8107
8108        token.cancel();
8109    }
8110
8111    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
8112    /// The spec says: "If there is no Content-Length, the limit does not apply at the
8113    /// consumer level — the route is responsible."
8114    #[tokio::test]
8115    async fn test_chunked_upload_without_content_length_bypasses_limit() {
8116        use bytes::Bytes;
8117        use camel_component_api::Body;
8118        use camel_component_api::ConsumerContext;
8119        use futures::stream;
8120
8121        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8122        let port = listener.local_addr().unwrap().port();
8123        drop(listener);
8124
8125        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
8126        let component = HttpComponent::new();
8127        let endpoint_ctx = NoOpComponentContext;
8128        let endpoint = component
8129            .create_endpoint(
8130                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
8131                &endpoint_ctx,
8132            )
8133            .unwrap();
8134        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8135
8136        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8137        let token = tokio_util::sync::CancellationToken::new();
8138        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8139
8140        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8141        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8142
8143        let client = reqwest::Client::new();
8144
8145        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
8146        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
8147        // but since there's no Content-Length the 413 check must NOT fire.
8148        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
8149            Ok(Bytes::from("y".repeat(50))),
8150            Ok(Bytes::from("y".repeat(50))),
8151        ];
8152        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
8153        let send_fut = client
8154            .post(format!("http://127.0.0.1:{port}/upload"))
8155            .body(stream_body)
8156            .send();
8157
8158        let consumer_fut = async {
8159            // Use timeout to avoid deadlock if the handler rejects before enqueueing
8160            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
8161                Ok(Some(mut envelope)) => {
8162                    assert!(
8163                        matches!(envelope.exchange.input.body, Body::Stream(_)),
8164                        "expected Body::Stream"
8165                    );
8166                    envelope.exchange.input.body = camel_component_api::Body::Empty;
8167                    if let Some(reply_tx) = envelope.reply_tx {
8168                        let _ = reply_tx.send(Ok(envelope.exchange));
8169                    }
8170                }
8171                Ok(None) => panic!("consumer channel closed unexpectedly"),
8172                Err(_) => {
8173                    // Timeout: the request was rejected before reaching the consumer.
8174                    // The HTTP response will carry the real status code (we check below).
8175                }
8176            }
8177        };
8178
8179        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
8180
8181        let resp = http_result.unwrap();
8182        // Audit 2026-08-31 (F2-1): the request is no longer rejected at the door
8183        // (no Content-Length to pre-check), but the byte cap now travels with the
8184        // stream: ANY materialization past maxRequestBody fails closed. This test
8185        // does not consume the body, so the request still completes with 200 —
8186        // enforcement happens at consumption time (see
8187        // test_http_consumer_chunked_body_is_capped).
8188        assert_ne!(
8189            resp.status().as_u16(),
8190            413,
8191            "chunked upload has no Content-Length to pre-check"
8192        );
8193        assert_eq!(resp.status().as_u16(), 200);
8194
8195        token.cancel();
8196    }
8197
8198    #[test]
8199    fn test_is_private_ip_ranges() {
8200        use camel_api::is_ssrf_blocked_ip;
8201        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
8202        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
8203        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
8204        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
8205        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
8206        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
8207
8208        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
8209        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
8210        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
8211        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
8212        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
8213        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
8214        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
8215        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
8216
8217        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
8218        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
8219        assert!(!is_ssrf_blocked_ip(
8220            &"2001:4860:4860::8888".parse().unwrap()
8221        )); // allow-unwrap
8222    }
8223
8224    #[test]
8225    fn test_title_case_header() {
8226        assert_eq!(title_case_header("content-type"), "Content-Type");
8227        assert_eq!(title_case_header("authorization"), "Authorization");
8228        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
8229        assert_eq!(title_case_header("host"), "Host");
8230        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
8231        assert_eq!(title_case_header("single"), "Single");
8232        assert_eq!(title_case_header(""), "");
8233    }
8234
8235    #[test]
8236    fn test_resolve_url_combines_path_and_query_sources() {
8237        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
8238        let mut exchange = Exchange::new(Message::default());
8239        exchange.input.set_header(
8240            "CamelHttpPath",
8241            serde_json::Value::String("next".to_string()),
8242        );
8243        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8244        assert!(url.starts_with("http://example.com/base/next?"));
8245        assert!(url.contains("foo=bar"));
8246
8247        exchange.input.set_header(
8248            "CamelHttpUri",
8249            serde_json::Value::String("http://other.test/root".to_string()),
8250        );
8251        exchange.input.set_header(
8252            "CamelHttpQuery",
8253            serde_json::Value::String("a=1&b=2".to_string()),
8254        );
8255
8256        let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8257        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
8258    }
8259
8260    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
8261        let mut exchange = Exchange::new(Message::default());
8262        exchange
8263            .input
8264            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
8265        exchange.input.set_header(
8266            "CamelHttpQuery",
8267            serde_json::Value::String(query.to_string()),
8268        );
8269        exchange
8270    }
8271
8272    #[test]
8273    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
8274        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8275        cfg.bridge_endpoint = true;
8276        cfg.query_params
8277            .push(("token".to_string(), "secret".to_string()));
8278        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
8279        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8280        // Verbatim assembly: the old round-trip normalized the empty base
8281        // path to `/` (`http://x/?token=secret`); authored bytes end-to-end
8282        // no longer insert it.
8283        assert_eq!(url, "http://x?token=secret");
8284        assert!(!url.contains("/foo"));
8285        assert!(!url.contains("dropme"));
8286    }
8287
8288    #[test]
8289    fn resolve_url_bridge_endpoint_false_merges_path() {
8290        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8291        cfg.bridge_endpoint = false;
8292        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
8293        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8294        assert!(url.contains("/foo"), "url should contain /foo: {url}");
8295        assert!(
8296            url.contains("dropme=1"),
8297            "url should contain dropme=1: {url}"
8298        );
8299    }
8300
8301    #[test]
8302    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
8303        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8304        cfg.bridge_endpoint = true;
8305        let mut exchange = Exchange::new(Message::default());
8306        exchange.input.set_header(
8307            "CamelHttpPath",
8308            serde_json::Value::String("/foo".to_string()),
8309        );
8310        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8311        assert_eq!(url, "http://x");
8312        assert!(!url.contains("/foo"));
8313    }
8314
8315    #[test]
8316    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
8317        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8318        cfg.bridge_endpoint = true;
8319        // query_params stays empty ([])
8320        let mut exchange = Exchange::new(Message::default());
8321        exchange.input.set_header(
8322            "CamelHttpUri",
8323            serde_json::Value::String("http://dest/explicit".to_string()),
8324        );
8325        exchange.input.set_header(
8326            "CamelHttpPath",
8327            serde_json::Value::String("/foo".to_string()),
8328        );
8329        exchange.input.set_header(
8330            "CamelHttpQuery",
8331            serde_json::Value::String("x=1".to_string()),
8332        );
8333        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8334        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
8335        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
8336        // wins verbatim.
8337        assert_eq!(url, "http://x");
8338    }
8339
8340    #[test]
8341    fn bridge_programmatic_params_use_percent20() {
8342        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8343        cfg.bridge_endpoint = true;
8344        cfg.query_params = vec![("b".to_string(), "x y".to_string())];
8345        let exchange = Exchange::new(Message::default());
8346
8347        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8348
8349        // `%20 never +` is global for programmatic values — the bridge arm
8350        // uses the same encoder as the non-bridge path. Bridging
8351        // semantics (what gets bridged, precedence) are unchanged.
8352        assert_eq!(url, "http://x?b=x%20y");
8353        assert!(!url.contains('+'));
8354    }
8355
8356    #[test]
8357    fn bridge_arm_carries_authored_raw_query() {
8358        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8359        // bridgeEndpoint is consumed as an endpoint option; a=1 is the
8360        // authored leftover riding raw_query.
8361        let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
8362
8363        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8364
8365        // Authored leftovers ride under bridging (Apache Camel semantics):
8366        // query is a=1 in authored bytes; exchange path/query stay ignored.
8367        assert_eq!(url, "http://h/p?a=1");
8368        assert!(!url.contains("dropme"), "exchange query leaked: {url}");
8369        assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
8370    }
8371
8372    // -----------------------------------------------------------------------
8373    // Bridge arm verbatim assembly (Papal Direction A): the bridged base is
8374    // never round-tripped through `url::Url` normalization — authored bytes
8375    // end-to-end, identical assembly to every other resolve_url arm.
8376    // -----------------------------------------------------------------------
8377
8378    #[test]
8379    fn resolve_url_bridge_preserves_dot_segments() {
8380        let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
8381        cfg.bridge_endpoint = true;
8382        cfg.query_params.push(("k".to_string(), "1".to_string()));
8383        let exchange = Exchange::new(Message::default());
8384
8385        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8386
8387        // Dot segments are authored bytes; the old round-trip collapsed
8388        // them (`/a/../b` → `/b`). Verbatim keeps them.
8389        assert_eq!(url, "http://h/a/../b?k=1");
8390    }
8391
8392    #[test]
8393    fn resolve_url_bridge_preserves_default_port() {
8394        let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
8395        cfg.bridge_endpoint = true;
8396        cfg.query_params.push(("k".to_string(), "1".to_string()));
8397        let exchange = Exchange::new(Message::default());
8398
8399        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8400
8401        // The old round-trip stripped the default port `:80`. Verbatim
8402        // keeps it.
8403        assert_eq!(url, "http://h:80/p?k=1");
8404    }
8405
8406    #[test]
8407    fn resolve_url_bridge_preserves_scheme_and_host_case() {
8408        let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
8409        cfg.bridge_endpoint = true;
8410        cfg.query_params.push(("k".to_string(), "1".to_string()));
8411        // `from_uri`'s scheme validation is case-sensitive, so the scheme
8412        // case is applied on the stored base directly — the resolve path
8413        // must carry whatever bytes the operator authored.
8414        cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
8415        let exchange = Exchange::new(Message::default());
8416
8417        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8418
8419        // The old round-trip lowercased scheme and host. Verbatim keeps
8420        // both authored.
8421        assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
8422    }
8423
8424    #[test]
8425    fn resolve_url_bridge_no_query_emits_base_verbatim() {
8426        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8427        cfg.bridge_endpoint = true;
8428        let exchange = Exchange::new(Message::default());
8429
8430        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8431
8432        // No resolved query: exactly the authored base — no synthetic `/`,
8433        // no dangling `?`.
8434        assert_eq!(url, "http://h/p");
8435    }
8436
8437    #[test]
8438    fn resolve_url_bridge_and_non_bridge_byte_identical() {
8439        // (a) Bridged arm: the effective query comes from programmatic
8440        // query_params.
8441        let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8442        bridged.bridge_endpoint = true;
8443        bridged
8444            .query_params
8445            .push(("k".to_string(), "1".to_string()));
8446        let bridge_url =
8447            HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
8448
8449        // (b) Non-bridge CamelHttpQuery composition path: same effective
8450        // query riding the exchange header.
8451        let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8452        let mut exchange = Exchange::new(Message::default());
8453        exchange.input.set_header(
8454            "CamelHttpQuery",
8455            serde_json::Value::String("k=1".to_string()),
8456        );
8457        let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
8458
8459        assert_eq!(bridge_url, plain_url);
8460        assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
8461    }
8462
8463    #[test]
8464    fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
8465        let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
8466        cfg.bridge_endpoint = true;
8467        cfg.query_params.push(("k".to_string(), "1".to_string()));
8468        let exchange = Exchange::new(Message::default());
8469
8470        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8471
8472        assert_eq!(url, "http://[::1]:8080/p?k=1");
8473    }
8474
8475    #[test]
8476    fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
8477        let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
8478        let exchange = Exchange::new(Message::default());
8479
8480        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8481
8482        // Authored query on an empty base path: the old round-trip
8483        // inserted a synthetic `/` (`http://h/?x=1`); verbatim does not.
8484        assert_eq!(url, "http://h?x=1");
8485    }
8486
8487    // -----------------------------------------------------------------------
8488    // Raw-preserving outbound query serialization (http-query-wire-fidelity)
8489    // -----------------------------------------------------------------------
8490
8491    #[test]
8492    fn resolve_url_preserves_authored_query_order_and_bytes() {
8493        let config =
8494            HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
8495        let exchange = Exchange::new(Message::default());
8496
8497        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8498
8499        // Authored order, authored separators, no %2C/%3A re-encoding,
8500        // consumed option (connectTimeout) removed.
8501        assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
8502    }
8503
8504    #[test]
8505    fn resolve_url_consumes_encoded_option_key() {
8506        let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
8507        let exchange = Exchange::new(Message::default());
8508
8509        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8510
8511        // The raw filter matches the decoded key, not the encoded bytes.
8512        assert_eq!(url, "http://h/p?a=1");
8513    }
8514
8515    #[test]
8516    fn resolve_url_all_options_consumed_drops_query() {
8517        let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
8518        let exchange = Exchange::new(Message::default());
8519
8520        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8521
8522        // A non-empty query whose every pair was consumed drops the query
8523        // component entirely — no dangling `?`.
8524        assert_eq!(url, "http://h/p");
8525        assert!(!url.contains('?'));
8526    }
8527
8528    #[test]
8529    fn resolve_url_preserves_empty_query_marker() {
8530        let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
8531        let exchange = Exchange::new(Message::default());
8532
8533        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8534
8535        // A bare `?` marker is preserved distinctly, never conflated with
8536        // an all-consumed query.
8537        assert_eq!(url, "http://h/p?");
8538    }
8539
8540    #[test]
8541    fn resolve_url_raw_wrapper_not_re_encoded() {
8542        let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
8543        let exchange = Exchange::new(Message::default());
8544
8545        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8546
8547        // RAW(...) wrapper bytes survive exactly as authored (rc-g4isv).
8548        assert_eq!(url, "http://h/p?token=RAW(abc)");
8549        assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
8550    }
8551
8552    #[test]
8553    fn resolve_url_camel_http_query_composes_verbatim_span() {
8554        let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
8555        let mut exchange = Exchange::new(Message::default());
8556        exchange.input.set_header(
8557            "CamelHttpQuery",
8558            serde_json::Value::String("userFilter=a%2Cb".to_string()),
8559        );
8560
8561        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8562
8563        // Policy change (ADR-0071): the header no longer replaces the
8564        // endpoint query — it composes, the endpoint winning collisions.
8565        // The header span bytes still ride verbatim: `a%2Cb` is carried
8566        // as-authored, never re-encoded (no %252C).
8567        assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
8568        assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
8569    }
8570
8571    // -----------------------------------------------------------------------
8572    // Outbound query composition (http-contract-surface, ADR-0071)
8573    // -----------------------------------------------------------------------
8574
8575    #[test]
8576    fn header_composes_with_endpoint_query() {
8577        let config =
8578            HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
8579        let mut exchange = Exchange::new(Message::default());
8580        exchange.input.set_header(
8581            "CamelHttpQuery",
8582            serde_json::Value::String("lang=es&page=2".to_string()),
8583        );
8584
8585        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8586
8587        // Higher precedence (endpoint) wins collisions: `lang` stays `en`;
8588        // the header appends only its absent keys.
8589        assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
8590    }
8591
8592    #[test]
8593    fn header_alone_still_rides() {
8594        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8595        let mut exchange = Exchange::new(Message::default());
8596        exchange.input.set_header(
8597            "CamelHttpQuery",
8598            serde_json::Value::String("page=2".to_string()),
8599        );
8600
8601        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8602
8603        // No endpoint query: the header pairs are the whole query.
8604        assert_eq!(url, "http://upstream/api?page=2");
8605    }
8606
8607    #[test]
8608    fn empty_reflected_query_leaves_endpoint_query_intact() {
8609        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8610        let mut exchange = Exchange::new(Message::default());
8611        // The consumer installs an empty CamelHttpQuery on requests that
8612        // arrived without a query string.
8613        exchange
8614            .input
8615            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8616
8617        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8618
8619        // No second `?` marker, no dropped endpoint pair.
8620        assert_eq!(url, "http://upstream/api?apiKey=secret");
8621        assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
8622    }
8623
8624    #[test]
8625    fn forbidden_byte_in_header_query_errors() {
8626        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8627        let mut exchange = Exchange::new(Message::default());
8628        exchange.input.set_header(
8629            "CamelHttpQuery",
8630            serde_json::Value::String("q=ab<cd".to_string()),
8631        );
8632
8633        let err = HttpProducer::resolve_url(&exchange, &config)
8634            .unwrap_err()
8635            .to_string();
8636
8637        // Fail loud naming the forbidden byte (`<` = 0x3C); a resolve
8638        // error means no URL is emitted, never a re-encoded one.
8639        assert!(err.contains("0x3C"), "error must name the byte: {err}");
8640    }
8641
8642    #[test]
8643    fn override_uri_with_query_plus_header_query() {
8644        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8645        let mut exchange = Exchange::new(Message::default());
8646        exchange.input.set_header(
8647            "CamelHttpUri",
8648            serde_json::Value::String("http://host/api?a=1".to_string()),
8649        );
8650        exchange.input.set_header(
8651            "CamelHttpQuery",
8652            serde_json::Value::String("a=2&b=3".to_string()),
8653        );
8654
8655        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8656
8657        // Pair-level merge with a single `?`: the override's `a=1` wins
8658        // the collision, the header appends `b=3` — no `?a=1?a=2` concat.
8659        assert_eq!(url, "http://host/api?a=1&b=3");
8660    }
8661
8662    #[test]
8663    fn path_applies_before_query_composition() {
8664        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8665        let mut exchange = Exchange::new(Message::default());
8666        exchange.input.set_header(
8667            "CamelHttpUri",
8668            serde_json::Value::String("http://host/api?a=1".to_string()),
8669        );
8670        exchange.input.set_header(
8671            "CamelHttpPath",
8672            serde_json::Value::String("/extra".to_string()),
8673        );
8674        exchange.input.set_header(
8675            "CamelHttpQuery",
8676            serde_json::Value::String("b=2".to_string()),
8677        );
8678
8679        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8680
8681        // CamelHttpPath applies to the override base without its query,
8682        // then the query composes.
8683        assert_eq!(url, "http://host/api/extra?a=1&b=2");
8684    }
8685
8686    #[test]
8687    fn plain_proxy_reflection_composes() {
8688        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8689        // Headers as the consumer installs them from the wire.
8690        let exchange = exchange_with_path_and_query("/in/extra", "page=2");
8691
8692        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8693
8694        // Reflection rides by default and composes: the operator pair is
8695        // not replaced (rc-k3pir parity).
8696        assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
8697    }
8698
8699    #[test]
8700    fn bridge_endpoint_ignores_url_headers() {
8701        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8702        let mut exchange = Exchange::new(Message::default());
8703        exchange.input.set_header(
8704            "CamelHttpUri",
8705            serde_json::Value::String("http://evil.test/x".to_string()),
8706        );
8707        exchange.input.set_header(
8708            "CamelHttpPath",
8709            serde_json::Value::String("/foo".to_string()),
8710        );
8711        exchange.input.set_header(
8712            "CamelHttpQuery",
8713            serde_json::Value::String("z=9".to_string()),
8714        );
8715
8716        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8717
8718        // All three URL headers ignored; the endpoint base plus its own
8719        // (consumed-option-filtered) query is sent, exactly as before.
8720        assert_eq!(url, "http://h/p?a=1");
8721        assert!(!url.contains("evil"), "override leaked: {url}");
8722        assert!(!url.contains("z=9"), "header query leaked: {url}");
8723        assert!(!url.contains("/foo"), "header path leaked: {url}");
8724    }
8725
8726    #[test]
8727    fn resolve_url_programmatic_params_use_percent20_deterministic() {
8728        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8729        config.query_params = vec![
8730            ("b".to_string(), "x y".to_string()),
8731            ("a".to_string(), "1".to_string()),
8732        ];
8733        let exchange = Exchange::new(Message::default());
8734
8735        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8736
8737        // Declaration order (not lexical), minimal RFC-3986 encoding,
8738        // `%20` — never `+` — for spaces.
8739        assert_eq!(url, "http://h/p?b=x%20y&a=1");
8740        assert!(!url.contains('+'));
8741    }
8742
8743    #[test]
8744    fn resolve_url_authored_and_programmatic_merge() {
8745        let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
8746        config.query_params = vec![
8747            ("b".to_string(), "2".to_string()),
8748            ("a".to_string(), "9".to_string()),
8749        ];
8750        let exchange = Exchange::new(Message::default());
8751
8752        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8753
8754        // Programmatic `b` appended (absent from raw); programmatic `a=9`
8755        // ignored (authored key wins); no duplication.
8756        assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
8757    }
8758
8759    #[test]
8760    fn from_uri_no_longer_fills_query_params_from_uri() {
8761        let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
8762
8763        // Authored pairs live in raw_query ONLY (provenance pin).
8764        assert!(
8765            config.query_params.is_empty(),
8766            "query_params is programmatic-only: {:?}",
8767            config.query_params
8768        );
8769        assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
8770    }
8771
8772    #[test]
8773    fn resolve_url_forbidden_raw_byte_errors() {
8774        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8775        config.raw_query = Some("a=x y".to_string());
8776        let exchange = Exchange::new(Message::default());
8777
8778        let err = HttpProducer::resolve_url(&exchange, &config)
8779            .expect_err("literal space in raw query must error");
8780
8781        // The error names the forbidden byte; no output string is produced.
8782        assert!(
8783            err.to_string().contains("0x20"),
8784            "error must name the forbidden byte: {err}"
8785        );
8786    }
8787
8788    /// rc-m4xk1: the override URI's own query is span-validated at resolve
8789    /// time — a forbidden byte in the override arm errors naming the byte,
8790    /// instead of riding verbatim to a reqwest send error.
8791    #[test]
8792    fn resolve_url_override_query_forbidden_byte_errors() {
8793        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8794        let mut exchange = Exchange::new(Message::default());
8795        exchange.input.set_header(
8796            "CamelHttpUri",
8797            serde_json::Value::String("http://h2/p?a=x y".to_string()),
8798        );
8799
8800        let err = HttpProducer::resolve_url(&exchange, &config)
8801            .expect_err("literal space in the override URI's query must error");
8802
8803        assert!(
8804            err.to_string().contains("0x20"),
8805            "error must name the forbidden byte from the override query: {err}"
8806        );
8807    }
8808
8809    /// rc-m4xk1 pin: decoded-key collision — a header pair whose key decodes
8810    /// to a key already present in the higher-precedence query (here
8811    /// `%61=2`, decoding to `a`) is dropped by the shared decoded-key
8812    /// matching; the higher-precedence authored span rides verbatim.
8813    #[test]
8814    fn merge_header_query_decoded_key_collision_drops_header_pair() {
8815        let merged = merge_header_query(Some("a=1"), "%61=2")
8816            .expect("decoded-key collision must not be a parse error");
8817        assert_eq!(
8818            merged.as_deref(),
8819            Some("a=1"),
8820            "the higher-precedence span wins and the colliding header pair is dropped"
8821        );
8822    }
8823
8824    /// rc-m4xk1 pin: duplicate keys within ONE header query are not
8825    /// deduplicated — both spans ride verbatim in authored order.
8826    #[test]
8827    fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
8828        let merged = merge_header_query(None, "k=1&k=2")
8829            .expect("duplicate header keys must not be a parse error");
8830        assert_eq!(
8831            merged.as_deref(),
8832            Some("k=1&k=2"),
8833            "intra-header duplicate keys ride verbatim"
8834        );
8835    }
8836
8837    /// rc-dhkeo: the Debug surface masks userinfo-style bytes in
8838    /// `base_url` and leaves a userinfo-free base untouched, byte-for-byte.
8839    #[test]
8840    fn endpoint_config_debug_masks_base_url_userinfo() {
8841        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
8842        config.base_url = "http://user:pass@h.example/p".to_string();
8843        let rendered = format!("{config:?}");
8844        assert!(
8845            rendered.contains("***@h.example"),
8846            "userinfo must render masked: {rendered}"
8847        );
8848        assert!(
8849            !rendered.contains("user:pass"),
8850            "no credentials in Debug output: {rendered}"
8851        );
8852
8853        let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
8854        let rendered_plain = format!("{plain:?}");
8855        assert!(
8856            rendered_plain.contains("http://h.example/p"),
8857            "a base without userinfo renders unchanged: {rendered_plain}"
8858        );
8859    }
8860
8861    /// rc-nmupb: authored apostrophe (0x27) is RFC 3986 pchar-legal, but
8862    /// reqwest's WHATWG parser re-encodes it as `%27` in every http/https
8863    /// query — the raw byte can never ride the wire verbatim. Resolve
8864    /// rejects it naming the byte; the authored `%27` escape is the
8865    /// wire-faithful form and rides verbatim.
8866    #[test]
8867    fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
8868        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8869
8870        config.raw_query = Some("q=it's".to_string());
8871        let exchange = Exchange::new(Message::default());
8872        let err = HttpProducer::resolve_url(&exchange, &config)
8873            .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
8874        assert!(
8875            err.to_string().contains("0x27"),
8876            "error must name the apostrophe byte: {err}"
8877        );
8878
8879        config.raw_query = Some("q=it%27s".to_string());
8880        let url = HttpProducer::resolve_url(&exchange, &config)
8881            .expect("authored %27 escape is wire-legal");
8882        assert!(
8883            url.contains("q=it%27s"),
8884            "the authored escape must ride byte-for-byte: {url}"
8885        );
8886
8887        // The rest of reqwest's WHATWG special-query set shares the same
8888        // rationale and is rejected alongside (`"` and backtick are not
8889        // RFC 3986 query-legal bytes; `<`/`>` likewise).
8890        for &byte in b"\"`<>" {
8891            config.raw_query = Some(format!("k={}x", byte as char));
8892            let err = HttpProducer::resolve_url(&exchange, &config)
8893                .expect_err("WHATWG special-query byte must be rejected");
8894            assert!(
8895                err.to_string().contains(&format!("0x{byte:02X}")),
8896                "error must name byte 0x{byte:02X}: {err}"
8897            );
8898        }
8899    }
8900
8901    #[test]
8902    fn armed_fence_rejects_unknown_host_redacted() {
8903        let cfg = HttpEndpointConfig::from_uri(
8904            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8905        )
8906        .unwrap();
8907        let mut exchange = Exchange::new(Message::default());
8908        exchange.input.set_header(
8909            "CamelHttpUri",
8910            serde_json::Value::String(
8911                "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
8912            ),
8913        );
8914
8915        let err = HttpProducer::resolve_url(&exchange, &cfg)
8916            .expect_err("override host outside the fence must fail resolution");
8917
8918        let message = err.to_string();
8919        assert!(!message.contains("pass"), "userinfo leaked: {message}");
8920        assert!(!message.contains("s3cret"), "query leaked: {message}");
8921    }
8922
8923    #[test]
8924    fn armed_fence_rejects_unparseable_override_redacted() {
8925        let cfg = HttpEndpointConfig::from_uri(
8926            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8927        )
8928        .unwrap();
8929        let mut exchange = Exchange::new(Message::default());
8930        exchange.input.set_header(
8931            "CamelHttpUri",
8932            serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
8933        );
8934
8935        let err = HttpProducer::resolve_url(&exchange, &cfg)
8936            .expect_err("unparseable override outside the fence must fail resolution");
8937
8938        let message = err.to_string();
8939        assert!(
8940            message.contains("allowedUriHosts fence"),
8941            "fence must be named: {message}"
8942        );
8943        assert!(
8944            message.contains("[redacted]"),
8945            "suppression sentinel missing: {message}"
8946        );
8947        assert!(
8948            !message.contains("evil.example.com"),
8949            "host leaked: fail-closed arm must render only the sentinel: {message}"
8950        );
8951        assert!(
8952            !message.contains("fencesecret"),
8953            "password leaked: {message}"
8954        );
8955        assert!(!message.contains("u:"), "userinfo leaked: {message}");
8956    }
8957
8958    #[test]
8959    fn armed_fence_rejects_password_only_userinfo_redacted() {
8960        let cfg = HttpEndpointConfig::from_uri(
8961            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8962        )
8963        .unwrap();
8964        let mut exchange = Exchange::new(Message::default());
8965        exchange.input.set_header(
8966            "CamelHttpUri",
8967            serde_json::Value::String(
8968                "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
8969            ),
8970        );
8971
8972        let err = HttpProducer::resolve_url(&exchange, &cfg)
8973            .expect_err("password-only override outside the fence must fail resolution");
8974
8975        let message = err.to_string();
8976        assert!(
8977            !message.contains("passwordonly"),
8978            "password-only userinfo leaked: {message}"
8979        );
8980        assert!(!message.contains("querysecret"), "query leaked: {message}");
8981        assert!(
8982            message.contains("http://***@evil.example.com/x?[redacted]"),
8983            "masked shape missing: {message}"
8984        );
8985    }
8986
8987    #[test]
8988    fn armed_fence_allows_listed_host() {
8989        let cfg = HttpEndpointConfig::from_uri(
8990            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8991        )
8992        .unwrap();
8993        let mut exchange = Exchange::new(Message::default());
8994        exchange.input.set_header(
8995            "CamelHttpUri",
8996            serde_json::Value::String("http://cdn.example.com/x".to_string()),
8997        );
8998
8999        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9000        assert_eq!(url, "http://cdn.example.com/x");
9001    }
9002
9003    #[test]
9004    fn host_only_entry_permits_any_port() {
9005        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
9006        let mut exchange = Exchange::new(Message::default());
9007        exchange.input.set_header(
9008            "CamelHttpUri",
9009            serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
9010        );
9011
9012        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9013        assert_eq!(url, "http://cdn.example.com:9443/x");
9014    }
9015
9016    #[test]
9017    fn unarmed_endpoint_unchanged() {
9018        let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9019        let mut exchange = Exchange::new(Message::default());
9020        exchange.input.set_header(
9021            "CamelHttpUri",
9022            serde_json::Value::String("http://any.example.com/path".to_string()),
9023        );
9024
9025        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9026        assert_eq!(url, "http://any.example.com/path");
9027    }
9028
9029    #[test]
9030    fn empty_allowlist_fails_endpoint_creation() {
9031        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
9032    }
9033
9034    #[test]
9035    fn malformed_entry_fails_endpoint_creation() {
9036        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
9037    }
9038
9039    #[test]
9040    fn fence_entry_with_path_fails_creation() {
9041        // A trailing path is a typo'd entry: silently narrowing it to the
9042        // hostname would widen or skew the fence. Reject loudly.
9043        assert!(
9044            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
9045        );
9046    }
9047
9048    #[test]
9049    fn fence_entry_with_userinfo_fails_creation() {
9050        assert!(
9051            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
9052        );
9053    }
9054
9055    #[test]
9056    fn ipv6_fence_entry_allows_bracketed_host() {
9057        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
9058        // The textual host forms differ; both parse to the same bracketed
9059        // canonical host (`[::1]`) that the entry stores, so both ride.
9060        for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
9061            let mut exchange = Exchange::new(Message::default());
9062            exchange
9063                .input
9064                .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
9065            let url = HttpProducer::resolve_url(&exchange, &cfg)
9066                .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
9067            assert_eq!(url, uri, "bracketed IPv6 override not honored");
9068        }
9069    }
9070
9071    #[test]
9072    fn dns_case_insensitive_fence_match() {
9073        // The entry is stored ASCII-lowercased, so the mixed-case option
9074        // matches the lowercase override host.
9075        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
9076        let mut exchange = Exchange::new(Message::default());
9077        exchange.input.set_header(
9078            "CamelHttpUri",
9079            serde_json::Value::String("http://cdn.example.com/x".to_string()),
9080        );
9081        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9082        assert_eq!(url, "http://cdn.example.com/x");
9083    }
9084
9085    #[test]
9086    fn fence_allowed_override_query_merges_with_header() {
9087        // Fence pass plus full composition: the override URI query is the
9088        // higher-precedence source, the header pair appends.
9089        let cfg =
9090            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
9091        let mut exchange = Exchange::new(Message::default());
9092        exchange.input.set_header(
9093            "CamelHttpUri",
9094            serde_json::Value::String("http://host.example/api?a=1".to_string()),
9095        );
9096        exchange.input.set_header(
9097            "CamelHttpQuery",
9098            serde_json::Value::String("b=2".to_string()),
9099        );
9100
9101        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9102        assert_eq!(url, "http://host.example/api?a=1&b=2");
9103    }
9104
9105    #[test]
9106    fn empty_header_with_armed_fence_leaves_no_query() {
9107        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
9108        let mut exchange = Exchange::new(Message::default());
9109        exchange.input.set_header(
9110            "CamelHttpUri",
9111            serde_json::Value::String("http://host.example/api".to_string()),
9112        );
9113        exchange
9114            .input
9115            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9116
9117        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9118        assert_eq!(url, "http://host.example/api");
9119        assert!(!url.contains('?'), "query marker leaked: {url}");
9120    }
9121
9122    #[test]
9123    fn fence_option_is_consumed() {
9124        // A raw query on the base URI plus the fence option; no override
9125        // header. The option is consumed at parse time and must never
9126        // appear in the outbound query.
9127        let cfg =
9128            HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
9129        let exchange = Exchange::new(Message::default());
9130
9131        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9132        assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
9133        assert!(url.contains("x=1"), "authored query lost: {url}");
9134    }
9135
9136    #[tokio::test]
9137    async fn resolve_url_malformed_base_url_errors_no_panic() {
9138        use tower::ServiceExt;
9139
9140        let (url, _handle) = start_test_server().await;
9141        let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
9142        config.allow_internal = true; // test server binds 127.0.0.1
9143        let producer = HttpProducer {
9144            config: Arc::new(config),
9145            client: build_client(&HttpConfig::default(), None),
9146            pinned_cache: Arc::new(PinnedClientCache::new(
9147                PINNED_CLIENT_TTL,
9148                PINNED_CLIENT_MAX_ENTRIES,
9149            )),
9150            http_config: Arc::new(HttpConfig::default()),
9151            runtime: rt(),
9152        };
9153
9154        // First call: malformed base URL propagates as an error through the
9155        // real producer path — no panic, no poisoned state (rc-ph7z2).
9156        let first = producer
9157            .clone()
9158            .oneshot(Exchange::new(Message::default()))
9159            .await;
9160        let err = first.expect_err("malformed base URL must error, not panic");
9161        assert!(
9162            err.to_string().to_lowercase().contains("url"),
9163            "error must name the malformed URL: {err}"
9164        );
9165
9166        // Second call through the SAME producer succeeds — the failure
9167        // left no poisoned state.
9168        let mut exchange = Exchange::new(Message::default());
9169        exchange.input.set_header(
9170            "CamelHttpUri",
9171            serde_json::Value::String(format!("{url}/api")),
9172        );
9173        let response = producer
9174            .oneshot(exchange)
9175            .await
9176            .expect("valid request through same producer must succeed");
9177        let status = response
9178            .input
9179            .header("CamelHttpResponseCode")
9180            .and_then(|v| v.as_u64())
9181            .unwrap();
9182        assert_eq!(status, 200);
9183    }
9184
9185    #[test]
9186    fn resolve_url_bridge_malformed_base_errors_no_panic() {
9187        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9188        cfg.bridge_endpoint = true;
9189        cfg.query_params.push(("k".to_string(), "1".to_string()));
9190        // `from_uri` rejects the malformed authority, so the base is set on
9191        // the stored config directly (same build shape as the scheme-case
9192        // test). The bridge arm's validation-only parse (rc-ph7z2) must
9193        // surface it as an error — no panic.
9194        cfg.base_url = "http://[::1:bad".to_string();
9195        let exchange = Exchange::new(Message::default());
9196
9197        let err = HttpProducer::resolve_url(&exchange, &cfg)
9198            .expect_err("malformed bridge base URL must error");
9199        assert!(
9200            err.to_string().contains("invalid base URL"),
9201            "error must name the invalid base URL: {err}"
9202        );
9203    }
9204
9205    #[test]
9206    fn test_http_producer_helpers_status_and_size_boundaries() {
9207        assert!(HttpProducer::is_ok_status(200, (200, 299)));
9208        assert!(HttpProducer::is_ok_status(299, (200, 299)));
9209        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
9210        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
9211
9212        assert!(!exceeds_max_response_body(10, 10));
9213        assert!(exceeds_max_response_body(11, 10));
9214    }
9215
9216    // -----------------------------------------------------------------------
9217    // Content-Type inference tests
9218    // -----------------------------------------------------------------------
9219
9220    #[allow(clippy::await_holding_lock)]
9221    async fn setup_consumer_on_free_port(
9222        path: &str,
9223    ) -> (
9224        u16,
9225        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
9226        tokio_util::sync::CancellationToken,
9227    ) {
9228        use camel_component_api::ConsumerContext;
9229
9230        // ADR-0070 staged-listener law: bind, KEEP the socket, and stage it
9231        // in the ServerRegistry; the consumer's `get_or_spawn` consumes the
9232        // staged listener, so the port never returns to the ephemeral pool
9233        // between probe and serve (no bind-read-drop race).
9234        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9235        let port = listener.local_addr().unwrap().port();
9236
9237        // Hold the registry test mutex across the whole stage→spawn→ready
9238        // window so a concurrent `ServerRegistry::reset()` cannot evict the
9239        // staged listener between staging and readiness. The guard covers
9240        // stage_listener, the consumer spawn, the readiness poll and the
9241        // tail-yield loop; it releases when this helper returns.
9242        // Poison-recovering acquire: a failed sibling test must not
9243        // cascade — the mutex guards test serialization only, no
9244        // structural invariant, so recovery via into_inner is safe.
9245        let _registry_guard = lock_registry_test_mutex();
9246
9247        ServerRegistry::global()
9248            .stage_listener(listener)
9249            .await
9250            .expect("stage consumer test listener");
9251
9252        let consumer_cfg = HttpServerConfig {
9253            scheme: "http".to_string(),
9254            host: "127.0.0.1".to_string(),
9255            port,
9256            path: path.to_string(),
9257            max_request_body: 2 * 1024 * 1024,
9258            max_response_body: 10 * 1024 * 1024,
9259            max_inflight_requests: 1024,
9260            method: None,
9261            tls_config: None,
9262        };
9263        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9264
9265        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9266        let token = tokio_util::sync::CancellationToken::new();
9267        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9268
9269        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9270
9271        // Readiness without a fixed wall-clock sleep: poll the registry
9272        // entry live (1ms doubling backoff, 10s deadline), then yield so
9273        // the spawned `start()` completes route registration (that tail
9274        // path has no pending timers — only the registry lock — so
9275        // scheduler yields order it deterministically behind this loop).
9276        wait_for_registry_ready("127.0.0.1", port).await;
9277        for _ in 0..8 {
9278            tokio::task::yield_now().await;
9279        }
9280
9281        (port, rx, token)
9282    }
9283
9284    /// Poll `ServerRegistry::bound_addr(host, port)` until the entry
9285    /// appears: 1ms backoff doubling per iteration, capped at 64ms, with
9286    /// a 10s deadline. Panics with a hint naming the likely causes when
9287    /// the deadline fires.
9288    async fn wait_for_registry_ready(host: &str, port: u16) {
9289        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
9290        let mut backoff = std::time::Duration::from_millis(1);
9291        while ServerRegistry::global().bound_addr(host, port).is_none() {
9292            assert!(
9293                tokio::time::Instant::now() < deadline,
9294                "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
9295            );
9296            tokio::time::sleep(backoff).await;
9297            backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
9298        }
9299    }
9300
9301    #[tokio::test]
9302    #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
9303    async fn readiness_deadline_fires_loud_with_hint() {
9304        // Poll a key no writer can produce. Registry keys come from
9305        // either the listener's resolved IP string (staged path) or the
9306        // caller-provided host verbatim (legacy get_or_spawn path), so a
9307        // synthetic host literal that no test passes is unreachable on
9308        // BOTH paths. Binding and HOLDING the listener (never dropped,
9309        // never staged) additionally keeps its port out of the ephemeral
9310        // pool, so no concurrent test can register that port either.
9311        // (Earlier drafts polled 127.0.0.2 — rejected: macOS exposes only
9312        // 127.0.0.1 and the bind fails there, rc-dwmd; and "localhost" —
9313        // rejected: the legacy host-verbatim path could produce it.)
9314        let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9315        let port = held.local_addr().unwrap().port();
9316        wait_for_registry_ready("httpflake-unreachable-host", port).await;
9317    }
9318
9319    // -----------------------------------------------------------------------
9320    // Readiness vs concurrent registry reset (httpflake, regression RED)
9321    // -----------------------------------------------------------------------
9322
9323    #[tokio::test]
9324    async fn readiness_survives_concurrent_registry_reset() {
9325        let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
9326        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
9327
9328        // Hammer thread: loop legal resets, counting a contention whenever
9329        // its try-lock on the registry test mutex blocks (someone else held
9330        // it). The guard is dropped at each iteration end.
9331        let contended_hammer = std::sync::Arc::clone(&contended);
9332        let stop_hammer = std::sync::Arc::clone(&stop);
9333        let handle = std::thread::spawn(move || {
9334            while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
9335                let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
9336                    Err(_) => {
9337                        contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
9338                        lock_registry_test_mutex()
9339                    }
9340                    Ok(guard) => guard,
9341                };
9342                ServerRegistry::reset();
9343            }
9344        });
9345
9346        // Drop guard: even if a setup panics, stop the hammer and join it so
9347        // the thread never outlives the test.
9348        struct StopHammerOnDrop {
9349            handle: Option<std::thread::JoinHandle<()>>,
9350            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
9351        }
9352        impl Drop for StopHammerOnDrop {
9353            fn drop(&mut self) {
9354                self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
9355                if let Some(handle) = self.handle.take() {
9356                    let _ = handle.join();
9357                }
9358            }
9359        }
9360        let _hammer_guard = StopHammerOnDrop {
9361            handle: Some(handle),
9362            stop,
9363        };
9364
9365        // Always at least 25 setups on fresh ephemeral ports; continue past
9366        // 25 only until one contended reset is observed; hard cap 50.
9367        let mut setups = 0;
9368        loop {
9369            setups += 1;
9370            let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
9371            drop(rx);
9372            token.cancel();
9373            if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
9374                || setups >= 50
9375            {
9376                break;
9377            }
9378        }
9379
9380        let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
9381        assert!(
9382            contended_hits >= 1,
9383            "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
9384        );
9385    }
9386
9387    #[tokio::test]
9388    async fn test_content_type_inferred_for_json_body() {
9389        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
9390
9391        let client = reqwest::Client::new();
9392        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
9393
9394        let (http_result, _) = tokio::join!(send_fut, async {
9395            if let Some(mut envelope) = rx.recv().await {
9396                envelope.exchange.input.body =
9397                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
9398                if let Some(reply_tx) = envelope.reply_tx {
9399                    let _ = reply_tx.send(Ok(envelope.exchange));
9400                }
9401            }
9402        });
9403
9404        let resp = http_result.unwrap();
9405        assert_eq!(resp.status().as_u16(), 200);
9406        let ct = resp
9407            .headers()
9408            .get("content-type")
9409            .expect("Content-Type header should be present");
9410        assert_eq!(ct, "application/json");
9411        let body = resp.text().await.unwrap();
9412        assert_eq!(body, r#"{"message":"hello"}"#);
9413
9414        token.cancel();
9415    }
9416
9417    #[tokio::test]
9418    async fn test_content_type_inferred_for_text_body() {
9419        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
9420
9421        let client = reqwest::Client::new();
9422        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
9423
9424        let (http_result, _) = tokio::join!(send_fut, async {
9425            if let Some(mut envelope) = rx.recv().await {
9426                envelope.exchange.input.body =
9427                    camel_component_api::Body::Text("plain text response".to_string());
9428                if let Some(reply_tx) = envelope.reply_tx {
9429                    let _ = reply_tx.send(Ok(envelope.exchange));
9430                }
9431            }
9432        });
9433
9434        let resp = http_result.unwrap();
9435        assert_eq!(resp.status().as_u16(), 200);
9436        let ct = resp
9437            .headers()
9438            .get("content-type")
9439            .expect("Content-Type header should be present");
9440        assert_eq!(ct, "text/plain; charset=utf-8");
9441        let body = resp.text().await.unwrap();
9442        assert_eq!(body, "plain text response");
9443
9444        token.cancel();
9445    }
9446
9447    #[tokio::test]
9448    async fn test_content_type_inferred_for_xml_body() {
9449        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
9450
9451        let client = reqwest::Client::new();
9452        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
9453
9454        let (http_result, _) = tokio::join!(send_fut, async {
9455            if let Some(mut envelope) = rx.recv().await {
9456                envelope.exchange.input.body =
9457                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
9458                if let Some(reply_tx) = envelope.reply_tx {
9459                    let _ = reply_tx.send(Ok(envelope.exchange));
9460                }
9461            }
9462        });
9463
9464        let resp = http_result.unwrap();
9465        assert_eq!(resp.status().as_u16(), 200);
9466        let ct = resp
9467            .headers()
9468            .get("content-type")
9469            .expect("Content-Type header should be present");
9470        assert_eq!(ct, "application/xml");
9471        let body = resp.text().await.unwrap();
9472        assert_eq!(body, "<root><item>value</item></root>");
9473
9474        token.cancel();
9475    }
9476
9477    #[tokio::test]
9478    async fn test_no_content_type_for_empty_body() {
9479        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
9480
9481        let client = reqwest::Client::new();
9482        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
9483
9484        let (http_result, _) = tokio::join!(send_fut, async {
9485            if let Some(mut envelope) = rx.recv().await {
9486                envelope.exchange.input.body = camel_component_api::Body::Empty;
9487                if let Some(reply_tx) = envelope.reply_tx {
9488                    let _ = reply_tx.send(Ok(envelope.exchange));
9489                }
9490            }
9491        });
9492
9493        let resp = http_result.unwrap();
9494        assert_eq!(resp.status().as_u16(), 200);
9495        assert!(
9496            resp.headers().get("content-type").is_none(),
9497            "Empty body should not set Content-Type"
9498        );
9499
9500        token.cancel();
9501    }
9502
9503    #[tokio::test]
9504    async fn test_no_content_type_for_raw_bytes_body() {
9505        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
9506
9507        let client = reqwest::Client::new();
9508        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
9509
9510        let (http_result, _) = tokio::join!(send_fut, async {
9511            if let Some(mut envelope) = rx.recv().await {
9512                envelope.exchange.input.body =
9513                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
9514                if let Some(reply_tx) = envelope.reply_tx {
9515                    let _ = reply_tx.send(Ok(envelope.exchange));
9516                }
9517            }
9518        });
9519
9520        let resp = http_result.unwrap();
9521        assert_eq!(resp.status().as_u16(), 200);
9522        assert!(
9523            resp.headers().get("content-type").is_none(),
9524            "Raw Bytes body should not set Content-Type"
9525        );
9526
9527        token.cancel();
9528    }
9529
9530    #[tokio::test]
9531    async fn test_content_type_from_stream_metadata() {
9532        use camel_component_api::{StreamBody, StreamMetadata};
9533        use futures::stream;
9534
9535        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
9536
9537        let client = reqwest::Client::new();
9538        let send_fut = client
9539            .get(format!("http://127.0.0.1:{port}/stream-ct"))
9540            .send();
9541
9542        let (http_result, _) = tokio::join!(send_fut, async {
9543            if let Some(mut envelope) = rx.recv().await {
9544                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
9545                    vec![Ok(bytes::Bytes::from("audio data"))];
9546                let stream = Box::pin(stream::iter(chunks));
9547                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
9548                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
9549                    metadata: StreamMetadata {
9550                        size_hint: None,
9551                        content_type: Some("audio/mpeg".to_string()),
9552                        origin: None,
9553                    },
9554                });
9555                if let Some(reply_tx) = envelope.reply_tx {
9556                    let _ = reply_tx.send(Ok(envelope.exchange));
9557                }
9558            }
9559        });
9560
9561        let resp = http_result.unwrap();
9562        assert_eq!(resp.status().as_u16(), 200);
9563        let ct = resp
9564            .headers()
9565            .get("content-type")
9566            .expect("Content-Type header should be present");
9567        assert_eq!(ct, "audio/mpeg");
9568        let body = resp.text().await.unwrap();
9569        assert_eq!(body, "audio data");
9570
9571        token.cancel();
9572    }
9573
9574    #[tokio::test]
9575    async fn test_user_content_type_overrides_inferred() {
9576        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
9577
9578        let client = reqwest::Client::new();
9579        let send_fut = client
9580            .get(format!("http://127.0.0.1:{port}/override-ct"))
9581            .send();
9582
9583        let (http_result, _) = tokio::join!(send_fut, async {
9584            if let Some(mut envelope) = rx.recv().await {
9585                envelope.exchange.input.body =
9586                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
9587                envelope.exchange.input.set_header(
9588                    "Content-Type",
9589                    serde_json::Value::String("text/html".to_string()),
9590                );
9591                if let Some(reply_tx) = envelope.reply_tx {
9592                    let _ = reply_tx.send(Ok(envelope.exchange));
9593                }
9594            }
9595        });
9596
9597        let resp = http_result.unwrap();
9598        assert_eq!(resp.status().as_u16(), 200);
9599        let ct = resp
9600            .headers()
9601            .get("content-type")
9602            .expect("Content-Type header should be present");
9603        assert_eq!(
9604            ct, "text/html",
9605            "User-set Content-Type should take precedence over inferred type"
9606        );
9607
9608        token.cancel();
9609    }
9610
9611    #[tokio::test]
9612    async fn test_user_content_type_with_bytes_body() {
9613        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
9614
9615        let client = reqwest::Client::new();
9616        let send_fut = client
9617            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
9618            .send();
9619
9620        let (http_result, _) = tokio::join!(send_fut, async {
9621            if let Some(mut envelope) = rx.recv().await {
9622                envelope.exchange.input.body =
9623                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
9624                envelope.exchange.input.set_header(
9625                    "Content-Type",
9626                    serde_json::Value::String("application/json".to_string()),
9627                );
9628                if let Some(reply_tx) = envelope.reply_tx {
9629                    let _ = reply_tx.send(Ok(envelope.exchange));
9630                }
9631            }
9632        });
9633
9634        let resp = http_result.unwrap();
9635        assert_eq!(resp.status().as_u16(), 200);
9636        let ct = resp
9637            .headers()
9638            .get("content-type")
9639            .expect("Content-Type header should be present for Bytes body with user header");
9640        assert_eq!(
9641            ct, "application/json",
9642            "User Content-Type should be sent for Bytes body"
9643        );
9644
9645        token.cancel();
9646    }
9647
9648    // -----------------------------------------------------------------------
9649    // Server monitor tests (GRL-005)
9650    // -----------------------------------------------------------------------
9651
9652    #[tokio::test]
9653    async fn monitor_task_silent_on_clean_exit() {
9654        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
9655        // Clean exit should complete without panicking or logging errors
9656        monitor_axum_task(
9657            handle,
9658            "127.0.0.1:0".to_string(),
9659            noop_rt(),
9660            "test-monitor".into(),
9661        )
9662        .await;
9663    }
9664
9665    #[tokio::test]
9666    async fn monitor_task_handles_panicked_task() {
9667        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
9668            panic!("simulated server crash");
9669        });
9670        // Should complete without panicking even though the inner task panicked
9671        monitor_axum_task(
9672            handle,
9673            "127.0.0.1:9999".to_string(),
9674            noop_rt(),
9675            "test-monitor".into(),
9676        )
9677        .await;
9678    }
9679
9680    // -----------------------------------------------------------------------
9681    // Credential redaction tests
9682    // -----------------------------------------------------------------------
9683
9684    #[test]
9685    fn http_auth_basic_debug_redacts_password() {
9686        let auth = HttpAuth::Basic {
9687            username: "admin".to_string(),
9688            password: "hunter2".to_string(),
9689        };
9690        let debug = format!("{:?}", auth);
9691        assert!(
9692            !debug.contains("hunter2"),
9693            "password must be redacted: {debug}"
9694        );
9695        assert!(debug.contains("admin"), "username should appear: {debug}");
9696    }
9697
9698    #[test]
9699    fn http_auth_bearer_debug_redacts_token() {
9700        let auth = HttpAuth::Bearer {
9701            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
9702        };
9703        let debug = format!("{:?}", auth);
9704        assert!(
9705            !debug.contains("eyJhbGci"),
9706            "token must be redacted: {debug}"
9707        );
9708    }
9709
9710    #[test]
9711    fn http_auth_none_debug_shows_variant() {
9712        let debug = format!("{:?}", HttpAuth::None);
9713        assert!(
9714            debug.contains("None"),
9715            "None variant should appear: {debug}"
9716        );
9717    }
9718
9719    #[test]
9720    fn http_endpoint_config_debug_redacts_auth_credentials() {
9721        let config = HttpEndpointConfig::from_uri(
9722            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
9723        )
9724        .unwrap();
9725        let debug = format!("{:?}", config);
9726        assert!(
9727            !debug.contains("secret123"),
9728            "password must be redacted in HttpEndpointConfig debug: {debug}"
9729        );
9730    }
9731
9732    #[test]
9733    fn debug_lists_all_public_fields() {
9734        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9735        let debug = format!("{:?}", config);
9736        for field in [
9737            "base_url",
9738            "http_method",
9739            "throw_exception_on_failure",
9740            "ok_status_code_range",
9741            "response_timeout",
9742            "query_params",
9743            "raw_query",
9744            "allow_internal",
9745            "blocked_hosts",
9746            "max_body_size",
9747            "read_timeout_ms",
9748            "max_response_bytes",
9749            "auth",
9750            "token_provider",
9751            "user_agent",
9752            "bridge_endpoint",
9753            "connection_close",
9754            "skip_request_headers",
9755            "skip_response_headers",
9756            "follow_redirects",
9757            "max_redirects",
9758        ] {
9759            assert!(
9760                debug.contains(field),
9761                "Debug output missing field '{field}': {debug}"
9762            );
9763        }
9764    }
9765
9766    // -----------------------------------------------------------------------
9767    // Static file serving tests (Task 5)
9768    // -----------------------------------------------------------------------
9769
9770    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
9771    use tower_http::services::ServeDir;
9772
9773    fn make_test_registry() -> HttpRouteRegistry {
9774        HttpRouteRegistry::new()
9775    }
9776
9777    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
9778        AppState {
9779            registry,
9780            max_request_body: 2 * 1024 * 1024,
9781            max_response_body: 10 * 1024 * 1024,
9782            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
9783        }
9784    }
9785
9786    #[allow(clippy::await_holding_lock)]
9787    #[tokio::test]
9788    async fn test_static_file_serving_serves_file_contents() {
9789        let _guard = lock_registry_test_mutex();
9790        ServerRegistry::reset();
9791
9792        // Create temp dir with test files
9793        let temp_dir =
9794            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
9795        std::fs::create_dir_all(&temp_dir).unwrap();
9796        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
9797        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
9798
9799        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9800
9801        let registry = make_test_registry();
9802        let serve_dir = ServeDir::new(&canonical_dir)
9803            .precompressed_gzip()
9804            .precompressed_br()
9805            .append_index_html_on_directories(true);
9806
9807        let mount = StaticMount {
9808            mount_path: "/".to_string(),
9809            mode: MountMode::Static,
9810            dir: canonical_dir.clone(),
9811            cache_control: "public, max-age=3600".to_string(),
9812            error_pages: std::collections::HashMap::new(),
9813            serve_dir,
9814        };
9815        registry.register_static_mount(mount).await.unwrap();
9816
9817        let state = make_test_state(registry);
9818
9819        // Test serving hello.txt
9820        let req = Request::builder()
9821            .uri("/hello.txt")
9822            .body(AxumBody::empty())
9823            .unwrap();
9824        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
9825        assert_eq!(resp.status(), StatusCode::OK);
9826        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9827            .await
9828            .unwrap();
9829        assert_eq!(&body[..], b"Hello, static world!");
9830
9831        // Test serving style.css
9832        let req = Request::builder()
9833            .uri("/style.css")
9834            .body(AxumBody::empty())
9835            .unwrap();
9836        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9837        assert_eq!(resp.status(), StatusCode::OK);
9838        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9839            .await
9840            .unwrap();
9841        assert_eq!(&body[..], b"body { color: red; }");
9842
9843        // Test 404 for non-existent file
9844        let req = Request::builder()
9845            .uri("/missing.txt")
9846            .body(AxumBody::empty())
9847            .unwrap();
9848        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
9849        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9850
9851        // Cleanup
9852        std::fs::remove_dir_all(&temp_dir).ok();
9853    }
9854
9855    #[allow(clippy::await_holding_lock)]
9856    #[tokio::test]
9857    async fn test_spa_fallback_serves_index_for_unknown_paths() {
9858        let _guard = lock_registry_test_mutex();
9859        ServerRegistry::reset();
9860
9861        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
9862        std::fs::create_dir_all(&temp_dir).unwrap();
9863        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
9864        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
9865
9866        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9867
9868        let registry = make_test_registry();
9869        let serve_dir = ServeDir::new(&canonical_dir)
9870            .precompressed_gzip()
9871            .precompressed_br()
9872            .append_index_html_on_directories(true);
9873
9874        let mount = StaticMount {
9875            mount_path: "/".to_string(),
9876            mode: MountMode::Spa,
9877            dir: canonical_dir.clone(),
9878            cache_control: "public, max-age=0".to_string(),
9879            error_pages: std::collections::HashMap::new(),
9880            serve_dir,
9881        };
9882        // Register as SPA mount
9883        registry.register_static_mount(mount).await.unwrap();
9884
9885        let state = make_test_state(registry);
9886
9887        // SPA fallback: GET /dashboard with Accept: text/html → index.html
9888        let req = Request::builder()
9889            .method("GET")
9890            .uri("/dashboard")
9891            .header("Accept", "text/html")
9892            .body(AxumBody::empty())
9893            .unwrap();
9894        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
9895        assert_eq!(resp.status(), StatusCode::OK);
9896        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9897            .await
9898            .unwrap();
9899        assert_eq!(&body[..], b"<h1>SPA App</h1>");
9900
9901        // Static file still works: GET /app.js
9902        let req = Request::builder()
9903            .method("GET")
9904            .uri("/app.js")
9905            .body(AxumBody::empty())
9906            .unwrap();
9907        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
9908        assert_eq!(resp.status(), StatusCode::OK);
9909        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9910            .await
9911            .unwrap();
9912        assert_eq!(&body[..], b"console.log('app')");
9913
9914        // No SPA fallback for JSON accept → 404
9915        let req = Request::builder()
9916            .method("GET")
9917            .uri("/api/data")
9918            .header("Accept", "application/json")
9919            .body(AxumBody::empty())
9920            .unwrap();
9921        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
9922        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9923
9924        // No SPA fallback for file extensions → 404
9925        let req = Request::builder()
9926            .method("GET")
9927            .uri("/style.css")
9928            .header("Accept", "text/html")
9929            .body(AxumBody::empty())
9930            .unwrap();
9931        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9932        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9933
9934        // Cleanup
9935        std::fs::remove_dir_all(&temp_dir).ok();
9936    }
9937
9938    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
9939    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
9940    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
9941    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
9942    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
9943    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
9944    #[allow(clippy::await_holding_lock)]
9945    async fn run_conditional_get_returns_304(mode: MountMode) {
9946        let _guard = lock_registry_test_mutex();
9947        ServerRegistry::reset();
9948
9949        let temp_dir = std::env::temp_dir().join(format!(
9950            "http_cond_get_{}_{}",
9951            if mode == MountMode::Spa {
9952                "spa"
9953            } else {
9954                "static"
9955            },
9956            std::process::id()
9957        ));
9958        std::fs::create_dir_all(&temp_dir).unwrap();
9959        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9960
9961        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9962
9963        let registry = make_test_registry();
9964        let serve_dir = ServeDir::new(&canonical_dir)
9965            .precompressed_gzip()
9966            .precompressed_br()
9967            .append_index_html_on_directories(true);
9968
9969        let mount = StaticMount {
9970            mount_path: "/".to_string(),
9971            mode,
9972            dir: canonical_dir.clone(),
9973            cache_control: "public, max-age=3600".to_string(),
9974            error_pages: std::collections::HashMap::new(),
9975            serve_dir,
9976        };
9977        registry.register_static_mount(mount).await.unwrap();
9978
9979        let state = make_test_state(registry);
9980
9981        // 1st request: normal GET → 200, capture validators.
9982        let req = Request::builder()
9983            .method("GET")
9984            .uri("/index.html")
9985            .body(AxumBody::empty())
9986            .unwrap();
9987        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9988        assert_eq!(
9989            resp.status(),
9990            StatusCode::OK,
9991            "first GET should return 200, got {}",
9992            resp.status()
9993        );
9994        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
9995        assert!(
9996            resp.headers().contains_key(http::header::CACHE_CONTROL),
9997            "200 response missing Cache-Control"
9998        );
9999        let etag = resp
10000            .headers()
10001            .get(http::header::ETAG)
10002            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
10003            .clone();
10004        let last_modified = resp
10005            .headers()
10006            .get(http::header::LAST_MODIFIED)
10007            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
10008            .clone();
10009        // Consume the body so the response is fully drained.
10010        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
10011            .await
10012            .unwrap();
10013
10014        // 2nd request: If-None-Match with the captured ETag → 304.
10015        // Unconditional: ETag presence is required (asserted above) so this
10016        // sub-test cannot silently skip on a ServeDir etag_method change.
10017        let req = Request::builder()
10018            .method("GET")
10019            .uri("/index.html")
10020            .header(http::header::IF_NONE_MATCH, etag.clone())
10021            .body(AxumBody::empty())
10022            .unwrap();
10023        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10024        assert_eq!(
10025            resp.status(),
10026            StatusCode::NOT_MODIFIED,
10027            "If-None-Match with matching ETag should return 304, got {}",
10028            resp.status()
10029        );
10030        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
10031        assert!(
10032            resp.headers().contains_key(http::header::CACHE_CONTROL),
10033            "304 (If-None-Match) missing Cache-Control"
10034        );
10035        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
10036        // response parts rebuild in serve_via_serve_dir preserves them.
10037        assert_eq!(
10038            resp.headers().get(http::header::ETAG),
10039            Some(&etag),
10040            "304 (If-None-Match) must echo the ETag validator"
10041        );
10042        assert_eq!(
10043            resp.headers().get(http::header::LAST_MODIFIED),
10044            Some(&last_modified),
10045            "304 (If-None-Match) must carry Last-Modified"
10046        );
10047
10048        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
10049        let req = Request::builder()
10050            .method("GET")
10051            .uri("/index.html")
10052            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
10053            .body(AxumBody::empty())
10054            .unwrap();
10055        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10056        assert_eq!(
10057            resp.status(),
10058            StatusCode::NOT_MODIFIED,
10059            "If-Modified-Since with matching timestamp should return 304, got {}",
10060            resp.status()
10061        );
10062        assert!(
10063            resp.headers().contains_key(http::header::CACHE_CONTROL),
10064            "304 (If-Modified-Since) missing Cache-Control"
10065        );
10066        assert_eq!(
10067            resp.headers().get(http::header::ETAG),
10068            Some(&etag),
10069            "304 (If-Modified-Since) must carry the ETag validator"
10070        );
10071        assert_eq!(
10072            resp.headers().get(http::header::LAST_MODIFIED),
10073            Some(&last_modified),
10074            "304 (If-Modified-Since) must echo Last-Modified"
10075        );
10076
10077        // Negative control: a PAST If-Modified-Since (before the file's mtime)
10078        // MUST return 200 — proving the 304 path is validator-aware, not a
10079        // blanket "always 304" regression. A future date would correctly yield
10080        // 304 since the file's mtime precedes it; that is RFC-correct 304
10081        // behaviour, not a negative control.
10082        let req = Request::builder()
10083            .method("GET")
10084            .uri("/index.html")
10085            .header(
10086                http::header::IF_MODIFIED_SINCE,
10087                "Wed, 21 Oct 2000 07:28:00 GMT",
10088            )
10089            .body(AxumBody::empty())
10090            .unwrap();
10091        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10092        assert_eq!(
10093            resp.status(),
10094            StatusCode::OK,
10095            "past If-Modified-Since should return 200 (file modified after it), got {}",
10096            resp.status()
10097        );
10098
10099        // Cleanup
10100        std::fs::remove_dir_all(&temp_dir).ok();
10101    }
10102
10103    #[tokio::test]
10104    async fn test_conditional_get_returns_304_static_mode() {
10105        run_conditional_get_returns_304(MountMode::Static).await;
10106    }
10107
10108    #[tokio::test]
10109    async fn test_conditional_get_returns_304_spa_mode() {
10110        run_conditional_get_returns_304(MountMode::Spa).await;
10111    }
10112
10113    #[allow(clippy::await_holding_lock)]
10114    #[tokio::test]
10115    async fn test_error_page_mapping_serves_custom_404() {
10116        let _guard = lock_registry_test_mutex();
10117        ServerRegistry::reset();
10118
10119        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
10120        let errors_dir = temp_dir.join("errors");
10121        std::fs::create_dir_all(&errors_dir).unwrap();
10122        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
10123        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
10124
10125        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10126        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
10127
10128        let registry = make_test_registry();
10129        let serve_dir = ServeDir::new(&canonical_dir)
10130            .precompressed_gzip()
10131            .precompressed_br()
10132            .append_index_html_on_directories(true);
10133
10134        let mut error_pages = std::collections::HashMap::new();
10135        error_pages.insert(404, canonical_404);
10136
10137        let mount = StaticMount {
10138            mount_path: "/".to_string(),
10139            mode: MountMode::Static,
10140            dir: canonical_dir.clone(),
10141            cache_control: "public, max-age=0".to_string(),
10142            error_pages,
10143            serve_dir,
10144        };
10145        registry.register_static_mount(mount).await.unwrap();
10146
10147        let state = make_test_state(registry);
10148
10149        // Request non-existent file → custom 404 page
10150        let req = Request::builder()
10151            .method("GET")
10152            .uri("/missing.html")
10153            .body(AxumBody::empty())
10154            .unwrap();
10155        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
10156        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10157        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10158            .await
10159            .unwrap();
10160        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
10161
10162        // Existing file still works
10163        let req = Request::builder()
10164            .method("GET")
10165            .uri("/index.html")
10166            .body(AxumBody::empty())
10167            .unwrap();
10168        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10169        assert_eq!(resp.status(), StatusCode::OK);
10170        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10171            .await
10172            .unwrap();
10173        assert_eq!(&body[..], b"<h1>Home</h1>");
10174
10175        // Cleanup
10176        std::fs::remove_dir_all(&temp_dir).ok();
10177    }
10178
10179    #[tokio::test]
10180    async fn http_consumer_returns_body_and_code_on_stop() {
10181        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
10182        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
10183        use tower::ServiceExt;
10184
10185        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
10186        let set_body_step = CompiledStep::Process {
10187            kind_hint: camel_api::SpanKindHint::Internal,
10188            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
10189                ex.input.body = Body::Text("nope".into());
10190                Box::pin(async move { Ok(ex) })
10191            }),
10192            body_contract: None,
10193            lifecycle: None,
10194            label: None,
10195            to_uri: None,
10196        };
10197        let set_status_step = CompiledStep::Process {
10198            kind_hint: camel_api::SpanKindHint::Internal,
10199            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
10200                ex.input.set_header(
10201                    "CamelHttpResponseCode",
10202                    serde_json::Value::Number(409.into()),
10203                );
10204                Box::pin(async move { Ok(ex) })
10205            }),
10206            body_contract: None,
10207            lifecycle: None,
10208            label: None,
10209            to_uri: None,
10210        };
10211        let pipeline = compose_pipeline_with_handler(
10212            vec![set_body_step, set_status_step, CompiledStep::Stop],
10213            None,
10214            PipelineRuntimeCtx::compile_time(),
10215        );
10216
10217        let ex = Exchange::new(Message::default());
10218        let result = pipeline.oneshot(ex).await;
10219        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
10220        let returned = result.unwrap();
10221        assert_eq!(returned.input.body.as_text(), Some("nope"));
10222        assert_eq!(
10223            returned
10224                .input
10225                .header("CamelHttpResponseCode")
10226                .and_then(|v| v.as_u64()),
10227            Some(409)
10228        );
10229    }
10230
10231    #[tokio::test]
10232    async fn http_consumer_returns_200_when_body_empty_on_stop() {
10233        // After ADR-0024: Stop with no body + no status header produces 200 (same as
10234        // a normal completion with no body). The 204 default is gone — users who
10235        // want 204 set CamelHttpResponseCode=204 explicitly.
10236        //
10237        // This test stays at the pipeline level (consistent with the test above).
10238        // E2E coverage of the full HTTP dispatch path is in
10239        // crates/camel-test/tests/integration_test.rs.
10240        use camel_api::{Exchange, Message};
10241        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
10242        use tower::ServiceExt;
10243
10244        let pipeline = compose_pipeline_with_handler(
10245            vec![CompiledStep::Stop],
10246            None,
10247            PipelineRuntimeCtx::compile_time(),
10248        );
10249        let ex = Exchange::new(Message::default());
10250        let result = pipeline.oneshot(ex).await;
10251        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
10252        // Body is default (empty); no CamelHttpResponseCode header was set.
10253        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
10254    }
10255
10256    // -----------------------------------------------------------------------
10257    // Task 5: Method-aware REST dispatch tests
10258    // -----------------------------------------------------------------------
10259
10260    /// Spins up an axum server on a free port with a fresh registry.
10261    /// Returns the port plus the registry so the caller can register
10262    /// REST endpoints directly.
10263    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
10264        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10265        let port = listener.local_addr().unwrap().port();
10266        let registry = HttpRouteRegistry::new();
10267        tokio::spawn(run_axum_server(
10268            listener,
10269            registry.clone(),
10270            2 * 1024 * 1024,
10271            10 * 1024 * 1024,
10272            Arc::new(tokio::sync::Semaphore::new(1024)),
10273            test_rt(),
10274            "test-route".into(),
10275        ));
10276        // Give the server a moment to start accepting.
10277        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10278        (port, registry)
10279    }
10280
10281    /// Helper for REST integration tests: spawns a responder task that
10282    /// reads from `rx`, writes a fixed `(status, body)` back via the
10283    /// envelope's reply channel, and returns once the test request is
10284    /// satisfied.
10285    fn spawn_responder(
10286        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
10287        status: u16,
10288        body: String,
10289    ) -> tokio::task::JoinHandle<()> {
10290        tokio::spawn(async move {
10291            if let Some(envelope) = rx.recv().await {
10292                let _ = envelope.reply_tx.send(HttpReply {
10293                    status,
10294                    headers: vec![],
10295                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
10296                });
10297            }
10298        })
10299    }
10300
10301    #[tokio::test]
10302    async fn method_aware_dispatch_same_path_different_verbs() {
10303        let (port, registry) = spawn_test_server().await;
10304
10305        // Register two REST endpoints on the same path with different
10306        // methods. This is the core scenario REST DSL needs to support:
10307        // GET /users (list) and POST /users (create) must not overwrite
10308        // each other.
10309        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10310        registry
10311            .register_rest_endpoint(
10312                "GET".into(),
10313                vec![PathSegment::Literal("users".into())],
10314                get_tx,
10315            )
10316            .await;
10317
10318        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10319        registry
10320            .register_rest_endpoint(
10321                "POST".into(),
10322                vec![PathSegment::Literal("users".into())],
10323                post_tx,
10324            )
10325            .await;
10326
10327        let get_handle = spawn_responder(get_rx, 200, "list".into());
10328        let post_handle = spawn_responder(post_rx, 201, "create".into());
10329
10330        let client = reqwest::Client::new();
10331
10332        // GET /users → list route
10333        let resp = client
10334            .get(format!("http://127.0.0.1:{port}/users"))
10335            .send()
10336            .await
10337            .unwrap();
10338        assert_eq!(resp.status().as_u16(), 200);
10339        let body = resp.text().await.unwrap();
10340        assert_eq!(body, "list");
10341
10342        // POST /users → create route
10343        let resp = client
10344            .post(format!("http://127.0.0.1:{port}/users"))
10345            .send()
10346            .await
10347            .unwrap();
10348        assert_eq!(resp.status().as_u16(), 201);
10349        let body = resp.text().await.unwrap();
10350        assert_eq!(body, "create");
10351
10352        let _ = tokio::join!(get_handle, post_handle);
10353    }
10354
10355    #[tokio::test]
10356    async fn method_aware_dispatch_templated_path_extracts_params() {
10357        let (port, registry) = spawn_test_server().await;
10358
10359        // Register GET /users/{id} as a templated endpoint. The
10360        // dispatcher should match `/users/42` against the template and
10361        // attach `id=42` to the envelope's path_params.
10362        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10363        registry
10364            .register_rest_endpoint(
10365                "GET".into(),
10366                vec![
10367                    PathSegment::Literal("users".into()),
10368                    PathSegment::Param("id".into()),
10369                ],
10370                tx,
10371            )
10372            .await;
10373
10374        // Spawn a responder that echoes the captured id back in the body
10375        // so the test can verify the param was set.
10376        let handle = tokio::spawn(async move {
10377            if let Some(envelope) = rx.recv().await {
10378                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
10379                let _ = envelope.reply_tx.send(HttpReply {
10380                    status: 200,
10381                    headers: vec![],
10382                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
10383                });
10384            }
10385        });
10386
10387        let client = reqwest::Client::new();
10388        let resp = client
10389            .get(format!("http://127.0.0.1:{port}/users/42"))
10390            .send()
10391            .await
10392            .unwrap();
10393        assert_eq!(resp.status().as_u16(), 200);
10394        let body = resp.text().await.unwrap();
10395        assert_eq!(body, "id=42");
10396
10397        let _ = handle.await;
10398    }
10399
10400    #[tokio::test]
10401    async fn method_aware_dispatch_unmatched_method_falls_through() {
10402        // If no REST endpoint matches the method, dispatch must fall
10403        // through to the legacy api_routes lookup or static mounts. With
10404        // nothing else registered, the request gets 404 from static
10405        // dispatch.
10406        let (port, _registry) = spawn_test_server().await;
10407
10408        // Register only GET /users; a DELETE /users request has no match.
10409        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10410        _registry
10411            .register_rest_endpoint(
10412                "GET".into(),
10413                vec![PathSegment::Literal("users".into())],
10414                get_tx,
10415            )
10416            .await;
10417
10418        // Drain the GET channel in the background so the consumer side
10419        // doesn't block (we don't expect any envelopes here).
10420        let drain = tokio::spawn(async move {
10421            let mut get_rx = get_rx;
10422            while get_rx.recv().await.is_some() {}
10423        });
10424
10425        let client = reqwest::Client::new();
10426        let resp = client
10427            .delete(format!("http://127.0.0.1:{port}/users"))
10428            .send()
10429            .await
10430            .unwrap();
10431        assert_eq!(resp.status().as_u16(), 404);
10432
10433        drop(drain);
10434    }
10435
10436    #[tokio::test]
10437    async fn regression_legacy_exact_api_route_still_works() {
10438        // A `http:` route registered without an `httpMethod=` URI param
10439        // lands in the legacy api_routes registry. The dispatcher must
10440        // still find it via exact path lookup. This guards against
10441        // regressions introduced by the new REST-aware dispatch.
10442        let (port, registry) = spawn_test_server().await;
10443
10444        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10445        registry.register_api_route("/legacy/path".into(), tx).await;
10446
10447        let handle = tokio::spawn(async move {
10448            if let Some(envelope) = rx.recv().await {
10449                let _ = envelope.reply_tx.send(HttpReply {
10450                    status: 200,
10451                    headers: vec![],
10452                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
10453                });
10454            }
10455        });
10456
10457        let client = reqwest::Client::new();
10458        let resp = client
10459            .get(format!("http://127.0.0.1:{port}/legacy/path"))
10460            .send()
10461            .await
10462            .unwrap();
10463        assert_eq!(resp.status().as_u16(), 200);
10464        let body = resp.text().await.unwrap();
10465        assert_eq!(body, "legacy ok");
10466
10467        let _ = handle.await;
10468    }
10469
10470    #[allow(clippy::await_holding_lock)]
10471    #[tokio::test]
10472    async fn regression_static_mount_still_works() {
10473        // Verify that static file serving still works after the
10474        // dispatch refactor. We register a temp-dir mount and request
10475        // a file from it; the static dispatcher should serve it.
10476        let _guard = lock_registry_test_mutex();
10477        ServerRegistry::reset();
10478
10479        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
10480        std::fs::create_dir_all(&temp_dir).unwrap();
10481        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
10482        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10483
10484        let registry = make_test_registry();
10485        let serve_dir = ServeDir::new(&canonical_dir)
10486            .precompressed_gzip()
10487            .precompressed_br()
10488            .append_index_html_on_directories(true);
10489        let mount = StaticMount {
10490            mount_path: "/".to_string(),
10491            mode: MountMode::Static,
10492            dir: canonical_dir.clone(),
10493            cache_control: "public, max-age=3600".to_string(),
10494            error_pages: std::collections::HashMap::new(),
10495            serve_dir,
10496        };
10497        registry.register_static_mount(mount).await.unwrap();
10498
10499        let state = make_test_state(registry);
10500        let req = Request::builder()
10501            .uri("/regress.txt")
10502            .body(AxumBody::empty())
10503            .unwrap();
10504        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
10505        assert_eq!(resp.status(), StatusCode::OK);
10506        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10507            .await
10508            .unwrap();
10509        assert_eq!(&body[..], b"static works");
10510
10511        std::fs::remove_dir_all(&temp_dir).ok();
10512    }
10513
10514    // -----------------------------------------------------------------------
10515    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
10516    // templated from-URI round-trip. These exercise the real axum dispatch
10517    // path (register → HTTP request → reply) so a regression in any of the
10518    // three critical fixes surfaces as a test failure rather than a silent
10519    // production 404/500.
10520    // -----------------------------------------------------------------------
10521
10522    #[tokio::test]
10523    async fn deregister_one_method_keeps_sibling_verbs() {
10524        // Review C1: stopping the GET /users consumer must NOT tear down the
10525        // live POST /users endpoint. Register both, deregister GET only,
10526        // then verify POST still dispatches.
10527        let (port, registry) = spawn_test_server().await;
10528
10529        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10530        registry
10531            .register_rest_endpoint(
10532                "GET".into(),
10533                vec![PathSegment::Literal("users".into())],
10534                get_tx,
10535            )
10536            .await;
10537
10538        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10539        registry
10540            .register_rest_endpoint(
10541                "POST".into(),
10542                vec![PathSegment::Literal("users".into())],
10543                post_tx,
10544            )
10545            .await;
10546
10547        // Drain GET in the background (no requests expected after deregister).
10548        let drain = tokio::spawn(async move {
10549            let mut get_rx = get_rx;
10550            while get_rx.recv().await.is_some() {}
10551        });
10552
10553        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
10554        registry.unregister_rest_endpoint("GET", "/users").await;
10555        drop(drain);
10556
10557        let post_handle = spawn_responder(post_rx, 201, "create".into());
10558
10559        let client = reqwest::Client::new();
10560        // POST /users must still reach its consumer after GET was removed.
10561        let resp = client
10562            .post(format!("http://127.0.0.1:{port}/users"))
10563            .send()
10564            .await
10565            .unwrap();
10566        assert_eq!(resp.status().as_u16(), 201);
10567        assert_eq!(resp.text().await.unwrap(), "create");
10568
10569        let _ = post_handle.await;
10570    }
10571
10572    #[tokio::test]
10573    async fn dispatch_exact_legacy_beats_rest_template() {
10574        // Review C2: an exact legacy API route (`GET /api/users`, no
10575        // httpMethod) must win over a templated REST route
10576        // (`GET /api/{resource}`) for the request `/api/users`, per spec
10577        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
10578        let (port, registry) = spawn_test_server().await;
10579
10580        // Exact legacy route.
10581        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10582        registry
10583            .register_api_route("/api/users".into(), exact_tx)
10584            .await;
10585        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
10586
10587        // Templated REST route that would ALSO match /api/users.
10588        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10589        registry
10590            .register_rest_endpoint(
10591                "GET".into(),
10592                vec![
10593                    PathSegment::Literal("api".into()),
10594                    PathSegment::Param("resource".into()),
10595                ],
10596                tpl_tx,
10597            )
10598            .await;
10599        // The templated handler must NOT receive the /api/users request. If
10600        // it does, it replies "template-leak" so a future assertion could
10601        // catch it. We do NOT await this task: the exact-match branch wins
10602        // and the templated channel never receives, so awaiting would block
10603        // until the test runtime tears down.
10604        let _tpl_drain = tokio::spawn(async move {
10605            let mut tpl_rx = tpl_rx;
10606            if let Some(env) = tpl_rx.recv().await {
10607                let _ = env.reply_tx.send(HttpReply {
10608                    status: 200,
10609                    headers: vec![],
10610                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
10611                });
10612            }
10613        });
10614
10615        let client = reqwest::Client::new();
10616        let resp = client
10617            .get(format!("http://127.0.0.1:{port}/api/users"))
10618            .send()
10619            .await
10620            .unwrap();
10621        assert_eq!(resp.status().as_u16(), 200);
10622        // Exact-match handler answered — not the templated one.
10623        assert_eq!(resp.text().await.unwrap(), "exact");
10624
10625        let _ = exact_handle.await;
10626    }
10627
10628    #[tokio::test]
10629    async fn ambiguous_rest_templates_return_500_not_silent_404() {
10630        // Review C3: two equal-specificity templates that both match one
10631        // request are an ambiguous registration. At runtime this must
10632        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
10633        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
10634        let (port, registry) = spawn_test_server().await;
10635
10636        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10637        registry
10638            .register_rest_endpoint(
10639                "GET".into(),
10640                vec![
10641                    PathSegment::Literal("users".into()),
10642                    PathSegment::Param("id".into()),
10643                ],
10644                a_tx,
10645            )
10646            .await;
10647
10648        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10649        registry
10650            .register_rest_endpoint(
10651                "GET".into(),
10652                vec![
10653                    PathSegment::Literal("users".into()),
10654                    PathSegment::Param("name".into()),
10655                ],
10656                b_tx,
10657            )
10658            .await;
10659
10660        let client = reqwest::Client::new();
10661        let resp = client
10662            .get(format!("http://127.0.0.1:{port}/users/42"))
10663            .send()
10664            .await
10665            .unwrap();
10666        // Ambiguous → 500 (previously a silent 404).
10667        assert_eq!(resp.status().as_u16(), 500);
10668    }
10669
10670    #[test]
10671    fn from_uri_round_trips_templated_path_with_http_method() {
10672        // Review I4: a REST-lowered from-URI like
10673        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
10674        // through HttpServerConfig::from_uri, preserving the templated path
10675        // and the (uppercased) method. This is the binding the DSL lowering
10676        // emits and the consumer reads; it was previously unasserted.
10677        use crate::UriConfig;
10678        let cfg =
10679            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
10680        assert_eq!(cfg.host, "0.0.0.0");
10681        assert_eq!(cfg.port, 8080);
10682        assert_eq!(cfg.path, "/users/{id}");
10683        assert_eq!(cfg.method.as_deref(), Some("GET"));
10684
10685        // Lower-case httpMethod is uppercased (review I5).
10686        let cfg_lc =
10687            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
10688        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
10689        assert_eq!(cfg_lc.path, "/orders");
10690    }
10691
10692    // -----------------------------------------------------------------------
10693    // rc-1dk4: TypeConversionFailed → 400 Bad Request
10694    // -----------------------------------------------------------------------
10695
10696    #[test]
10697    fn type_conversion_failed_maps_to_400() {
10698        let reply = pipeline_error_to_reply(
10699            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
10700            "/api/users",
10701        );
10702        assert_eq!(reply.status, 400);
10703        // Exactly one Content-Type header, application/json
10704        let json_ct = reply
10705            .headers
10706            .iter()
10707            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10708            .count();
10709        assert_eq!(json_ct, 1);
10710        // Body must be structured error JSON with the expected fields
10711        let body = match &reply.body {
10712            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10713            _ => panic!("expected bytes body"),
10714        };
10715        let parsed: serde_json::Value =
10716            serde_json::from_str(&body).expect("body must be valid JSON");
10717        assert_eq!(parsed["error"], "bad_request");
10718        assert_eq!(parsed["message"], "invalid JSON at line 1");
10719    }
10720
10721    #[test]
10722    fn other_error_still_maps_to_500() {
10723        let reply =
10724            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
10725        assert_eq!(reply.status, 500);
10726    }
10727
10728    #[test]
10729    fn unauthenticated_maps_to_401() {
10730        let reply = pipeline_error_to_reply(
10731            CamelError::Unauthenticated("no token".to_string()),
10732            "/api/users",
10733        );
10734        assert_eq!(reply.status, 401);
10735    }
10736
10737    #[test]
10738    fn unauthorized_maps_to_403() {
10739        let reply = pipeline_error_to_reply(
10740            CamelError::Unauthorized("forbidden".to_string()),
10741            "/api/users",
10742        );
10743        assert_eq!(reply.status, 403);
10744    }
10745
10746    #[test]
10747    fn validation_error_maps_to_400() {
10748        let reply = pipeline_error_to_reply(
10749            CamelError::ValidationError("body does not match schema".to_string()),
10750            "/api/users",
10751        );
10752        assert_eq!(reply.status, 400);
10753        let json_ct = reply
10754            .headers
10755            .iter()
10756            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10757            .count();
10758        assert_eq!(json_ct, 1);
10759        let body = match &reply.body {
10760            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10761            _ => panic!("expected bytes body"),
10762        };
10763        let parsed: serde_json::Value =
10764            serde_json::from_str(&body).expect("body must be valid JSON");
10765        assert_eq!(parsed["error"], "validation_error");
10766        assert_eq!(parsed["message"], "body does not match schema");
10767    }
10768
10769    // -----------------------------------------------------------------------
10770    // rc-hlb1q: media negotiation errors → 415 / 406
10771    // -----------------------------------------------------------------------
10772
10773    #[test]
10774    fn finalizer_maps_unsupported_media_type() {
10775        let reply = pipeline_error_to_reply(
10776            CamelError::UnsupportedMediaType {
10777                consumed: "text/plain".to_string(),
10778                declared: "application/json".to_string(),
10779            },
10780            "/x",
10781        );
10782        assert_eq!(reply.status, 415);
10783        let json_ct = reply
10784            .headers
10785            .iter()
10786            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10787            .count();
10788        assert_eq!(json_ct, 1);
10789        let body = match &reply.body {
10790            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10791            _ => panic!("expected bytes body"),
10792        };
10793        let parsed: serde_json::Value =
10794            serde_json::from_str(&body).expect("body must be valid JSON");
10795        assert_eq!(parsed["error"], "unsupported_media_type");
10796        assert_eq!(
10797            parsed["message"],
10798            "consumed text/plain, declared application/json"
10799        );
10800    }
10801
10802    #[test]
10803    fn finalizer_maps_not_acceptable() {
10804        let reply = pipeline_error_to_reply(
10805            CamelError::NotAcceptable {
10806                accept: "application/xml".to_string(),
10807                produced: "application/json".to_string(),
10808            },
10809            "/x",
10810        );
10811        assert_eq!(reply.status, 406);
10812        let json_ct = reply
10813            .headers
10814            .iter()
10815            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10816            .count();
10817        assert_eq!(json_ct, 1);
10818        let body = match &reply.body {
10819            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10820            _ => panic!("expected bytes body"),
10821        };
10822        let parsed: serde_json::Value =
10823            serde_json::from_str(&body).expect("body must be valid JSON");
10824        assert_eq!(parsed["error"], "not_acceptable");
10825        assert_eq!(
10826            parsed["message"],
10827            "accept application/xml, produced application/json"
10828        );
10829    }
10830
10831    #[test]
10832    fn json_error_reply_preserves_empty_message() {
10833        let reply = json_error_reply(400, "bad_request", "".to_string());
10834        assert_eq!(reply.status, 400);
10835        let json_ct = reply
10836            .headers
10837            .iter()
10838            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10839            .count();
10840        assert_eq!(json_ct, 1);
10841        let body = match &reply.body {
10842            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10843            _ => panic!("expected bytes body"),
10844        };
10845        let parsed: serde_json::Value =
10846            serde_json::from_str(&body).expect("body must be valid JSON");
10847        assert_eq!(parsed["error"], "bad_request");
10848        assert_eq!(parsed["message"], "");
10849    }
10850
10851    #[test]
10852    fn https_consumer_without_tls_cert_errors() {
10853        let endpoint = HttpEndpoint {
10854            uri: "https://0.0.0.0:8443/api".to_string(),
10855            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10856            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10857            client: reqwest::Client::new(),
10858            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10859                PINNED_CLIENT_TTL,
10860                PINNED_CLIENT_MAX_ENTRIES,
10861            )),
10862            http_config: HttpConfig::default(),
10863        };
10864        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10865        let result = endpoint.create_consumer(rt);
10866        assert!(result.is_err(), "expected error for https without tls cert");
10867        if let Err(e) = result {
10868            let msg = e.to_string();
10869            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
10870        }
10871    }
10872
10873    #[test]
10874    fn http_consumer_with_tls_config_errors() {
10875        let endpoint = HttpEndpoint {
10876            uri: "http://0.0.0.0:8080/api".to_string(),
10877            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
10878            server_config: HttpServerConfig::from_uri(
10879                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
10880            )
10881            .unwrap(),
10882            client: reqwest::Client::new(),
10883            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10884                PINNED_CLIENT_TTL,
10885                PINNED_CLIENT_MAX_ENTRIES,
10886            )),
10887            http_config: HttpConfig::default(),
10888        };
10889        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10890        let result = endpoint.create_consumer(rt);
10891        assert!(result.is_err(), "expected error for http with tls config");
10892        if let Err(e) = result {
10893            let msg = e.to_string();
10894            assert!(msg.contains("https"), "error must mention https: {msg}");
10895        }
10896    }
10897
10898    #[test]
10899    fn https_consumer_with_partial_tls_cert_only_errors() {
10900        // tlsCert without tlsKey → tls_config is None at parse time
10901        // → create_consumer sees https:// + no TLS → must error
10902        let server_config =
10903            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10904        assert!(
10905            server_config.tls_config.is_none(),
10906            "partial tlsCert must not create ServerTlsConfig"
10907        );
10908        let endpoint = HttpEndpoint {
10909            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
10910            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
10911                .unwrap(),
10912            server_config,
10913            client: reqwest::Client::new(),
10914            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10915                PINNED_CLIENT_TTL,
10916                PINNED_CLIENT_MAX_ENTRIES,
10917            )),
10918            http_config: HttpConfig::default(),
10919        };
10920        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10921        let result = endpoint.create_consumer(rt);
10922        assert!(
10923            result.is_err(),
10924            "must error: https:// requires both tlsCert and tlsKey"
10925        );
10926    }
10927
10928    #[test]
10929    fn load_tls_config_parses_valid_pem() {
10930        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
10931        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10932        use camel_component_api::test_support::tls;
10933        let (_, cert_pem, key_pem) = tls::gen_server_cert();
10934        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
10935        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
10936
10937        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
10938        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
10939    }
10940
10941    #[tokio::test(flavor = "multi_thread")]
10942    #[allow(clippy::await_holding_lock)]
10943    async fn consumer_tls_handshake_roundtrip() {
10944        use camel_component_api::test_support::tls;
10945        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10946
10947        // Install rustls crypto provider (aws-lc-rs)
10948        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10949
10950        // Serialize against global ServerRegistry singleton
10951        let _guard = lock_registry_test_mutex();
10952
10953        // Generate CA + server cert
10954        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
10955        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
10956        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
10957        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
10958
10959        // Get ephemeral port
10960        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10961        let port = probe.local_addr().unwrap().port();
10962        drop(probe);
10963
10964        ServerRegistry::reset();
10965
10966        // Create real HttpComponent + endpoint with TLS URI
10967        let component = HttpComponent::new();
10968        let endpoint_ctx = NoOpComponentContext;
10969        let uri = format!(
10970            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10971            cert_path.to_string_lossy(),
10972            key_path.to_string_lossy(),
10973        );
10974        let endpoint = component
10975            .create_endpoint(&uri, &endpoint_ctx)
10976            .expect("create TLS endpoint");
10977        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
10978
10979        // Start consumer — this calls get_or_spawn with tls_config
10980        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10981        let token = tokio_util::sync::CancellationToken::new();
10982        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
10983        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10984
10985        // Give server time to start
10986        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10987
10988        // Client with CA cert — REAL verification (no danger_accept_invalid)
10989        let ca_bytes = std::fs::read(&ca_path).unwrap();
10990        let client = reqwest::Client::builder()
10991            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
10992            .build()
10993            .unwrap();
10994
10995        let send_fut = client
10996            .post(format!("https://localhost:{port}/test"))
10997            .body("ping")
10998            .send();
10999
11000        // Handler: receive envelope, reply 200 with "pong" body
11001        let (http_result, _) = tokio::join!(send_fut, async {
11002            if let Some(mut envelope) = rx.recv().await {
11003                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
11004                if let Some(reply_tx) = envelope.reply_tx {
11005                    let _ = reply_tx.send(Ok(envelope.exchange));
11006                }
11007            }
11008        });
11009
11010        let resp = http_result.expect("TLS handshake + request must succeed");
11011
11012        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
11013        let body = resp.text().await.unwrap();
11014        assert_eq!(body, "pong");
11015
11016        token.cancel();
11017    }
11018
11019    #[tokio::test(flavor = "multi_thread")]
11020    #[allow(clippy::await_holding_lock)]
11021    async fn consumer_tls_rejects_client_without_ca() {
11022        use camel_component_api::test_support::tls;
11023        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11024
11025        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11026
11027        // Serialize against global ServerRegistry singleton
11028        let _guard = lock_registry_test_mutex();
11029
11030        let (_, cert_pem, key_pem) = tls::gen_server_cert();
11031        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
11032        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
11033
11034        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11035        let port = probe.local_addr().unwrap().port();
11036        drop(probe);
11037
11038        ServerRegistry::reset();
11039
11040        // Spawn TLS server via real HttpComponent path
11041        let component = HttpComponent::new();
11042        let endpoint_ctx = NoOpComponentContext;
11043        let uri = format!(
11044            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
11045            cert_path.to_string_lossy(),
11046            key_path.to_string_lossy(),
11047        );
11048        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
11049        let mut consumer = endpoint.create_consumer(rt()).unwrap();
11050        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11051        let token = tokio_util::sync::CancellationToken::new();
11052        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
11053        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11054
11055        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
11056
11057        // Client WITHOUT CA cert — must fail TLS verification
11058        let client = reqwest::Client::builder().build().unwrap();
11059
11060        let result = client
11061            .get(format!("https://localhost:{port}/test"))
11062            .send()
11063            .await;
11064
11065        assert!(
11066            result.is_err(),
11067            "must reject without CA — proves real verification"
11068        );
11069
11070        token.cancel();
11071    }
11072
11073    #[test]
11074    fn server_config_partial_tls_cert_without_key() {
11075        // Parse URI with only tlsCert (no tlsKey)
11076        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
11077        // Partial params → tls_config must be None
11078        assert!(cfg.tls_config.is_none());
11079    }
11080
11081    #[test]
11082    fn endpoint_uri_options_count_parity() {
11083        // Mirror struct must stay in sync with bespoke from_components parser.
11084        assert_eq!(
11085            HttpEndpointConfig::uri_options().len(),
11086            22,
11087            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
11088        );
11089    }
11090
11091    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
11092        pairs
11093            .iter()
11094            .map(|(k, v)| {
11095                (
11096                    (*k).to_string(),
11097                    serde_json::Value::String((*v).to_string()),
11098                )
11099            })
11100            .collect()
11101    }
11102
11103    #[test]
11104    fn response_emits_cache_control_via_pragma_warning() {
11105        let headers = make_headers(&[
11106            ("Cache-Control", "public, max-age=3600"),
11107            ("Via", "1.1 myproxy"),
11108            ("Pragma", "no-cache"),
11109            ("Warning", "199 misc"),
11110        ]);
11111        let selected = select_response_headers(&headers, None, None);
11112        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11113        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
11114            assert!(
11115                names.contains(&expected),
11116                "{expected} should pass through to the response"
11117            );
11118        }
11119    }
11120
11121    #[test]
11122    fn response_excludes_request_only_and_server_owned() {
11123        let headers = make_headers(&[
11124            ("User-Agent", "x"),
11125            ("Accept", "*/*"),
11126            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
11127        ]);
11128        let selected = select_response_headers(&headers, None, None);
11129        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11130        for excluded in ["User-Agent", "Accept", "Date"] {
11131            assert!(
11132                !names.contains(&excluded),
11133                "{excluded} should NOT appear in the response"
11134            );
11135        }
11136    }
11137
11138    #[test]
11139    fn response_re_derives_content_type() {
11140        let headers = make_headers(&[("Content-Type", "text/plain")]);
11141        let selected = select_response_headers(&headers, Some("application/json".into()), None);
11142        let ct_entries: Vec<&str> = selected
11143            .iter()
11144            .filter(|(k, _)| k == "Content-Type")
11145            .map(|(_, v)| v.as_str())
11146            .collect();
11147        assert_eq!(
11148            ct_entries,
11149            ["application/json"],
11150            "exactly one Content-Type entry, re-derived from user_content_type"
11151        );
11152    }
11153
11154    #[test]
11155    fn response_excludes_camel_headers() {
11156        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
11157        let selected = select_response_headers(&headers, None, None);
11158        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11159        assert!(
11160            !names.contains(&"CamelHttpPath"),
11161            "Camel-namespace headers must be excluded"
11162        );
11163        assert!(
11164            names.contains(&"Cache-Control"),
11165            "Cache-Control must pass through"
11166        );
11167    }
11168
11169    #[test]
11170    fn response_stringifies_scalar_header_values() {
11171        let mut headers = make_headers(&[("X-Label", "keep")]);
11172        headers.insert("X-Retries".to_string(), serde_json::json!(3));
11173        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
11174        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
11175        let selected = select_response_headers(&headers, None, None);
11176        let get = |name: &str| -> Option<&str> {
11177            selected
11178                .iter()
11179                .find(|(k, _)| k == name)
11180                .map(|(_, v)| v.as_str())
11181        };
11182        assert_eq!(
11183            get("X-Retries"),
11184            Some("3"),
11185            "integer header must be stringified"
11186        );
11187        assert_eq!(
11188            get("X-Ratio"),
11189            Some("3.5"),
11190            "float header must be stringified"
11191        );
11192        assert_eq!(
11193            get("X-Enabled"),
11194            Some("true"),
11195            "bool header must be stringified"
11196        );
11197        assert_eq!(
11198            get("X-Label"),
11199            Some("keep"),
11200            "string header must pass through"
11201        );
11202    }
11203
11204    #[test]
11205    fn response_drops_null_and_structured_header_values() {
11206        let mut headers = make_headers(&[("X-Keep", "yes")]);
11207        headers.insert("X-Null".to_string(), serde_json::Value::Null);
11208        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
11209        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
11210        let selected = select_response_headers(&headers, None, None);
11211        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11212        for dropped in ["X-Null", "X-Obj", "X-Arr"] {
11213            assert!(
11214                !names.contains(&dropped),
11215                "{dropped} must not be emitted: no single-value form"
11216            );
11217        }
11218        assert!(names.contains(&"X-Keep"), "scalar headers must survive");
11219    }
11220
11221    #[test]
11222    fn response_stringifies_scalars_despite_excluded_names() {
11223        // Excluded names stay excluded regardless of value type: the policy
11224        // filter runs before stringification, so numeric values cannot smuggle
11225        // content-length or server-owned headers into the reply.
11226        let mut headers = HashMap::new();
11227        headers.insert("Content-Length".to_string(), serde_json::json!(999));
11228        headers.insert("Date".to_string(), serde_json::json!(12345));
11229        let selected = select_response_headers(&headers, None, None);
11230        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11231        assert!(
11232            !names.contains(&"Content-Length"),
11233            "content-length is re-derived by the server"
11234        );
11235        assert!(!names.contains(&"Date"), "date is server-owned");
11236    }
11237
11238    #[test]
11239    fn outbound_stringifies_scalar_header_values() {
11240        let mut headers = make_headers(&[("X-Label", "keep")]);
11241        headers.insert("X-Retries".to_string(), serde_json::json!(3));
11242        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
11243        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
11244        let outbound = select_outbound_headers(&headers, &[], &[]);
11245        // HeaderName construction lowercases; lookups compare case-blind.
11246        let get = |name: &str| -> Option<String> {
11247            outbound
11248                .accepted
11249                .iter()
11250                .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11251                .map(|(_, v)| v.to_str().unwrap().to_string())
11252        };
11253        assert_eq!(
11254            get("X-Retries").as_deref(),
11255            Some("3"),
11256            "integer header must be stringified"
11257        );
11258        assert_eq!(
11259            get("X-Ratio").as_deref(),
11260            Some("3.5"),
11261            "float header must be stringified"
11262        );
11263        assert_eq!(
11264            get("X-Enabled").as_deref(),
11265            Some("true"),
11266            "bool header must be stringified"
11267        );
11268        assert_eq!(
11269            get("X-Label").as_deref(),
11270            Some("keep"),
11271            "string header must pass through"
11272        );
11273        assert!(outbound.drops.is_empty(), "scalar headers must not drop");
11274    }
11275
11276    #[test]
11277    fn outbound_drops_null_and_structured_header_values() {
11278        let mut headers = make_headers(&[("X-Keep", "yes")]);
11279        headers.insert("X-Null".to_string(), serde_json::Value::Null);
11280        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
11281        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
11282        let outbound = select_outbound_headers(&headers, &[], &[]);
11283        let has = |name: &str| {
11284            outbound
11285                .accepted
11286                .iter()
11287                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11288        };
11289        assert!(has("X-Keep"), "scalar headers must survive");
11290        for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
11291            let dropped = outbound
11292                .drops
11293                .iter()
11294                .find(|d| d.name == name)
11295                .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
11296            assert_eq!(
11297                dropped.reason, "no scalar string form",
11298                "{name} drop reason must name the value kind absence"
11299            );
11300            assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
11301        }
11302    }
11303
11304    #[test]
11305    fn outbound_stringifies_scalars_despite_excluded_names() {
11306        // Excluded names stay excluded regardless of value type: the policy
11307        // filter runs before stringification, so numeric values cannot smuggle
11308        // hop-by-hop or client-derived headers onto the wire.
11309        let mut headers = HashMap::new();
11310        headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
11311        headers.insert("Host".to_string(), serde_json::json!(12345));
11312        headers.insert("X-Ok".to_string(), serde_json::json!(7));
11313        let outbound = select_outbound_headers(&headers, &[], &[]);
11314        let has = |name: &str| {
11315            outbound
11316                .accepted
11317                .iter()
11318                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11319        };
11320        assert!(
11321            !has("Transfer-Encoding"),
11322            "hop-by-hop header must stay excluded"
11323        );
11324        assert!(!has("Host"), "host is destination-derived");
11325        assert!(has("X-Ok"), "non-excluded scalar must be stringified");
11326        assert!(
11327            outbound
11328                .drops
11329                .iter()
11330                .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
11331            "policy drop must be recorded before coercion"
11332        );
11333    }
11334
11335    #[test]
11336    fn outbound_drops_invalid_names_values_and_skip_config() {
11337        let mut headers = make_headers(&[("X-Good", "fine")]);
11338        headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
11339        headers.insert(
11340            "X-Control-Value".to_string(),
11341            serde_json::json!("line1\nline2"),
11342        );
11343        headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
11344        headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
11345        let skip = vec!["x-secret".to_string()];
11346        let outbound = select_outbound_headers(&headers, &skip, &[]);
11347        let has = |name: &str| {
11348            outbound
11349                .accepted
11350                .iter()
11351                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11352        };
11353        assert!(has("X-Good"), "valid header must survive");
11354        assert!(!has("X Bad Name"), "invalid header name must drop");
11355        assert!(!has("X-Control-Value"), "control-char value must drop");
11356        assert!(!has("X-Secret"), "skipped header must drop");
11357        assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
11358        let reason = |n: &str| {
11359            outbound
11360                .drops
11361                .iter()
11362                .find(|d| d.name == n)
11363                .map(|d| d.reason)
11364        };
11365        assert_eq!(reason("X Bad Name"), Some("invalid header name"));
11366        assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
11367        assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
11368        assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
11369    }
11370
11371    #[test]
11372    fn constructed_header_invalid_value_returns_drop_record() {
11373        let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
11374        let Err(record) = result else {
11375            panic!("invalid value must produce a drop record");
11376        };
11377        assert_eq!(record.reason, "invalid header value");
11378        assert_eq!(record.name, "user-agent");
11379        assert!(record.value_kind.is_none());
11380        let debug = format!("{record:?}");
11381        assert!(
11382            !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
11383            "drop record debug must not leak the value"
11384        );
11385    }
11386
11387    #[test]
11388    fn constructed_header_invalid_name_returns_drop_record() {
11389        let result = constructed_header("bad name", "ok");
11390        let Err(record) = result else {
11391            panic!("invalid name must produce a drop record");
11392        };
11393        assert_eq!(record.reason, "invalid header name");
11394        assert_eq!(record.name, "bad name");
11395        let debug = format!("{record:?}");
11396        assert!(
11397            !debug.contains("ok"),
11398            "drop record debug must not leak the value"
11399        );
11400    }
11401
11402    #[test]
11403    fn constructed_header_valid_pair_roundtrip() {
11404        let result = constructed_header("authorization", "Bearer abc123");
11405        let Ok((name, val)) = result else {
11406            panic!("valid pair must construct");
11407        };
11408        assert_eq!(name.as_str(), "authorization");
11409        let Ok(roundtrip) = val.to_str() else {
11410            panic!("valid value must roundtrip to str");
11411        };
11412        assert_eq!(roundtrip, "Bearer abc123");
11413    }
11414
11415    // -----------------------------------------------------------------------
11416    // Bridge proxy end-to-end integration tests (Task 4.1)
11417    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
11418    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
11419    // -----------------------------------------------------------------------
11420
11421    /// Destination server that captures the outbound request line and the
11422    /// `Host:` header the producer actually sent on the wire. Returns
11423    /// `(host_value, request_line)` so a bridge-proxy test can assert that
11424    /// the producer derived `Host` from the destination (not the exchange)
11425    /// and honoured bridging semantics for the path.
11426    async fn start_host_capturing_destination() -> (
11427        String,
11428        Arc<std::sync::Mutex<Option<(String, String)>>>,
11429        tokio::task::JoinHandle<()>,
11430    ) {
11431        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11432        let port = listener.local_addr().unwrap().port();
11433        let url = format!("http://127.0.0.1:{port}");
11434        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
11435            Arc::new(std::sync::Mutex::new(None));
11436        let captured_clone = Arc::clone(&captured);
11437        let handle = tokio::spawn(async move {
11438            use tokio::io::{AsyncReadExt, AsyncWriteExt};
11439            if let Ok((mut stream, _)) = listener.accept().await {
11440                let mut buf = vec![0u8; 16384];
11441                let n = stream.read(&mut buf).await.unwrap_or(0);
11442                let request = String::from_utf8_lossy(&buf[..n]).to_string();
11443                if request.contains("\r\n\r\n") {
11444                    let request_line = request.lines().next().unwrap_or("").to_string();
11445                    let host_value = request
11446                        .lines()
11447                        .find(|l| l.to_lowercase().starts_with("host:"))
11448                        .and_then(|l| l.split_once(':'))
11449                        .map(|(_, v)| v.trim().to_string())
11450                        .unwrap_or_default();
11451                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
11452                }
11453                let body = r#"{"echo":"ok"}"#;
11454                let resp = format!(
11455                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
11456                    body.len(),
11457                    body
11458                );
11459                let _ = stream.write_all(resp.as_bytes()).await;
11460            }
11461        });
11462        (url, captured, handle)
11463    }
11464
11465    /// A bridging producer must derive `Host` from the destination URL and
11466    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
11467    /// semantics. The wire-level proof is the raw `Host:` header and request
11468    /// line captured at the destination TCP socket.
11469    #[tokio::test]
11470    async fn bridge_proxy_outbound_host_matches_destination() {
11471        use tower::ServiceExt;
11472
11473        let (url, captured, _handle) = start_host_capturing_destination().await;
11474        // The Host header reqwest derives for http://127.0.0.1:{port} is the
11475        // authority, scheme-stripped: "127.0.0.1:{port}".
11476        let expected_host = url.strip_prefix("http://").unwrap();
11477
11478        let ctx = test_producer_ctx();
11479        let component = HttpComponent::new();
11480        let endpoint_ctx = NoOpComponentContext;
11481        let endpoint = component
11482            .create_endpoint(
11483                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
11484                &endpoint_ctx,
11485            )
11486            .unwrap();
11487        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
11488
11489        // Exchange carries a stale Host and a CamelHttpPath that bridging
11490        // must drop.
11491        let mut exchange = Exchange::new(Message::default());
11492        exchange.input.set_header("Host", "localhost");
11493        exchange.input.set_header("CamelHttpPath", "/foo");
11494
11495        let result = producer.oneshot(exchange).await;
11496        assert!(result.is_ok(), "producer call failed: {:?}", result);
11497
11498        tokio::time::sleep(Duration::from_millis(100)).await;
11499        let (host_value, request_line) = captured
11500            .lock()
11501            .unwrap()
11502            .take()
11503            .expect("destination capture mutex empty — producer did not reach the destination");
11504
11505        assert_ne!(
11506            host_value, "localhost",
11507            "bridge producer must not forward the exchange Host: localhost"
11508        );
11509        assert_eq!(
11510            host_value, expected_host,
11511            "Host must be derived from the destination authority (no scheme)"
11512        );
11513        assert!(
11514            !request_line.contains("/foo"),
11515            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
11516        );
11517    }
11518
11519    /// A response header set by the route (`Cache-Control`) must survive to
11520    /// the wire. The assertion is on the reqwest HTTP response — not an
11521    /// in-process HttpReply struct — so it proves the consumer's reply
11522    /// finaliser emitted the header over the socket.
11523    #[tokio::test]
11524    async fn bridge_proxy_route_set_response_header_survives() {
11525        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11526
11527        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11528        let port = listener.local_addr().unwrap().port();
11529        drop(listener);
11530
11531        let component = HttpComponent::new();
11532        let endpoint_ctx = NoOpComponentContext;
11533        let endpoint = component
11534            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
11535            .unwrap();
11536        let mut consumer = endpoint.create_consumer(rt()).unwrap();
11537
11538        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11539        let token = tokio_util::sync::CancellationToken::new();
11540        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
11541
11542        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11543        tokio::time::sleep(Duration::from_millis(50)).await;
11544
11545        let client = reqwest::Client::new();
11546        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
11547
11548        // Route sets Cache-Control on the outbound reply (exchange.input is
11549        // the message the reply finaliser reads — see select_response_headers
11550        // at the dispatch site).
11551        let (http_result, _) = tokio::join!(send_fut, async {
11552            if let Some(mut envelope) = rx.recv().await {
11553                envelope
11554                    .exchange
11555                    .input
11556                    .set_header("Cache-Control", "public, max-age=3600");
11557                if let Some(reply_tx) = envelope.reply_tx {
11558                    let _ = reply_tx.send(Ok(envelope.exchange));
11559                }
11560            }
11561        });
11562
11563        let resp = http_result.unwrap();
11564        assert_eq!(resp.status().as_u16(), 200);
11565
11566        let cache_control = resp.headers().get("cache-control");
11567        assert!(
11568            cache_control.is_some(),
11569            "Cache-Control header must survive to the wire response"
11570        );
11571        assert_eq!(
11572            cache_control.unwrap().to_str().unwrap(),
11573            "public, max-age=3600"
11574        );
11575
11576        token.cancel();
11577    }
11578
11579    // -----------------------------------------------------------------------
11580    // credential-sources task 2.3: credential values stay out of diagnostics
11581    // -----------------------------------------------------------------------
11582    //
11583    // camel-http has no request access log (design.md "Redaction sinks",
11584    // ADR-0051). The only diagnostic sink on the failed-auth path is
11585    // `pipeline_error_to_reply`, which renders the (generic) error message and
11586    // the *configured* route path — never the request URI, query string, or
11587    // extracted credential. These tests pin that redact-by-construction
11588    // contract: a sentinel credential presented in a declared source must not
11589    // appear in the reply body nor in any tracing record emitted while the
11590    // request is handled.
11591    //
11592    // Capture scope: `#[traced_test]` installs a per-crate env filter
11593    // (`camel_component_http=trace`), so records from OTHER targets
11594    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
11595    // redaction contract for those crates is guarded by their own tests.
11596    // Revisit this capture scope if camel-auth ever logs on the auth path.
11597    use camel_api::security_policy::CredentialSource;
11598    use camel_auth::credential_source::extract_token_from_exchange;
11599    use camel_auth::native_auth::NativeCredentialStore;
11600    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
11601
11602    // Sentinel credential values — test fixtures only, not real secrets.
11603    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
11604    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
11605    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
11606
11607    /// Build the exchange the consumer would build for a request envelope:
11608    /// standard Camel HTTP headers plus title-cased forwarded request headers.
11609    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
11610        let mut msg = Message::default();
11611        msg.set_header(
11612            "CamelHttpMethod",
11613            serde_json::Value::String(envelope.method.clone()),
11614        );
11615        msg.set_header(
11616            "CamelHttpPath",
11617            serde_json::Value::String(envelope.path.clone()),
11618        );
11619        msg.set_header(
11620            "CamelHttpQuery",
11621            serde_json::Value::String(envelope.query.clone()),
11622        );
11623        for (k, v) in &envelope.headers {
11624            if let Ok(val_str) = v.to_str() {
11625                msg.set_header(
11626                    title_case_header(k.as_str()),
11627                    serde_json::Value::String(val_str.to_string()),
11628                );
11629            }
11630        }
11631        Exchange::new(msg)
11632    }
11633
11634    /// Register a route whose responder authenticates each request against an
11635    /// empty native store, so every presented credential fails lookup with
11636    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
11637    /// authentication step (extract per `sources` → authenticate → deny) so the
11638    /// credential-extraction redaction contract is exercised on a real
11639    /// authentication failure.
11640    async fn spawn_failing_auth_route(
11641        registry: &HttpRouteRegistry,
11642        path: &str,
11643        sources: Vec<CredentialSource>,
11644    ) {
11645        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
11646            NativeCredentialStore::try_new(vec![]).unwrap(),
11647        ));
11648        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11649        registry.register_api_route(path.to_string(), tx).await;
11650        let path_owned = path.to_string();
11651        tokio::spawn(async move {
11652            while let Some(envelope) = rx.recv().await {
11653                let exchange = envelope_to_exchange(&envelope);
11654                let reply_tx = envelope.reply_tx;
11655                let result: Result<(), CamelError> = async {
11656                    let token = extract_token_from_exchange(&exchange, &sources)
11657                        .map(|extracted| extracted.token)
11658                        .ok_or_else(|| {
11659                            CamelError::Unauthenticated("no credential in any source".into())
11660                        })?;
11661                    authenticator.authenticate_bearer(&token).await?;
11662                    Ok(())
11663                }
11664                .await;
11665                let reply = match result {
11666                    Ok(()) => HttpReply {
11667                        status: 200,
11668                        headers: vec![],
11669                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
11670                    },
11671                    Err(e) => pipeline_error_to_reply(e, &path_owned),
11672                };
11673                let _ = reply_tx.send(reply);
11674            }
11675        });
11676    }
11677
11678    /// Whether any tracing record captured so far (process-wide) contains
11679    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
11680    /// shared buffer, so logs from spawned request-handling tasks are included.
11681    fn captured_logs_contain(needle: &str) -> bool {
11682        let buf = tracing_test::internal::global_buf().lock().unwrap();
11683        String::from_utf8_lossy(&buf).contains(needle)
11684    }
11685
11686    #[tracing_test::traced_test]
11687    #[tokio::test]
11688    async fn error_context_redacts_query_sentinel() {
11689        let (port, registry) = spawn_test_server().await;
11690        spawn_failing_auth_route(
11691            &registry,
11692            "/secure-query",
11693            vec![CredentialSource::QueryParam {
11694                param: "token".to_string(),
11695            }],
11696        )
11697        .await;
11698
11699        let client = reqwest::Client::new();
11700        let resp = client
11701            // allow-secret: `token` is the declared query-source param name, not a credential
11702            .get(format!(
11703                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
11704            ))
11705            .send()
11706            .await
11707            .unwrap();
11708
11709        assert_eq!(resp.status().as_u16(), 401);
11710        let body = resp.text().await.unwrap();
11711        assert_eq!(body, "Unauthorized");
11712        assert!(
11713            !body.contains(SENTINEL_QRY_42),
11714            "reply body must not contain the query credential"
11715        );
11716        assert!(
11717            !captured_logs_contain(SENTINEL_QRY_42),
11718            "no tracing record during request handling may render the query credential"
11719        );
11720        // Permanent positive control: the failed-auth warn! must be captured.
11721        // If the per-crate env filter ever stops matching, this fails loudly
11722        // instead of letting the sentinel assertions pass vacuously.
11723        assert!(
11724            captured_logs_contain("Authentication failed"),
11725            "positive control: the failed-auth warn! must be captured by the test subscriber"
11726        );
11727    }
11728
11729    #[tracing_test::traced_test]
11730    #[tokio::test]
11731    async fn error_context_redacts_cookie_sentinel() {
11732        let (port, registry) = spawn_test_server().await;
11733        spawn_failing_auth_route(
11734            &registry,
11735            "/secure-cookie",
11736            vec![CredentialSource::Cookie {
11737                name: "session".to_string(),
11738            }],
11739        )
11740        .await;
11741
11742        let client = reqwest::Client::new();
11743        let resp = client
11744            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
11745            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
11746            .send()
11747            .await
11748            .unwrap();
11749
11750        assert_eq!(resp.status().as_u16(), 401);
11751        let body = resp.text().await.unwrap();
11752        assert_eq!(body, "Unauthorized");
11753        assert!(
11754            !body.contains(SENTINEL_CKY_7),
11755            "reply body must not contain the cookie credential"
11756        );
11757        assert!(
11758            !captured_logs_contain(SENTINEL_CKY_7),
11759            "no tracing record during request handling may render the cookie credential"
11760        );
11761    }
11762
11763    #[tracing_test::traced_test]
11764    #[tokio::test]
11765    async fn error_reply_no_credential_value() {
11766        let (port, registry) = spawn_test_server().await;
11767        spawn_failing_auth_route(
11768            &registry,
11769            "/secure-bad",
11770            vec![CredentialSource::Cookie {
11771                name: "session".to_string(),
11772            }],
11773        )
11774        .await;
11775
11776        let client = reqwest::Client::new();
11777        let resp = client
11778            .get(format!("http://127.0.0.1:{port}/secure-bad"))
11779            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
11780            .send()
11781            .await
11782            .unwrap();
11783
11784        assert_eq!(resp.status().as_u16(), 401);
11785        let body = resp.text().await.unwrap();
11786        assert_eq!(body, "Unauthorized");
11787        assert!(
11788            !body.contains(SENTINEL_BAD_1),
11789            "reply body must not contain the credential value"
11790        );
11791        assert!(
11792            !captured_logs_contain(SENTINEL_BAD_1),
11793            "error logs must not render the credential value"
11794        );
11795    }
11796
11797    // -----------------------------------------------------------------------
11798    // Pinned-client-cache producer-path behavioral tests
11799    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
11800    // the endpoint cache, hostname requests build one client while the entry
11801    // stays retrievable, IP-literal requests bypass the cache)
11802    // -----------------------------------------------------------------------
11803
11804    /// Local responder that accepts any number of HTTP/1.1 connections on an
11805    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
11806    /// Unlike [`start_host_capturing_destination`], which serves exactly one
11807    /// connection, this loop keeps accepting so cache-reuse tests can drive
11808    /// several requests through one destination. Returns
11809    /// `(base_url, JoinHandle)`.
11810    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
11811        use tokio::io::AsyncWriteExt;
11812
11813        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
11814            .await
11815            .expect("bind ephemeral 127.0.0.1 listener");
11816        let port = listener.local_addr().expect("local addr").port();
11817        let base_url = format!("http://localhost:{port}");
11818        let handle = tokio::spawn(async move {
11819            while let Ok((mut conn, _)) = listener.accept().await {
11820                let _ = conn
11821                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
11822                    .await;
11823                let _ = conn.shutdown().await;
11824            }
11825        });
11826        (base_url, handle)
11827    }
11828
11829    /// rc-0li3: local HTTPS responder — the TLS twin of
11830    /// [`spawn_multi_accept_200`]. Accepts any number of TLS connections on
11831    /// an ephemeral 127.0.0.1 port and answers each with a fixed 200. The
11832    /// certificate comes from `camel_component_api::test_support`
11833    /// (SANs: localhost, 127.0.0.1, ::1); clients run with
11834    /// `tls.insecure = true`.
11835    async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
11836        use tokio::io::AsyncWriteExt;
11837
11838        let (_ca_pem, cert_pem, key_pem) =
11839            camel_component_api::test_support::tls::gen_server_cert();
11840        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
11841            .collect::<Result<_, _>>()
11842            .expect("parse server cert pem");
11843        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
11844            .expect("parse server key pem")
11845            .expect("server key present");
11846        // Explicit provider: the process default is ambiguous when multiple
11847        // crates pull rustls feature sets; the graph enables aws-lc-rs.
11848        let provider =
11849            std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
11850        let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
11851            .with_safe_default_protocol_versions()
11852            .expect("safe default protocol versions")
11853            .with_no_client_auth()
11854            .with_single_cert(certs, key)
11855            .expect("build rustls server config");
11856        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
11857
11858        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
11859            .await
11860            .expect("bind ephemeral 127.0.0.1 listener");
11861        let port = listener.local_addr().expect("local addr").port();
11862        let base_url = format!("https://localhost:{port}");
11863        let handle = tokio::spawn(async move {
11864            while let Ok((conn, _)) = listener.accept().await {
11865                let acceptor = acceptor.clone();
11866                tokio::spawn(async move {
11867                    if let Ok(mut tls) = acceptor.accept(conn).await {
11868                        let _ = tls
11869                            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
11870                            .await;
11871                        let _ = tls.shutdown().await;
11872                    }
11873                });
11874            }
11875        });
11876        (base_url, handle)
11877    }
11878
11879    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
11880    /// target a different authority (the 127.0.0.1 literal) on the same
11881    /// listener.
11882    fn responder_port(base_url: &str) -> u16 {
11883        url::Url::parse(base_url)
11884            .expect("responder base URL parses")
11885            .port()
11886            .expect("responder base URL carries an explicit port")
11887    }
11888
11889    /// Build an endpoint literal whose outbound config points at
11890    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
11891    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
11892    /// build counts stay observable across producers.
11893    fn endpoint_with_shared_cache(
11894        base_url: &str,
11895        pinned_cache: &Arc<PinnedClientCache>,
11896    ) -> HttpEndpoint {
11897        let uri = format!("{base_url}?allowInternal=true");
11898        HttpEndpoint {
11899            uri: uri.clone(),
11900            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
11901            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
11902            client: reqwest::Client::new(),
11903            pinned_cache: Arc::clone(pinned_cache),
11904            http_config: HttpConfig::default(),
11905        }
11906    }
11907
11908    #[tokio::test]
11909    async fn producers_share_endpoint_cache() {
11910        use tower::ServiceExt;
11911
11912        let (base_url, _handle) = spawn_multi_accept_200().await;
11913        let pinned_cache = Arc::new(PinnedClientCache::new(
11914            PINNED_CLIENT_TTL,
11915            PINNED_CLIENT_MAX_ENTRIES,
11916        ));
11917
11918        let ctx = test_producer_ctx();
11919        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11920        let producer_a = endpoint.create_producer(rt(), &ctx);
11921        let producer_b = endpoint.create_producer(rt(), &ctx);
11922
11923        // Each producer sends one exchange whose resolved URL is the
11924        // endpoint's localhost base URL (a domain name → pinned-client path).
11925        for producer in [producer_a, producer_b] {
11926            let producer = producer.expect("create producer");
11927            let exchange = Exchange::new(Message::default());
11928            let reply = producer.oneshot(exchange).await;
11929            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11930        }
11931
11932        assert_eq!(
11933            pinned_cache.build_count(),
11934            1,
11935            "both producers must hit the same shared cache entry; a second \
11936             build means sharing is broken"
11937        );
11938    }
11939
11940    #[tokio::test]
11941    async fn producer_repeated_hostname_requests_build_one_client() {
11942        use tower::ServiceExt;
11943
11944        let (base_url, _handle) = spawn_multi_accept_200().await;
11945        let pinned_cache = Arc::new(PinnedClientCache::new(
11946            PINNED_CLIENT_TTL,
11947            PINNED_CLIENT_MAX_ENTRIES,
11948        ));
11949        let ctx = test_producer_ctx();
11950        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11951        let producer = endpoint
11952            .create_producer(rt(), &ctx)
11953            .expect("create producer");
11954
11955        // Two sequential hostname requests — the cached pinned client stays
11956        // retrievable between them, so no second build may happen.
11957        for i in 0..2 {
11958            let exchange = Exchange::new(Message::default());
11959            let reply = producer.clone().oneshot(exchange).await;
11960            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
11961        }
11962
11963        assert_eq!(
11964            pinned_cache.build_count(),
11965            1,
11966            "repeated hostname requests must reuse the one pinned client; \
11967             0 builds means the producer bypassed the cache, more than 1 \
11968             means the entry was dropped"
11969        );
11970    }
11971
11972    #[tokio::test]
11973    async fn ip_literal_request_never_enters_cache() {
11974        use tower::ServiceExt;
11975
11976        let (base_url, _handle) = spawn_multi_accept_200().await;
11977        let pinned_cache = Arc::new(PinnedClientCache::new(
11978            PINNED_CLIENT_TTL,
11979            PINNED_CLIENT_MAX_ENTRIES,
11980        ));
11981
11982        let ctx = test_producer_ctx();
11983        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
11984        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
11985        let producer = endpoint
11986            .create_producer(rt(), &ctx)
11987            .expect("create producer");
11988
11989        let exchange = Exchange::new(Message::default());
11990        let reply = producer.oneshot(exchange).await;
11991        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11992
11993        assert_eq!(
11994            pinned_cache.build_count(),
11995            0,
11996            "an IP-literal URL must use the shared unpinned client and \
11997             never enter the pinned cache"
11998        );
11999    }
12000
12001    #[tokio::test]
12002    async fn test_component_endpoints_share_pinned_cache() {
12003        use tower::ServiceExt;
12004
12005        let component = HttpComponent::new();
12006        let (base_url, _handle) = spawn_multi_accept_200().await;
12007        let baseline = component.pinned_cache.build_count();
12008
12009        let ctx = test_producer_ctx();
12010        let endpoint_ctx = NoOpComponentContext;
12011        for uri in [
12012            format!("{base_url}/a?allowInternal=true&k=a"),
12013            format!("{base_url}/b?allowInternal=true&k=b"),
12014        ] {
12015            let endpoint = component
12016                .create_endpoint(&uri, &endpoint_ctx)
12017                .expect("create endpoint");
12018            let producer = endpoint
12019                .create_producer(rt(), &ctx)
12020                .expect("create producer");
12021            let exchange = Exchange::new(Message::default());
12022            let reply = producer.oneshot(exchange).await;
12023            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12024        }
12025
12026        assert_eq!(
12027            component.pinned_cache.build_count() - baseline,
12028            1,
12029            "endpoints created by one component must share its pinned cache; \
12030             0 builds means the endpoints bypassed it, more than 1 means \
12031             per-endpoint caches came back"
12032        );
12033    }
12034
12035    #[tokio::test]
12036    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
12037        use tower::ServiceExt;
12038
12039        let component = HttpComponent::new();
12040        let (base_url, _handle) = spawn_multi_accept_200().await;
12041        let baseline = component.pinned_cache.build_count();
12042
12043        let ctx = test_producer_ctx();
12044        let endpoint_ctx = NoOpComponentContext;
12045        for i in 0..3 {
12046            let endpoint = component
12047                .create_endpoint(
12048                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
12049                    &endpoint_ctx,
12050                )
12051                .expect("create endpoint");
12052            let producer = endpoint
12053                .create_producer(rt(), &ctx)
12054                .expect("create producer");
12055            let exchange = Exchange::new(Message::default());
12056            let reply = producer.oneshot(exchange).await;
12057            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
12058        }
12059
12060        assert_eq!(
12061            component.pinned_cache.build_count() - baseline,
12062            1,
12063            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
12064             must reuse the component's one pinned cache entry; 0 builds \
12065             means the endpoints bypassed it, more than 1 means \
12066             per-endpoint caches came back"
12067        );
12068    }
12069
12070    /// rc-0li3: BEHAVIORAL https sharing pin — two https endpoints created
12071    /// through one `HttpsComponent` drive real TLS requests through the
12072    /// component's single pinned cache. A regression that reintroduces
12073    /// per-endpoint `PinnedClientCache::new` inside
12074    /// `HttpsComponent::create_endpoint` leaves the component cache at
12075    /// delta 0 and fails this test (the structural ptr_eq test cannot see
12076    /// that).
12077    #[tokio::test]
12078    async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
12079        use tower::ServiceExt;
12080
12081        let http_config = HttpConfig {
12082            tls: Some(crate::config::TlsConfig {
12083                enabled: true,
12084                insecure: true,
12085                ..Default::default()
12086            }),
12087            ..Default::default()
12088        };
12089        let component = HttpsComponent::with_config(http_config);
12090        let (base_url, _handle) = spawn_tls_multi_accept_200().await;
12091        let baseline = component.pinned_cache.build_count();
12092
12093        let ctx = test_producer_ctx();
12094        let endpoint_ctx = NoOpComponentContext;
12095        for uri in [
12096            format!("{base_url}/a?allowInternal=true&k=a"),
12097            format!("{base_url}/b?allowInternal=true&k=b"),
12098        ] {
12099            let endpoint = component
12100                .create_endpoint(&uri, &endpoint_ctx)
12101                .expect("create https endpoint");
12102            let producer = endpoint
12103                .create_producer(rt(), &ctx)
12104                .expect("create producer");
12105            let exchange = Exchange::new(Message::default());
12106            let reply = producer.oneshot(exchange).await;
12107            assert!(reply.is_ok(), "https request failed: {reply:?}");
12108        }
12109
12110        assert_eq!(
12111            component.pinned_cache.build_count() - baseline,
12112            1,
12113            "endpoints of one HttpsComponent must share its pinned cache over \
12114             real https requests; 0 builds means the endpoints bypassed it \
12115             (per-endpoint cache regression), more than 1 means \
12116             per-endpoint caches came back"
12117        );
12118    }
12119
12120    #[test]
12121    fn test_https_component_owns_distinct_cache() {
12122        let http = HttpComponent::new();
12123        let https = HttpsComponent::new();
12124
12125        assert!(
12126            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
12127            "http and https components must each own their own pinned cache"
12128        );
12129
12130        let endpoint_ctx = NoOpComponentContext;
12131        let _ = http
12132            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
12133            .expect("http endpoint");
12134        let _ = https
12135            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
12136            .expect("https endpoint");
12137
12138        assert_eq!(
12139            http.pinned_cache.build_count(),
12140            0,
12141            "endpoint creation must not build a pinned client"
12142        );
12143        assert_eq!(
12144            https.pinned_cache.build_count(),
12145            0,
12146            "endpoint creation must not build a pinned client"
12147        );
12148    }
12149
12150    #[test]
12151    fn test_component_constructor_builds_one_unpinned_client() {
12152        let baseline = build_client_call_count();
12153
12154        let _http = HttpComponent::new();
12155        assert_eq!(
12156            build_client_call_count() - baseline,
12157            1,
12158            "HttpComponent::new() must build exactly one shared unpinned client"
12159        );
12160
12161        let _https = HttpsComponent::new();
12162        assert_eq!(
12163            build_client_call_count() - baseline,
12164            2,
12165            "HttpsComponent::new() must build exactly one more shared unpinned client"
12166        );
12167    }
12168
12169    #[test]
12170    fn test_component_endpoints_share_unpinned_client() {
12171        let component = HttpComponent::new();
12172        let baseline = build_client_call_count();
12173
12174        let endpoint_ctx = NoOpComponentContext;
12175        for uri in [
12176            "http://localhost:1/a?allowInternal=true",
12177            "http://localhost:1/b?allowInternal=true",
12178        ] {
12179            let _endpoint = component
12180                .create_endpoint(uri, &endpoint_ctx)
12181                .expect("create endpoint");
12182        }
12183
12184        assert_eq!(
12185            build_client_call_count() - baseline,
12186            0,
12187            "create_endpoint must clone the component's shared unpinned client, \
12188             never build a fresh one"
12189        );
12190    }
12191
12192    #[test]
12193    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
12194        let component = HttpComponent::new();
12195        let baseline = build_client_call_count();
12196
12197        let ctx = test_producer_ctx();
12198        let endpoint_ctx = NoOpComponentContext;
12199        for i in 0..3 {
12200            let endpoint = component
12201                .create_endpoint(
12202                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
12203                    &endpoint_ctx,
12204                )
12205                .expect("create endpoint");
12206            let _producer = endpoint
12207                .create_producer(rt(), &ctx)
12208                .expect("create producer");
12209        }
12210
12211        assert_eq!(
12212            build_client_call_count() - baseline,
12213            0,
12214            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
12215             must reuse the component's shared unpinned client and build \
12216             no additional clients"
12217        );
12218    }
12219}