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", &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            let mut url = base.to_string();
2559            if let Some(path) = exchange
2560                .input
2561                .header("CamelHttpPath")
2562                .and_then(|v| v.as_str())
2563            {
2564                if !url.ends_with('/') && !path.starts_with('/') {
2565                    url.push('/');
2566                }
2567                url.push_str(path);
2568            }
2569            if let Some(query) = exchange
2570                .input
2571                .header("CamelHttpQuery")
2572                .and_then(|v| v.as_str())
2573            {
2574                if let Some(merged) = merge_header_query(override_query, query)? {
2575                    url.push('?');
2576                    url.push_str(&merged);
2577                }
2578                return Ok(url);
2579            }
2580            if let Some(query) = override_query {
2581                url.push('?');
2582                url.push_str(query);
2583            }
2584            return Ok(url);
2585        }
2586
2587        let mut url = config.base_url.clone();
2588
2589        if let Some(path) = exchange
2590            .input
2591            .header("CamelHttpPath")
2592            .and_then(|v| v.as_str())
2593        {
2594            if !url.ends_with('/') && !path.starts_with('/') {
2595                url.push('/');
2596            }
2597            url.push_str(path);
2598        }
2599
2600        if let Some(query) = exchange
2601            .input
2602            .header("CamelHttpQuery")
2603            .and_then(|v| v.as_str())
2604        {
2605            // Compose: the endpoint query (raw-preserving,
2606            // consumed-option-filtered) comes first and wins collisions;
2607            // header pairs append verbatim for absent keys (ADR-0071).
2608            // An empty header leaves the endpoint query unchanged.
2609            if let Some(merged) =
2610                merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2611            {
2612                url.push('?');
2613                url.push_str(&merged);
2614            }
2615            return Ok(url);
2616        }
2617
2618        if let Some(query) = resolve_endpoint_query(config)? {
2619            url.push('?');
2620            url.push_str(&query);
2621        }
2622
2623        Ok(url)
2624    }
2625
2626    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2627        status >= range.0 && status <= range.1
2628    }
2629}
2630
2631/// One allowlist entry of the `CamelHttpUri` host fence (`allowedUriHosts`
2632/// endpoint option). DNS hosts are stored ASCII-lowercased; IPv6 literals
2633/// in bracketed canonical form (the `url` crate's host serialization). A
2634/// `port` of `None` is a host-only entry and permits any port.
2635#[derive(Clone, Debug, PartialEq, Eq)]
2636pub struct AllowedUriHost {
2637    /// Canonical host: lowercased DNS name or bracketed IPv6 literal.
2638    pub host: String,
2639    /// `Some` pins the entry to one effective port; `None` permits any.
2640    pub port: Option<u16>,
2641}
2642
2643/// Parse the `allowedUriHosts` option value: comma-separated `host` or
2644/// `host:port` entries. Bracketed IPv6 is supported (`[::1]:8443`, bare
2645/// `[::1]` host-only). Empty segments are dropped. Segments are parsed
2646/// through the `url` crate (with an `http://` scheme injected) so DNS
2647/// names are lowercased and ports range-checked; anything it rejects is a
2648/// malformed entry. A value yielding zero valid entries is also an error.
2649/// Both failure modes fail endpoint creation (fail-closed).
2650fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2651    let mut entries = Vec::new();
2652    for segment in raw.split(',') {
2653        let segment = segment.trim();
2654        if segment.is_empty() {
2655            continue;
2656        }
2657        let parsed = url::Url::parse(&format!("http://{segment}"))
2658            .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2659        // A segment carrying a path or userinfo is a typo'd entry — the
2660        // spec's "any other malformed entry" clause. Silently narrowing it
2661        // to its hostname would widen or skew the fence.
2662        if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2663            return Err(invalid_allowed_uri_host_entry(segment));
2664        }
2665        let Some(host) = parsed.host_str() else {
2666            return Err(invalid_allowed_uri_host_entry(segment));
2667        };
2668        entries.push(AllowedUriHost {
2669            host: host.to_string(),
2670            port: parsed.port(),
2671        });
2672    }
2673    if entries.is_empty() {
2674        return Err(CamelError::InvalidUri(
2675            "allowedUriHosts declares no valid host entries".to_string(),
2676        ));
2677    }
2678    Ok(entries)
2679}
2680
2681fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2682    CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2683}
2684
2685/// Whether `url_str` matches the fence. Parse failure or a host-less URL
2686/// is fail-closed (`Ok(false)`). DNS hosts compare case-insensitively
2687/// (both sides are lowercased by the `url` crate); IPv6 compares in
2688/// bracketed canonical form. A host-only entry permits any port; a
2689/// `host:port` entry matches only the effective port — the explicit port
2690/// or the scheme default (443 for https, 80 for http).
2691fn uri_host_allowed(url_str: &str, fence: &[AllowedUriHost]) -> Result<bool, CamelError> {
2692    let Ok(parsed) = url::Url::parse(url_str) else {
2693        return Ok(false);
2694    };
2695    let Some(host) = parsed.host_str() else {
2696        return Ok(false);
2697    };
2698    let effective_port = parsed.port().or(match parsed.scheme() {
2699        "https" => Some(443_u16),
2700        "http" => Some(80),
2701        _ => None,
2702    });
2703    Ok(fence.iter().any(|entry| {
2704        entry.host == host
2705            && match entry.port {
2706                None => true,
2707                Some(port) => effective_port == Some(port),
2708            }
2709    }))
2710}
2711
2712/// Serialize the outbound query for the endpoint base.
2713///
2714/// Authored raw pairs come first, byte-for-byte minus consumed option keys
2715/// (order, separators and authored escapes — including `RAW(...)` text —
2716/// preserved); then programmatic `query_params` entries whose key is absent
2717/// from the authored pairs, in declaration order with minimal RFC-3986
2718/// encoding (`%20`, never `+`). Authored keys always win — no duplication,
2719/// no override.
2720///
2721/// Returns `Ok(None)` when no query component is emitted: no pairs at all,
2722/// or a non-empty raw query whose every pair was consumed. A bare `?`
2723/// marker (`raw_query == Some("")`) always emits the query component.
2724fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2725    let mut parts: Vec<String> = Vec::new();
2726    let mut authored_keys = std::collections::HashSet::new();
2727
2728    if let Some(raw) = config.raw_query.as_deref() {
2729        for (key, span) in raw_query_pairs(raw)? {
2730            authored_keys.insert(key.clone());
2731            if is_consumed_option(&key) {
2732                continue;
2733            }
2734            validate_raw_query_span(span)?;
2735            parts.push(span.to_string());
2736        }
2737    }
2738
2739    for (key, value) in &config.query_params {
2740        if !authored_keys.contains(key.as_str()) {
2741            parts.push(format!(
2742                "{}={}",
2743                encode_query_component(key),
2744                encode_query_component(value)
2745            ));
2746        }
2747    }
2748
2749    if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2750        return Ok(None);
2751    }
2752    Ok(Some(parts.join("&")))
2753}
2754
2755/// Compose the outbound query when a `CamelHttpQuery` exchange header is
2756/// present (ADR-0071). `higher_precedence` — the endpoint query in the
2757/// base arm, the override URI's own query in the override arm — comes
2758/// first and wins any key collision; header pairs append verbatim for
2759/// absent keys only. An empty header leaves the higher-precedence query
2760/// unchanged (no additional `?` marker). Header spans are validated, not
2761/// re-encoded: a byte forbidden in a query component is a resolve error
2762/// naming the byte (Wave-A law).
2763fn merge_header_query(
2764    higher_precedence: Option<&str>,
2765    header_query: &str,
2766) -> Result<Option<String>, CamelError> {
2767    if header_query.is_empty() {
2768        return Ok(higher_precedence.map(str::to_string));
2769    }
2770    let mut parts: Vec<String> = Vec::new();
2771    let mut higher_keys = std::collections::HashSet::new();
2772    for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2773        higher_keys.insert(key);
2774        parts.push(span.to_string());
2775    }
2776    for (key, span) in raw_query_pairs(header_query)? {
2777        validate_raw_query_span(span)?;
2778        if !higher_keys.contains(key.as_str()) {
2779            parts.push(span.to_string());
2780        }
2781    }
2782    if parts.is_empty() {
2783        return Ok(None);
2784    }
2785    Ok(Some(parts.join("&")))
2786}
2787
2788/// Bytes that may appear unescaped in a URI query component (RFC 3986
2789/// `query = *( pchar / "/" / "?" )`): unreserved, sub-delims, `:`, `@`,
2790/// `/`, `?`, plus the `%` escape introducer.
2791fn is_legal_query_byte(byte: u8) -> bool {
2792    matches!(byte,
2793        b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
2794        | b'-' | b'.' | b'_' | b'~'
2795        | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
2796        | b':' | b'@' | b'/' | b'?'
2797        | b'%')
2798}
2799
2800/// Reject an authored raw pair carrying a byte that is not legal in a query
2801/// component (e.g. literal space, `#`, non-ASCII). The serializer never
2802/// silently re-encodes operator-authored bytes: "byte-for-byte" is bounded
2803/// to wire-legal bytes, and the check fires before the resolved string
2804/// reaches any consumer (SSRF pre-check, diagnostics redaction).
2805fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
2806    for &byte in span.as_bytes() {
2807        if !is_legal_query_byte(byte) {
2808            return Err(CamelError::ProcessorError(format!(
2809                "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
2810            )));
2811        }
2812    }
2813    Ok(())
2814}
2815
2816/// Minimal RFC-3986 percent-encoding for one programmatic query component:
2817/// unreserved bytes pass through, every other byte encodes as uppercase
2818/// hex. A space encodes as `%20`, never `+`.
2819fn encode_query_component(component: &str) -> String {
2820    const HEX: &[u8; 16] = b"0123456789ABCDEF";
2821    let mut out = String::with_capacity(component.len());
2822    for &byte in component.as_bytes() {
2823        match byte {
2824            b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
2825                out.push(byte as char);
2826            }
2827            _ => {
2828                out.push('%');
2829                out.push(HEX[(byte >> 4) as usize] as char);
2830                out.push(HEX[(byte & 0x0f) as usize] as char);
2831            }
2832        }
2833    }
2834    out
2835}
2836
2837/// Redact credentials from a URL before it reaches logs or error values
2838/// (ADR-0051 redact-by-construction). Masks userinfo (`user:pass@`) and the
2839/// query string (which commonly carries API keys/tokens). Host and path stay
2840/// visible for diagnosability. Best-effort: on parse failure the raw string is
2841/// returned truncated to 256 chars (never a secret-bearing suffix).
2842fn redact_url_for_diagnostics(raw: &str) -> String {
2843    const MAX_URL_LOG_LEN: usize = 256;
2844    match url::Url::parse(raw) {
2845        Ok(mut u) => {
2846            if !u.username().is_empty() {
2847                let _ = u.set_username("***");
2848                let _ = u.set_password(None);
2849            }
2850            if u.query().is_some() {
2851                u.set_query(None);
2852                // Mark that a query was present without echoing it.
2853                let mut s = u.to_string();
2854                if let Some(stripped) = s.strip_suffix('?') {
2855                    s = stripped.to_string();
2856                }
2857                s.push_str("?[redacted]");
2858                if s.len() > MAX_URL_LOG_LEN {
2859                    s.truncate(MAX_URL_LOG_LEN);
2860                }
2861                return s;
2862            }
2863            let mut s = u.to_string();
2864            if s.len() > MAX_URL_LOG_LEN {
2865                s.truncate(MAX_URL_LOG_LEN);
2866            }
2867            s
2868        }
2869        Err(_) => {
2870            let mut s = raw.to_string();
2871            s.truncate(MAX_URL_LOG_LEN);
2872            s
2873        }
2874    }
2875}
2876
2877/// Maximum bytes of an upstream error response body embedded into
2878/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
2879/// malicious or compromised upstream), so it is truncated and lossy-decoded to
2880/// bound log injection / DLQ payload size.
2881const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
2882
2883fn truncate_error_body(body: &[u8]) -> String {
2884    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
2885        String::from_utf8_lossy(body).into_owned()
2886    } else {
2887        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
2888        s.push_str("...[truncated]");
2889        s
2890    }
2891}
2892
2893impl HttpProducer {
2894    /// Whether the HTTP method is entity-enclosing (may carry a request
2895    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
2896    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
2897    /// §9.3.1/§9.3.2).
2898    fn is_entity_enclosing(method: &str) -> bool {
2899        matches!(method, "POST" | "PUT" | "PATCH")
2900    }
2901}
2902
2903impl Service<Exchange> for HttpProducer {
2904    type Response = Exchange;
2905    type Error = CamelError;
2906    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2907
2908    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2909        Poll::Ready(Ok(()))
2910    }
2911
2912    fn call(&mut self, exchange: Exchange) -> Self::Future {
2913        let config = self.config.clone();
2914        let shared_client = self.client.clone();
2915        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2916        let http_config = self.http_config.clone();
2917        let component_metrics = self.runtime.component_metrics();
2918
2919        Box::pin(async move {
2920            let mut exchange = exchange;
2921            let outcome = async {
2922                let method_str = HttpProducer::resolve_method(&exchange, &config);
2923                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
2924                // and PATCH may carry a request body. Any other resolved method
2925                // drops the exchange body before the request is built (Apache
2926                // Camel `HttpMethods` parity).
2927                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2928                let url = HttpProducer::resolve_url(&exchange, &config)?;
2929
2930                // SECURITY: Validate URL for SSRF
2931                ssrf::validate_url_for_ssrf(&url, &config)?;
2932
2933                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
2934                // (L-H2). When the URL uses a domain name and SSRF protection is active,
2935                // reuse the endpoint's cached DNS-pinned client for that validated
2936                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
2937                // repeated requests keep one connection pool without re-resolving DNS.
2938                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
2939                // URLs use the endpoint's unpinned shared client.
2940                let resolved =
2941                    ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2942                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2943                    pinned_cache
2944                        .get_or_build(host.as_str(), addrs, || {
2945                            build_client(&http_config, Some((host.as_str(), addrs)))
2946                        })
2947                        .await
2948                } else {
2949                    shared_client.clone()
2950                };
2951
2952                debug!(
2953                    correlation_id = %exchange.correlation_id(),
2954                    method = %method_str,
2955                    url = %redact_url_for_diagnostics(&url),
2956                    "HTTP request"
2957                );
2958
2959                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2960                    CamelError::ProcessorError(format!(
2961                        "Invalid HTTP method '{}': {}",
2962                        method_str, e
2963                    ))
2964                })?;
2965
2966                // Collect headers for potential redirect replay
2967                let mut collected_headers: Vec<(
2968                    reqwest::header::HeaderName,
2969                    reqwest::header::HeaderValue,
2970                )> = Vec::new();
2971
2972                if let Some(user_agent) = &config.user_agent
2973                    && !config.bridge_endpoint
2974                    && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2975                {
2976                    collected_headers.push((reqwest::header::USER_AGENT, val));
2977                }
2978
2979                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
2980                #[cfg(feature = "otel")]
2981                let should_inject_otel = !config.bridge_endpoint;
2982                #[cfg(feature = "otel")]
2983                if should_inject_otel {
2984                    let mut otel_headers = HashMap::new();
2985                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2986                    for (k, v) in otel_headers {
2987                        if let (Ok(name), Ok(val)) = (
2988                            reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2989                            reqwest::header::HeaderValue::from_str(&v),
2990                        ) {
2991                            collected_headers.push((name, val));
2992                        }
2993                    }
2994                }
2995
2996                let conn_tokens = header_policy::connection_tokens(
2997                    exchange
2998                        .input
2999                        .headers
3000                        .iter()
3001                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3002                        .filter_map(|(_, v)| v.as_str()),
3003                );
3004
3005                let outbound = select_outbound_headers(
3006                    &exchange.input.headers,
3007                    &config.skip_request_headers,
3008                    &conn_tokens,
3009                );
3010                for drop in &outbound.drops {
3011                    if let Some(value_kind) = drop.value_kind {
3012                        debug!(
3013                            correlation_id = %exchange.correlation_id(),
3014                            header = %drop.name,
3015                            value_kind = value_kind,
3016                            "outbound header dropped: {}",
3017                            drop.reason
3018                        );
3019                    } else {
3020                        debug!(
3021                            correlation_id = %exchange.correlation_id(),
3022                            header = %drop.name,
3023                            "outbound header dropped: {}",
3024                            drop.reason
3025                        );
3026                    }
3027                }
3028                collected_headers.extend(outbound.accepted);
3029
3030                // Auth headers
3031                if !config.bridge_endpoint {
3032                    match &config.auth {
3033                        HttpAuth::None => {}
3034                        HttpAuth::Basic { username, password } => {
3035                            use base64::Engine;
3036                            // allow-secret: credentials combined for base64 Basic auth header
3037                            let credentials = format!("{username}:{password}");
3038                            let encoded =
3039                                base64::engine::general_purpose::STANDARD.encode(credentials);
3040                            if let Ok(val) =
3041                                reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
3042                            {
3043                                collected_headers.push((reqwest::header::AUTHORIZATION, val));
3044                            }
3045                        }
3046                        HttpAuth::Bearer { token } => {
3047                            // allow-secret: Bearer token in Authorization header
3048                            let bearer = format!("Bearer {token}");
3049                            if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
3050                                collected_headers.push((reqwest::header::AUTHORIZATION, val));
3051                            }
3052                        }
3053                    }
3054
3055                    if config.connection_close
3056                        && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
3057                    {
3058                        collected_headers.push((reqwest::header::CONNECTION, val));
3059                    }
3060                }
3061
3062                // Materialize body
3063                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3064                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3065                    if suppress_body {
3066                        // A stream body dropped under a non-entity-enclosing
3067                        // method always warns (its emptiness is unknowable) and
3068                        // stays consumed (mem::take). The stream attach arm below
3069                        // still runs its outer flag check, but the inner `if let
3070                        // Body::Stream` re-match fails on the now-Empty body, so
3071                        // no stream is attached and no AlreadyConsumed error can
3072                        // fire.
3073                        std::mem::take(&mut exchange.input.body);
3074                        // log-policy: handler-owned
3075                        tracing::warn!(
3076                            correlation_id = %exchange.correlation_id(),
3077                            method = %method_str,
3078                            "dropping request body for non-entity-enclosing HTTP method"
3079                        );
3080                    }
3081                    None // Streams can't be replayed on redirect
3082                } else {
3083                    let body = std::mem::take(&mut exchange.input.body);
3084                    let bytes = body.into_bytes(config.max_body_size).await?;
3085                    if bytes.is_empty() {
3086                        // Empty body: nothing to send and nothing to warn about.
3087                        None
3088                    } else if suppress_body {
3089                        // log-policy: handler-owned
3090                        tracing::warn!(
3091                            correlation_id = %exchange.correlation_id(),
3092                            method = %method_str,
3093                            "dropping request body for non-entity-enclosing HTTP method"
3094                        );
3095                        None
3096                    } else {
3097                        Some(bytes.to_vec())
3098                    }
3099                };
3100
3101                let response = if config.follow_redirects && !is_stream_body {
3102                    // Use manual redirect loop with per-hop SSRF validation.
3103                    // `client` is the pinned-or-shared binding for the initial
3104                    // request (a hostname initial request keeps its DNS-pinned
3105                    // client); `shared_client` is the unpinned endpoint client
3106                    // reused by IP-literal redirect hops.
3107                    ssrf::send_with_ssrf_safe_redirects(
3108                        &client,
3109                        &shared_client,
3110                        &pinned_cache,
3111                        &http_config,
3112                        &config,
3113                        method,
3114                        &url,
3115                        collected_headers,
3116                        materialized_body,
3117                        config.max_redirects,
3118                        config.response_timeout,
3119                    )
3120                    .await?
3121                } else {
3122                    // Direct send (no redirect following, or streaming body)
3123                    let mut request = client.request(method, &url);
3124
3125                    if let Some(timeout) = config.response_timeout {
3126                        request = request.timeout(timeout);
3127                    }
3128
3129                    for (name, value) in &collected_headers {
3130                        request = request.header(name, value);
3131                    }
3132
3133                    if is_stream_body {
3134                        if let Body::Stream(ref s) = exchange.input.body {
3135                            let mut stream_lock = s.stream.lock().await;
3136                            if let Some(stream) = stream_lock.take() {
3137                                request = request.body(reqwest::Body::wrap_stream(stream));
3138                            } else {
3139                                return Err(CamelError::AlreadyConsumed);
3140                            }
3141                        }
3142                    } else if let Some(ref body_bytes) = materialized_body {
3143                        request = request.body(body_bytes.clone());
3144                    }
3145
3146                    request.send().await.map_err(|e| {
3147                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3148                    })?
3149                };
3150
3151                let status_code = response.status().as_u16();
3152                let status_text = response
3153                    .status()
3154                    .canonical_reason()
3155                    .unwrap_or("Unknown")
3156                    .to_string();
3157
3158                for (key, value) in response.headers() {
3159                    if config
3160                        .skip_response_headers
3161                        .iter()
3162                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3163                    {
3164                        continue;
3165                    }
3166                    if let Ok(val_str) = value.to_str() {
3167                        exchange.input.set_header(
3168                            title_case_header(key.as_str()),
3169                            serde_json::Value::String(val_str.to_string()),
3170                        );
3171                    }
3172                }
3173
3174                exchange.input.set_header(
3175                    "CamelHttpResponseCode",
3176                    serde_json::Value::Number(status_code.into()),
3177                );
3178                exchange.input.set_header(
3179                    "CamelHttpResponseText",
3180                    serde_json::Value::String(status_text.clone()),
3181                );
3182
3183                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
3184                let read_timeout = Duration::from_millis(config.read_timeout_ms);
3185                let response_body = tokio::time::timeout(read_timeout, async {
3186                    // Check Content-Length header before allocating
3187                    if let Some(content_len) = response.content_length()
3188                        && content_len > config.max_response_bytes as u64
3189                    {
3190                        return Err(CamelError::ProcessorError(format!(
3191                            "Response body too large: {} bytes exceeds limit of {} bytes",
3192                            content_len, config.max_response_bytes
3193                        )));
3194                    }
3195                    // Use bytes_stream() for lazy streaming with size guard
3196                    use futures::TryStreamExt;
3197                    let mut stream = response.bytes_stream();
3198                    let mut total: usize = 0;
3199                    let mut collected = Vec::new();
3200                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
3201                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3202                    })? {
3203                        total += chunk.len();
3204                        if total > config.max_response_bytes {
3205                            return Err(CamelError::ProcessorError(format!(
3206                                "Response body too large: {} bytes exceeds limit of {} bytes",
3207                                total, config.max_response_bytes
3208                            )));
3209                        }
3210                        collected.push(chunk);
3211                    }
3212                    let mut result = bytes::BytesMut::with_capacity(total);
3213                    for chunk in collected {
3214                        result.extend_from_slice(&chunk);
3215                    }
3216                    Ok::<bytes::Bytes, CamelError>(result.freeze())
3217                })
3218                .await
3219                .map_err(|_| {
3220                    CamelError::ProcessorError(format!(
3221                        "Read timeout after {}ms",
3222                        config.read_timeout_ms
3223                    ))
3224                })??;
3225
3226                if config.throw_exception_on_failure
3227                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3228                {
3229                    return Err(CamelError::HttpOperationFailed {
3230                        method: method_str,
3231                        // ADR-0051 redact-by-construction: never embed
3232                        // userinfo/query credentials in the error value.
3233                        url: redact_url_for_diagnostics(&url),
3234                        status_code,
3235                        status_text,
3236                        response_body: Some(truncate_error_body(&response_body)),
3237                    });
3238                }
3239
3240                if !response_body.is_empty() {
3241                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3242                }
3243
3244                debug!(
3245                    correlation_id = %exchange.correlation_id(),
3246                    status = status_code,
3247                    url = %redact_url_for_diagnostics(&url),
3248                    "HTTP response"
3249                );
3250                Ok(exchange)
3251            }
3252            .await;
3253            // ("http","request") facade (dashboard-observability 4.3): the
3254            // request boundary is the full client round-trip — SSRF checks,
3255            // send, response read, and (with throwExceptionOnFailure) the
3256            // status gate. http runs no retry_async and the producer
3257            // previously emitted nothing, so no label collides with
3258            // e:http:request.
3259            component_metrics.observe("http", "request", outcome.is_err());
3260            outcome
3261        })
3262    }
3263}
3264
3265/// Serializes tests that mutate or depend on the global `ServerRegistry`.
3266///
3267/// `ServerRegistry::global()` is a process-wide singleton that persists
3268/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
3269/// with another test that has a live server on a fixed port (e.g. 9991),
3270/// the registry entry is removed while the OS socket is still bound, so
3271/// the next `get_or_spawn` call on that port fails with "Address already
3272/// in use". Holding this mutex for the full body of each affected test
3273/// prevents the race without requiring `--test-threads=1`.
3274#[cfg(test)]
3275pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3276
3277/// Map a pipeline error to an HTTP reply.
3278///
3279/// Extracted from the inline `match` in `dispatch_handler` for unit
3280/// testability (rc-1dk4). `TypeConversionFailed` (e.g. malformed JSON
3281/// body) maps to `400 Bad Request` with a structured JSON error body;
3282/// `Unauthenticated`/`Unauthorized` keep their existing `401`/`403`
3283/// mappings; all other errors map to `500 Internal Server Error`.
3284fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3285    match e {
3286        CamelError::Unauthenticated(msg) => {
3287            tracing::warn!(error = %msg, path = %path, "Authentication failed");
3288            HttpReply {
3289                status: 401,
3290                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3291                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3292            }
3293        }
3294        CamelError::Unauthorized(msg) => {
3295            tracing::warn!(error = %msg, path = %path, "Authorization failed");
3296            HttpReply {
3297                status: 403,
3298                headers: vec![],
3299                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3300            }
3301        }
3302        CamelError::TypeConversionFailed(msg) => {
3303            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3304            let body = serde_json::to_string(&serde_json::json!({
3305                "error": "bad_request",
3306                "message": msg,
3307            }))
3308            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3309            HttpReply {
3310                status: 400,
3311                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3312                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3313            }
3314        }
3315        CamelError::ValidationError(msg) => {
3316            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3317            let body = serde_json::to_string(&serde_json::json!({
3318                "error": "validation_error",
3319                "message": msg,
3320            }))
3321            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3322            HttpReply {
3323                status: 400,
3324                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3325                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3326            }
3327        }
3328        CamelError::ConsumerStopping => {
3329            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3330            HttpReply {
3331                status: 503,
3332                headers: vec![],
3333                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3334            }
3335        }
3336        e => {
3337            // log-policy: handler-owned
3338            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3339            HttpReply {
3340                status: 500,
3341                headers: vec![],
3342                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3343            }
3344        }
3345    }
3346}
3347
3348/// Lowercase kind name for a JSON value, used in drop diagnostics so log
3349/// readers see *why* a header had no scalar string form without the value
3350/// itself ever entering diagnostics.
3351const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3352    match v {
3353        serde_json::Value::Null => "null",
3354        serde_json::Value::Bool(_) => "bool",
3355        serde_json::Value::Number(_) => "number",
3356        serde_json::Value::String(_) => "string",
3357        serde_json::Value::Array(_) => "array",
3358        serde_json::Value::Object(_) => "object",
3359    }
3360}
3361
3362/// Scalar string form of a JSON value: strings pass through, `Number` and
3363/// `Bool` are stringified, everything else has no single-value form.
3364/// Shared by the consumer reply finaliser and the producer outbound filter
3365/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
3366fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3367    match v {
3368        serde_json::Value::String(s) => Some(s.clone()),
3369        serde_json::Value::Number(n) => Some(n.to_string()),
3370        serde_json::Value::Bool(b) => Some(b.to_string()),
3371        _ => None,
3372    }
3373}
3374
3375/// Select the HTTP response headers emitted by the consumer reply finaliser
3376/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
3377/// `dispatch_handler` for unit testability.
3378///
3379/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
3380/// server-owned headers, plus `content-length`/`content-type` (re-derived),
3381/// and any header named by a `Connection` token. Scalar non-string values
3382/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3383/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
3384/// and arrays have no single-value form and are dropped. Every drop is
3385/// logged at DEBUG with the header name and reason — names only, never
3386/// values, so credentials cannot leak into diagnostics (ADR-0051).
3387/// Appends a single `Content-Type` from `user_content_type` falling back to
3388/// `inferred_content_type` when either is present.
3389fn select_response_headers(
3390    headers: &HashMap<String, serde_json::Value>,
3391    user_content_type: Option<String>,
3392    inferred_content_type: Option<String>,
3393) -> Vec<(String, String)> {
3394    let conn_tokens = header_policy::connection_tokens(
3395        headers
3396            .iter()
3397            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3398            .filter_map(|(_, v)| v.as_str()),
3399    );
3400    let mut selected: Vec<(String, String)> = Vec::new();
3401    for (k, v) in headers {
3402        if k.starts_with("Camel") {
3403            debug!(header = %k, "reply header dropped: Camel namespace");
3404            continue;
3405        }
3406        if header_policy::excluded_response(k, &conn_tokens) {
3407            debug!(header = %k, "reply header dropped: emission policy");
3408            continue;
3409        }
3410        match scalar_string_form(v) {
3411            Some(s) => selected.push((k.clone(), s)),
3412            None => debug!(
3413                header = %k,
3414                value_kind = json_value_kind(v),
3415                "reply header dropped: no scalar string form"
3416            ),
3417        }
3418    }
3419    if let Some(ct) = user_content_type.or(inferred_content_type) {
3420        selected.push(("Content-Type".to_string(), ct));
3421    }
3422    selected
3423}
3424
3425/// One outbound header drop: the exchange header name, a stable reason
3426/// string, and — when the drop was caused by the value having no scalar
3427/// string form — the JSON value kind. Names and kinds only, never values
3428/// (ADR-0051).
3429#[derive(Debug)]
3430struct OutboundHeaderDrop<'a> {
3431    name: &'a str,
3432    reason: &'static str,
3433    value_kind: Option<&'static str>,
3434}
3435
3436/// Outbound exchange-header selection result: headers accepted for the
3437/// wire plus drop records for call-site DEBUG logging.
3438struct OutboundHeaderSelection<'a> {
3439    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3440    drops: Vec<OutboundHeaderDrop<'a>>,
3441}
3442
3443/// Select the exchange headers the HTTP producer forwards on the outbound
3444/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
3445/// `HttpProducer::call` for unit testability.
3446///
3447/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
3448/// hop-by-hop/framing and connection-token-named headers excluded by the
3449/// outbound emission policy, and headers whose name or stringified value
3450/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
3451/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3452/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
3453/// and arrays have no single-value form and are dropped. Drops are returned
3454/// rather than logged so the call site can attach the correlation id; log
3455/// consumers see names and kinds only, never values (ADR-0051).
3456fn select_outbound_headers<'a>(
3457    headers: &'a HashMap<String, serde_json::Value>,
3458    skip_request_headers: &[String],
3459    conn_tokens: &[String],
3460) -> OutboundHeaderSelection<'a> {
3461    let mut accepted = Vec::new();
3462    let mut drops = Vec::new();
3463    for (key, value) in headers {
3464        if key.starts_with("Camel") {
3465            drops.push(OutboundHeaderDrop {
3466                name: key,
3467                reason: "Camel namespace",
3468                value_kind: None,
3469            });
3470            continue;
3471        }
3472        if skip_request_headers
3473            .iter()
3474            .any(|h| h.eq_ignore_ascii_case(key))
3475        {
3476            drops.push(OutboundHeaderDrop {
3477                name: key,
3478                reason: "skip_request_headers",
3479                value_kind: None,
3480            });
3481            continue;
3482        }
3483        if header_policy::excluded_outbound(key, conn_tokens) {
3484            drops.push(OutboundHeaderDrop {
3485                name: key,
3486                reason: "outbound emission policy",
3487                value_kind: None,
3488            });
3489            continue;
3490        }
3491        let Some(val_str) = scalar_string_form(value) else {
3492            drops.push(OutboundHeaderDrop {
3493                name: key,
3494                reason: "no scalar string form",
3495                value_kind: Some(json_value_kind(value)),
3496            });
3497            continue;
3498        };
3499        let name = match reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
3500            Ok(name) => name,
3501            Err(_) => {
3502                drops.push(OutboundHeaderDrop {
3503                    name: key,
3504                    reason: "invalid header name",
3505                    value_kind: None,
3506                });
3507                continue;
3508            }
3509        };
3510        match reqwest::header::HeaderValue::from_str(&val_str) {
3511            Ok(val) => accepted.push((name, val)),
3512            Err(_) => drops.push(OutboundHeaderDrop {
3513                name: key,
3514                reason: "invalid header value",
3515                value_kind: None,
3516            }),
3517        }
3518    }
3519    OutboundHeaderSelection { accepted, drops }
3520}
3521
3522#[cfg(test)]
3523mod tests {
3524    use camel_component_api::test_support::NoopRuntimeObservability;
3525
3526    // Producer/consumer tests drive the component-ops facade on every
3527    // call (dashboard-observability 4.3), so even non-observability tests
3528    // must supply a collector-returning runtime — Noop everywhere.
3529    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3530        std::sync::Arc::new(NoopRuntimeObservability)
3531    }
3532    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3533        std::sync::Arc::new(NoopRuntimeObservability)
3534    }
3535    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3536        std::sync::Arc::new(NoopRuntimeObservability)
3537    }
3538
3539    use super::*;
3540    use crate::rest_match::PathSegment;
3541    use camel_component_api::{Message, NoOpComponentContext};
3542    use std::sync::Arc;
3543    use std::time::Duration;
3544
3545    fn test_producer_ctx() -> ProducerContext {
3546        ProducerContext::new()
3547    }
3548
3549    // -----------------------------------------------------------------------
3550    // Security: credential redaction (audit 2026-08-31, finding F3-1)
3551    // -----------------------------------------------------------------------
3552
3553    #[test]
3554    fn redact_url_masks_userinfo_and_query() {
3555        let redacted =
3556            redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
3557        assert!(
3558            !redacted.contains("secretpass"),
3559            "password must be masked: {redacted}"
3560        );
3561        assert!(
3562            !redacted.contains("token=abc123"),
3563            "query must be masked: {redacted}"
3564        );
3565        assert!(
3566            !redacted.contains("user@"),
3567            "username must be masked: {redacted}"
3568        );
3569        assert!(
3570            redacted.contains("internal.example"),
3571            "host stays visible: {redacted}"
3572        );
3573        assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
3574    }
3575
3576    #[test]
3577    fn redact_url_keeps_clean_urls_visible() {
3578        let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
3579        assert_eq!(redacted, "https://api.example.com/v1/items");
3580    }
3581
3582    #[test]
3583    fn redact_url_truncates_unparseable() {
3584        let long = "x".repeat(1000);
3585        let redacted = redact_url_for_diagnostics(&long);
3586        assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
3587    }
3588
3589    #[test]
3590    fn truncate_error_body_caps_attacker_body() {
3591        let big = vec![b'A'; 10 * 1024 * 1024];
3592        let truncated = truncate_error_body(&big);
3593        assert!(
3594            truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
3595            "body must be capped near {} bytes, got {}",
3596            MAX_ERROR_RESPONSE_BODY_BYTES,
3597            truncated.len()
3598        );
3599        assert!(truncated.ends_with("...[truncated]"));
3600    }
3601
3602    #[test]
3603    fn truncate_error_body_keeps_small_body() {
3604        assert_eq!(truncate_error_body(b"boom"), "boom");
3605    }
3606
3607    #[test]
3608    fn test_http_config_defaults() {
3609        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
3610        assert_eq!(config.base_url, "http://localhost:8080/api");
3611        assert!(config.http_method.is_none());
3612        assert!(config.throw_exception_on_failure);
3613        assert_eq!(config.ok_status_code_range, (200, 299));
3614        assert!(config.response_timeout.is_none());
3615        assert!(matches!(config.auth, HttpAuth::None));
3616        assert!(!config.bridge_endpoint);
3617        assert!(!config.connection_close);
3618    }
3619
3620    #[test]
3621    fn test_http_config_scheme() {
3622        // UriConfig trait method returns "http" as primary scheme
3623        assert_eq!(HttpEndpointConfig::scheme(), "http");
3624    }
3625
3626    #[test]
3627    fn test_http_config_from_components() {
3628        // Test from_components directly (trait method)
3629        let components = camel_component_api::UriComponents {
3630            scheme: "https".to_string(),
3631            path: "//api.example.com/v1".to_string(),
3632            params: std::collections::HashMap::from([(
3633                "httpMethod".to_string(),
3634                "POST".to_string(),
3635            )]),
3636            raw_query: None,
3637        };
3638        let config = HttpEndpointConfig::from_components(components).unwrap();
3639        assert_eq!(config.base_url, "https://api.example.com/v1");
3640        assert_eq!(config.http_method, Some("POST".to_string()));
3641    }
3642
3643    #[test]
3644    fn test_http_config_with_options() {
3645        let config = HttpEndpointConfig::from_uri(
3646            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
3647        ).unwrap();
3648        assert_eq!(config.base_url, "https://api.example.com/v1");
3649        assert_eq!(config.http_method, Some("PUT".to_string()));
3650        assert!(!config.throw_exception_on_failure);
3651        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
3652    }
3653
3654    #[test]
3655    fn test_http_endpoint_config_auth_and_headers_options() {
3656        let config = HttpEndpointConfig::from_uri(
3657            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
3658        )
3659        .unwrap();
3660
3661        assert!(matches!(
3662            config.auth,
3663            HttpAuth::Basic { username, password } if username == "u" && password == "p"
3664        ));
3665        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
3666        assert!(config.bridge_endpoint);
3667        assert!(config.connection_close);
3668        assert_eq!(
3669            config.skip_request_headers,
3670            vec!["authorization".to_string(), "x-secret".to_string()]
3671        );
3672        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
3673    }
3674
3675    #[test]
3676    fn test_http_endpoint_config_bearer_auth() {
3677        let config = HttpEndpointConfig::from_uri(
3678            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
3679        )
3680        .unwrap();
3681        assert!(matches!(
3682            config.auth,
3683            HttpAuth::Bearer { token } if token == "t"
3684        ));
3685    }
3686
3687    #[test]
3688    fn rejects_cookie_handling_inmemory() {
3689        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
3690        match result {
3691            Err(CamelError::InvalidUri(msg)) => {
3692                assert!(
3693                    msg.contains("cookieHandling is not supported"),
3694                    "expected rejection message, got: {msg}"
3695                );
3696            }
3697            other => panic!("expected InvalidUri error, got: {other:?}"),
3698        }
3699    }
3700
3701    #[test]
3702    fn rejects_cookie_handling_disabled() {
3703        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
3704        match result {
3705            Err(CamelError::InvalidUri(msg)) => {
3706                assert!(
3707                    msg.contains("cookieHandling is not supported"),
3708                    "expected rejection message, got: {msg}"
3709                );
3710            }
3711            other => panic!("expected InvalidUri error, got: {other:?}"),
3712        }
3713    }
3714
3715    #[test]
3716    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
3717        let config = HttpConfig::default()
3718            .with_response_timeout_ms(999)
3719            .with_allow_internal(true)
3720            .with_blocked_hosts(vec!["evil.com".to_string()])
3721            .with_max_body_size(12345);
3722        let endpoint =
3723            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
3724        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
3725        assert!(endpoint.allow_internal);
3726        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
3727        assert_eq!(endpoint.max_body_size, 12345);
3728    }
3729
3730    #[test]
3731    fn test_from_uri_with_defaults_uri_overrides_config() {
3732        let config = HttpConfig::default()
3733            .with_response_timeout_ms(999)
3734            .with_allow_internal(true)
3735            .with_blocked_hosts(vec!["evil.com".to_string()])
3736            .with_max_body_size(12345);
3737        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
3738            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
3739            &config,
3740        )
3741        .unwrap();
3742        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
3743        assert!(!endpoint.allow_internal);
3744        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
3745        assert_eq!(endpoint.max_body_size, 99);
3746    }
3747
3748    #[test]
3749    fn test_http_config_ok_status_range() {
3750        let config =
3751            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
3752        assert_eq!(config.ok_status_code_range, (200, 204));
3753    }
3754
3755    #[test]
3756    fn test_http_config_wrong_scheme() {
3757        let result = HttpEndpointConfig::from_uri("file:/tmp");
3758        assert!(result.is_err());
3759    }
3760
3761    #[test]
3762    fn test_http_component_scheme() {
3763        let component = HttpComponent::new();
3764        assert_eq!(component.scheme(), "http");
3765    }
3766
3767    #[test]
3768    fn test_https_component_scheme() {
3769        let component = HttpsComponent::new();
3770        assert_eq!(component.scheme(), "https");
3771    }
3772
3773    #[test]
3774    fn test_http_endpoint_creates_consumer() {
3775        let component = HttpComponent::new();
3776        let ctx = NoOpComponentContext;
3777        let endpoint = component
3778            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
3779            .unwrap();
3780        assert!(endpoint.create_consumer(rt()).is_ok());
3781    }
3782
3783    #[test]
3784    fn test_https_endpoint_creates_consumer_errors_without_tls() {
3785        let component = HttpsComponent::new();
3786        let ctx = NoOpComponentContext;
3787        let endpoint = component
3788            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
3789            .unwrap();
3790        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
3791        assert!(endpoint.create_consumer(rt()).is_err());
3792    }
3793
3794    #[test]
3795    fn test_http_endpoint_creates_producer() {
3796        let ctx = test_producer_ctx();
3797        let component = HttpComponent::new();
3798        let endpoint_ctx = NoOpComponentContext;
3799        let endpoint = component
3800            .create_endpoint("http://localhost/api", &endpoint_ctx)
3801            .unwrap();
3802        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
3803    }
3804
3805    // -----------------------------------------------------------------------
3806    // Producer tests
3807    // -----------------------------------------------------------------------
3808
3809    #[tokio::test]
3810    async fn test_producer_with_token_provider() {
3811        use camel_auth::oauth2::TokenProvider;
3812        use tower::ServiceExt;
3813
3814        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
3815            Arc::new(std::sync::Mutex::new(None));
3816        let captured_clone = Arc::clone(&captured_auth);
3817
3818        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3819        let port = listener.local_addr().unwrap().port();
3820
3821        let _handle = tokio::spawn(async move {
3822            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3823            if let Ok((mut stream, _)) = listener.accept().await {
3824                let mut buf = vec![0u8; 8192];
3825                let n = stream.read(&mut buf).await.unwrap_or(0);
3826                let request = String::from_utf8_lossy(&buf[..n]).to_string();
3827                let auth = request
3828                    .lines()
3829                    .find(|l| l.to_lowercase().starts_with("authorization:"))
3830                    .map(|l| {
3831                        l.split(':')
3832                            .nth(1)
3833                            .map(|s| s.trim().to_string())
3834                            .unwrap_or_default()
3835                    });
3836                *captured_clone.lock().unwrap() = auth;
3837                let body = r#"{"echo":"ok"}"#;
3838                let resp = format!(
3839                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3840                    body.len(),
3841                    body
3842                );
3843                let _ = stream.write_all(resp.as_bytes()).await;
3844            }
3845        });
3846
3847        #[derive(Debug)]
3848        struct StaticProvider;
3849        #[async_trait::async_trait]
3850        impl TokenProvider for StaticProvider {
3851            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
3852                Ok("injected-token".into())
3853            }
3854        }
3855
3856        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
3857        let ctx = test_producer_ctx();
3858        let component = HttpComponent::new();
3859        let endpoint_ctx = NoOpComponentContext;
3860        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
3861        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3862
3863        let exchange = Exchange::new(Message::new("hello"));
3864
3865        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
3866        let mut layered = layer.layer(producer);
3867        let result = layered.ready().await.unwrap().call(exchange).await;
3868        assert!(result.is_ok(), "producer call failed: {:?}", result);
3869
3870        tokio::time::sleep(Duration::from_millis(100)).await;
3871        let auth = captured_auth.lock().unwrap().take();
3872        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
3873    }
3874
3875    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
3876        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3877        let addr = listener.local_addr().unwrap();
3878        let url = format!("http://127.0.0.1:{}", addr.port());
3879
3880        let handle = tokio::spawn(async move {
3881            loop {
3882                if let Ok((mut stream, _)) = listener.accept().await {
3883                    tokio::spawn(async move {
3884                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
3885                        let mut buf = vec![0u8; 4096];
3886                        let n = stream.read(&mut buf).await.unwrap_or(0);
3887                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
3888
3889                        let method = request.split_whitespace().next().unwrap_or("GET");
3890
3891                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
3892                        let response = format!(
3893                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
3894                            body.len(),
3895                            body
3896                        );
3897                        let _ = stream.write_all(response.as_bytes()).await;
3898                    });
3899                }
3900            }
3901        });
3902
3903        (url, handle)
3904    }
3905
3906    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
3907        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3908        let addr = listener.local_addr().unwrap();
3909        let url = format!("http://127.0.0.1:{}", addr.port());
3910
3911        let handle = tokio::spawn(async move {
3912            loop {
3913                if let Ok((mut stream, _)) = listener.accept().await {
3914                    let status = status;
3915                    tokio::spawn(async move {
3916                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
3917                        let mut buf = vec![0u8; 4096];
3918                        let _ = stream.read(&mut buf).await;
3919
3920                        let status_text = match status {
3921                            404 => "Not Found",
3922                            500 => "Internal Server Error",
3923                            _ => "Error",
3924                        };
3925                        let body = "error body";
3926                        let response = format!(
3927                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
3928                            status,
3929                            status_text,
3930                            body.len(),
3931                            body
3932                        );
3933                        let _ = stream.write_all(response.as_bytes()).await;
3934                    });
3935                }
3936            }
3937        });
3938
3939        (url, handle)
3940    }
3941
3942    async fn start_request_capturing_server() -> (
3943        String,
3944        Arc<std::sync::Mutex<Option<String>>>,
3945        tokio::task::JoinHandle<()>,
3946    ) {
3947        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3948        let port = listener.local_addr().unwrap().port();
3949        let url = format!("http://127.0.0.1:{port}");
3950        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
3951        let captured_clone = Arc::clone(&captured);
3952        let handle = tokio::spawn(async move {
3953            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3954            if let Ok((mut stream, _)) = listener.accept().await {
3955                let mut buf = vec![0u8; 16384];
3956                let n = stream.read(&mut buf).await.unwrap_or(0);
3957                let request = String::from_utf8_lossy(&buf[..n]).to_string();
3958                if request.contains("\r\n\r\n") {
3959                    *captured_clone.lock().unwrap() = Some(request);
3960                }
3961                let body = r#"{"echo":"ok"}"#;
3962                let resp = format!(
3963                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3964                    body.len(),
3965                    body
3966                );
3967                let _ = stream.write_all(resp.as_bytes()).await;
3968            }
3969        });
3970        (url, captured, handle)
3971    }
3972
3973    #[tokio::test]
3974    async fn test_http_producer_get_request() {
3975        use tower::ServiceExt;
3976
3977        let (url, _handle) = start_test_server().await;
3978        let ctx = test_producer_ctx();
3979
3980        let component = HttpComponent::new();
3981        let endpoint_ctx = NoOpComponentContext;
3982        let endpoint = component
3983            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3984            .unwrap();
3985        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3986
3987        let exchange = Exchange::new(Message::default());
3988        let result = producer.oneshot(exchange).await.unwrap();
3989
3990        let status = result
3991            .input
3992            .header("CamelHttpResponseCode")
3993            .and_then(|v| v.as_u64())
3994            .unwrap();
3995        assert_eq!(status, 200);
3996
3997        assert!(!result.input.body.is_empty());
3998    }
3999
4000    #[tokio::test]
4001    async fn producer_excludes_host_and_framing() {
4002        use tower::ServiceExt;
4003
4004        let (url, captured, _handle) = start_request_capturing_server().await;
4005        let ctx = test_producer_ctx();
4006        let component = HttpComponent::new();
4007        let endpoint_ctx = NoOpComponentContext;
4008        let endpoint = component
4009            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4010            .unwrap();
4011        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4012
4013        let mut exchange = Exchange::new(Message::default());
4014        exchange.input.set_header("Host", "localhost");
4015        exchange.input.set_header("Content-Length", "42");
4016        exchange.input.set_header("Connection", "keep-alive");
4017        exchange.input.set_header("Upgrade", "h2c");
4018
4019        let result = producer.oneshot(exchange).await;
4020        assert!(result.is_ok(), "producer call failed: {:?}", result);
4021
4022        tokio::time::sleep(Duration::from_millis(100)).await;
4023        let request = captured
4024            .lock()
4025            .unwrap()
4026            .take()
4027            .expect("no outbound request captured");
4028        let lower = request.to_ascii_lowercase();
4029        assert!(
4030            !lower.contains("\r\nhost: localhost"),
4031            "forwarded Host: localhost must be stripped\n{request}"
4032        );
4033        assert!(
4034            !lower.contains("content-length: 42"),
4035            "exchange Content-Length must not be copied\n{request}"
4036        );
4037        assert!(
4038            !lower.lines().any(|l| l.starts_with("connection:")),
4039            "Connection header must not be forwarded\n{request}"
4040        );
4041        assert!(
4042            !lower.lines().any(|l| l.starts_with("upgrade:")),
4043            "Upgrade header must not be forwarded\n{request}"
4044        );
4045        let host_header = lower
4046            .lines()
4047            .find(|l| l.starts_with("host:"))
4048            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
4049            .expect("outbound Host header must be set by reqwest");
4050        assert!(
4051            host_header.starts_with("127.0.0.1:"),
4052            "outbound Host '{host_header}' must match the capture-server address"
4053        );
4054    }
4055
4056    #[tokio::test]
4057    async fn producer_forwards_request_only_headers() {
4058        use tower::ServiceExt;
4059
4060        let (url, captured, _handle) = start_request_capturing_server().await;
4061        let ctx = test_producer_ctx();
4062        let component = HttpComponent::new();
4063        let endpoint_ctx = NoOpComponentContext;
4064        let endpoint = component
4065            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4066            .unwrap();
4067        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4068
4069        let mut exchange = Exchange::new(Message::default());
4070        exchange.input.set_header("Accept", "application/json");
4071        exchange.input.set_header("User-Agent", "myclient/1.0");
4072
4073        let result = producer.oneshot(exchange).await;
4074        assert!(result.is_ok(), "producer call failed: {:?}", result);
4075
4076        tokio::time::sleep(Duration::from_millis(100)).await;
4077        let request = captured
4078            .lock()
4079            .unwrap()
4080            .take()
4081            .expect("no outbound request captured");
4082        let lower = request.to_ascii_lowercase();
4083        assert!(
4084            lower.contains("accept: application/json"),
4085            "request-only Accept header must be forwarded\n{request}"
4086        );
4087        assert!(
4088            lower.contains("user-agent: myclient/1.0"),
4089            "request-only User-Agent header must be forwarded\n{request}"
4090        );
4091    }
4092
4093    #[tokio::test]
4094    async fn producer_honours_skip_request_headers() {
4095        use tower::ServiceExt;
4096
4097        let (url, captured, _handle) = start_request_capturing_server().await;
4098        let ctx = test_producer_ctx();
4099        let component = HttpComponent::new();
4100        let endpoint_ctx = NoOpComponentContext;
4101        let endpoint = component
4102            .create_endpoint(
4103                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
4104                &endpoint_ctx,
4105            )
4106            .unwrap();
4107        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4108
4109        let mut exchange = Exchange::new(Message::default());
4110        exchange.input.set_header("Authorization", "Bearer x");
4111
4112        let result = producer.oneshot(exchange).await;
4113        assert!(result.is_ok(), "producer call failed: {:?}", result);
4114
4115        tokio::time::sleep(Duration::from_millis(100)).await;
4116        let request = captured
4117            .lock()
4118            .unwrap()
4119            .take()
4120            .expect("no outbound request captured");
4121        assert!(
4122            !request.to_ascii_lowercase().contains("authorization"),
4123            "Authorization must be stripped by skipRequestHeaders\n{request}"
4124        );
4125    }
4126
4127    #[tokio::test]
4128    async fn producer_stringifies_scalar_header_values_on_wire() {
4129        use tower::ServiceExt;
4130
4131        let (url, captured, _handle) = start_request_capturing_server().await;
4132        let ctx = test_producer_ctx();
4133        let component = HttpComponent::new();
4134        let endpoint_ctx = NoOpComponentContext;
4135        let endpoint = component
4136            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4137            .unwrap();
4138        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4139
4140        let mut exchange = Exchange::new(Message::default());
4141        exchange.input.set_header("X-Retries", serde_json::json!(3));
4142        exchange
4143            .input
4144            .set_header("X-Enabled", serde_json::json!(true));
4145        exchange
4146            .input
4147            .set_header("X-Obj", serde_json::json!({"a": 1}));
4148
4149        let result = producer.oneshot(exchange).await;
4150        assert!(result.is_ok(), "producer call failed: {:?}", result);
4151
4152        tokio::time::sleep(Duration::from_millis(100)).await;
4153        let request = captured
4154            .lock()
4155            .unwrap()
4156            .take()
4157            .expect("no outbound request captured");
4158        let lower = request.to_ascii_lowercase();
4159        assert!(
4160            lower.contains("x-retries: 3"),
4161            "numeric header must reach the wire stringified\n{request}"
4162        );
4163        assert!(
4164            lower.contains("x-enabled: true"),
4165            "bool header must reach the wire stringified\n{request}"
4166        );
4167        assert!(
4168            !lower.contains("x-obj:"),
4169            "object header has no single-value form and must not reach the wire\n{request}"
4170        );
4171    }
4172
4173    #[tokio::test]
4174    async fn test_http_producer_post_with_body() {
4175        use tower::ServiceExt;
4176
4177        let (url, _handle) = start_test_server().await;
4178        let ctx = test_producer_ctx();
4179
4180        let component = HttpComponent::new();
4181        let endpoint_ctx = NoOpComponentContext;
4182        let endpoint = component
4183            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
4184            .unwrap();
4185        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4186
4187        let exchange = Exchange::new(Message::new("request body"));
4188        let result = producer.oneshot(exchange).await.unwrap();
4189
4190        let status = result
4191            .input
4192            .header("CamelHttpResponseCode")
4193            .and_then(|v| v.as_u64())
4194            .unwrap();
4195        assert_eq!(status, 200);
4196    }
4197
4198    #[tokio::test]
4199    async fn test_http_producer_method_from_header() {
4200        use tower::ServiceExt;
4201
4202        let (url, _handle) = start_test_server().await;
4203        let ctx = test_producer_ctx();
4204
4205        let component = HttpComponent::new();
4206        let endpoint_ctx = NoOpComponentContext;
4207        let endpoint = component
4208            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4209            .unwrap();
4210        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4211
4212        let mut exchange = Exchange::new(Message::default());
4213        exchange.input.set_header(
4214            "CamelHttpMethod",
4215            serde_json::Value::String("DELETE".to_string()),
4216        );
4217
4218        let result = producer.oneshot(exchange).await.unwrap();
4219        let status = result
4220            .input
4221            .header("CamelHttpResponseCode")
4222            .and_then(|v| v.as_u64())
4223            .unwrap();
4224        assert_eq!(status, 200);
4225    }
4226
4227    #[tokio::test]
4228    async fn test_http_producer_forced_method() {
4229        use tower::ServiceExt;
4230
4231        let (url, _handle) = start_test_server().await;
4232        let ctx = test_producer_ctx();
4233
4234        let component = HttpComponent::new();
4235        let endpoint_ctx = NoOpComponentContext;
4236        let endpoint = component
4237            .create_endpoint(
4238                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
4239                &endpoint_ctx,
4240            )
4241            .unwrap();
4242        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4243
4244        let exchange = Exchange::new(Message::default());
4245        let result = producer.oneshot(exchange).await.unwrap();
4246
4247        let status = result
4248            .input
4249            .header("CamelHttpResponseCode")
4250            .and_then(|v| v.as_u64())
4251            .unwrap();
4252        assert_eq!(status, 200);
4253    }
4254
4255    #[tokio::test]
4256    async fn test_http_producer_throw_exception_on_failure() {
4257        use tower::ServiceExt;
4258
4259        let (url, _handle) = start_status_server(404).await;
4260        let ctx = test_producer_ctx();
4261
4262        let component = HttpComponent::new();
4263        let endpoint_ctx = NoOpComponentContext;
4264        let endpoint = component
4265            .create_endpoint(
4266                &format!("{url}/not-found?allowInternal=true"),
4267                &endpoint_ctx,
4268            )
4269            .unwrap();
4270        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4271
4272        let exchange = Exchange::new(Message::default());
4273        let result = producer.oneshot(exchange).await;
4274        assert!(result.is_err());
4275
4276        match result.unwrap_err() {
4277            CamelError::HttpOperationFailed { status_code, .. } => {
4278                assert_eq!(status_code, 404);
4279            }
4280            e => panic!("Expected HttpOperationFailed, got: {e}"),
4281        }
4282    }
4283
4284    #[tokio::test]
4285    async fn test_http_producer_no_throw_on_failure() {
4286        use tower::ServiceExt;
4287
4288        let (url, _handle) = start_status_server(500).await;
4289        let ctx = test_producer_ctx();
4290
4291        let component = HttpComponent::new();
4292        let endpoint_ctx = NoOpComponentContext;
4293        let endpoint = component
4294            .create_endpoint(
4295                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
4296                &endpoint_ctx,
4297            )
4298            .unwrap();
4299        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4300
4301        let exchange = Exchange::new(Message::default());
4302        let result = producer.oneshot(exchange).await.unwrap();
4303
4304        let status = result
4305            .input
4306            .header("CamelHttpResponseCode")
4307            .and_then(|v| v.as_u64())
4308            .unwrap();
4309        assert_eq!(status, 500);
4310    }
4311
4312    #[tokio::test]
4313    async fn test_http_producer_uri_override() {
4314        use tower::ServiceExt;
4315
4316        let (url, _handle) = start_test_server().await;
4317        let ctx = test_producer_ctx();
4318
4319        let component = HttpComponent::new();
4320        let endpoint_ctx = NoOpComponentContext;
4321        let endpoint = component
4322            .create_endpoint(
4323                "http://localhost:1/does-not-exist?allowInternal=true",
4324                &endpoint_ctx,
4325            )
4326            .unwrap();
4327        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4328
4329        let mut exchange = Exchange::new(Message::default());
4330        exchange.input.set_header(
4331            "CamelHttpUri",
4332            serde_json::Value::String(format!("{url}/api")),
4333        );
4334
4335        let result = producer.oneshot(exchange).await.unwrap();
4336        let status = result
4337            .input
4338            .header("CamelHttpResponseCode")
4339            .and_then(|v| v.as_u64())
4340            .unwrap();
4341        assert_eq!(status, 200);
4342    }
4343
4344    #[tokio::test]
4345    async fn test_http_producer_response_headers_mapped() {
4346        use tower::ServiceExt;
4347
4348        let (url, _handle) = start_test_server().await;
4349        let ctx = test_producer_ctx();
4350
4351        let component = HttpComponent::new();
4352        let endpoint_ctx = NoOpComponentContext;
4353        let endpoint = component
4354            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4355            .unwrap();
4356        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4357
4358        let exchange = Exchange::new(Message::default());
4359        let result = producer.oneshot(exchange).await.unwrap();
4360
4361        assert!(
4362            result.input.header("Content-Type").is_some(),
4363            "Response should have Content-Type header"
4364        );
4365        assert!(result.input.header("CamelHttpResponseText").is_some());
4366    }
4367
4368    // -----------------------------------------------------------------------
4369    // Bug fix tests: Client configuration per-endpoint
4370    // -----------------------------------------------------------------------
4371
4372    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
4373        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4374        let addr = listener.local_addr().unwrap();
4375        let url = format!("http://127.0.0.1:{}", addr.port());
4376
4377        let handle = tokio::spawn(async move {
4378            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4379            loop {
4380                if let Ok((mut stream, _)) = listener.accept().await {
4381                    tokio::spawn(async move {
4382                        let mut buf = vec![0u8; 4096];
4383                        let n = stream.read(&mut buf).await.unwrap_or(0);
4384                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
4385
4386                        // Check if this is a request to /final
4387                        if request.contains("GET /final") {
4388                            let body = r#"{"status":"final"}"#;
4389                            let response = format!(
4390                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4391                                body.len(),
4392                                body
4393                            );
4394                            let _ = stream.write_all(response.as_bytes()).await;
4395                        } else {
4396                            // Redirect to /final
4397                            // Connection: close stops the client pooling the
4398                            // connection the server drops right after this
4399                            // response (pooled-race, rc-u3aw class).
4400                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
4401                            let _ = stream.write_all(response.as_bytes()).await;
4402                        }
4403                    });
4404                }
4405            }
4406        });
4407
4408        (url, handle)
4409    }
4410
4411    struct CapturedRequest {
4412        method: String,
4413        path: String,
4414        body: Vec<u8>,
4415        content_length: Option<String>,
4416        transfer_encoding: Option<String>,
4417    }
4418
4419    /// Parse a request head plus its Content-Length-driven body from a freshly
4420    /// accepted connection. Returns `None` if the client closes before sending
4421    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
4422    /// keep-alive connections and never sends FIN) and does NOT rely on a
4423    /// single fixed-size read (a segmented small body would flake).
4424    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
4425        use tokio::io::AsyncReadExt;
4426
4427        // Read the request head (up to and including the terminating CRLF CRLF).
4428        let mut buf: Vec<u8> = Vec::new();
4429        let mut chunk = [0u8; 4096];
4430        let head_end: usize;
4431        loop {
4432            let n = stream.read(&mut chunk).await.unwrap_or(0);
4433            if n == 0 {
4434                return None;
4435            }
4436            buf.extend_from_slice(&chunk[..n]);
4437            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
4438                head_end = pos + 4;
4439                break;
4440            }
4441        }
4442
4443        // Parse the request head.
4444        let head = String::from_utf8_lossy(&buf[..head_end]);
4445        let mut lines = head.split("\r\n");
4446        let request_line = lines.next().unwrap_or("");
4447        let mut parts = request_line.split_whitespace();
4448        let method = parts.next().unwrap_or("").to_string();
4449        let path = parts.next().unwrap_or("").to_string();
4450
4451        let mut content_length: Option<String> = None;
4452        let mut transfer_encoding: Option<String> = None;
4453        for line in lines {
4454            if let Some((name, value)) = line.split_once(':') {
4455                let name = name.trim().to_ascii_lowercase();
4456                let value = value.trim().to_string();
4457                if name == "content-length" {
4458                    content_length = Some(value);
4459                } else if name == "transfer-encoding" {
4460                    transfer_encoding = Some(value);
4461                }
4462            }
4463        }
4464
4465        // Content-Length-driven exact read. A missing header means a 0-length body.
4466        let body_len: usize = content_length
4467            .as_deref()
4468            .and_then(|v| v.parse::<usize>().ok())
4469            .unwrap_or(0);
4470
4471        let mut body: Vec<u8> = buf[head_end..].to_vec();
4472        while body.len() < body_len {
4473            let n = stream.read(&mut chunk).await.unwrap_or(0);
4474            if n == 0 {
4475                break;
4476            }
4477            body.extend_from_slice(&chunk[..n]);
4478        }
4479        body.truncate(body_len);
4480
4481        Some(CapturedRequest {
4482            method,
4483            path,
4484            body,
4485            content_length,
4486            transfer_encoding,
4487        })
4488    }
4489
4490    /// A raw-TCP capture server. Each connection parses the request head, then
4491    /// performs a Content-Length-driven exact read of the body (see
4492    /// [`capture_request`]). Each connection is dropped after the response so
4493    /// every hop opens a fresh connection.
4494    async fn start_capture_server() -> (
4495        String,
4496        tokio::task::JoinHandle<()>,
4497        Arc<Mutex<Vec<CapturedRequest>>>,
4498    ) {
4499        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4500        let addr = listener.local_addr().unwrap();
4501        let url = format!("http://127.0.0.1:{}", addr.port());
4502
4503        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4504        let captured_for_return = Arc::clone(&captured);
4505
4506        let handle = tokio::spawn(async move {
4507            use tokio::io::AsyncWriteExt;
4508            loop {
4509                if let Ok((mut stream, _)) = listener.accept().await {
4510                    let captured = Arc::clone(&captured);
4511                    tokio::spawn(async move {
4512                        let Some(req) = capture_request(&mut stream).await else {
4513                            return;
4514                        };
4515                        captured.lock().unwrap().push(req);
4516
4517                        // 200 OK with Content-Length: 0 and no body, then drop
4518                        // the stream so the client opens a fresh connection.
4519                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
4520                        let _ = stream.write_all(response.as_bytes()).await;
4521                    });
4522                }
4523            }
4524        });
4525
4526        (url, handle, captured_for_return)
4527    }
4528
4529    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
4530    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
4531    /// whose `/final` path answers `200 OK` with an empty body. Every hop
4532    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
4533    /// the connection after responding so each hop is a fresh connection.
4534    async fn start_redirect_capture_server() -> (
4535        String,
4536        tokio::task::JoinHandle<()>,
4537        Arc<Mutex<Vec<CapturedRequest>>>,
4538    ) {
4539        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4540        let addr = listener.local_addr().unwrap();
4541        let url = format!("http://127.0.0.1:{}", addr.port());
4542
4543        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4544        let captured_for_return = Arc::clone(&captured);
4545
4546        let handle = tokio::spawn(async move {
4547            use tokio::io::AsyncWriteExt;
4548            loop {
4549                if let Ok((mut stream, _)) = listener.accept().await {
4550                    let captured = Arc::clone(&captured);
4551                    tokio::spawn(async move {
4552                        let Some(req) = capture_request(&mut stream).await else {
4553                            return;
4554                        };
4555                        let path = req.path.clone();
4556                        captured.lock().unwrap().push(req);
4557
4558                        let (status_line, location) = match path.as_str() {
4559                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
4560                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
4561                            "/final" => ("HTTP/1.1 200 OK", None),
4562                            _ => ("HTTP/1.1 404 Not Found", None),
4563                        };
4564
4565                        let response = match location {
4566                            // Connection: close stops the client pooling the
4567                            // connection this handler drops right after the
4568                            // response (pooled-race, rc-u3aw class).
4569                            Some(loc) => format!(
4570                                "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
4571                            ),
4572                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
4573                        };
4574                        let _ = stream.write_all(response.as_bytes()).await;
4575                    });
4576                }
4577            }
4578        });
4579
4580        (url, handle, captured_for_return)
4581    }
4582
4583    #[tokio::test]
4584    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
4585        use tower::ServiceExt;
4586
4587        let (url, _handle, captured) = start_capture_server().await;
4588        let ctx = test_producer_ctx();
4589
4590        let component = HttpComponent::with_config(HttpConfig::default());
4591        let endpoint_ctx = NoOpComponentContext;
4592        let endpoint = component
4593            .create_endpoint(
4594                &format!("{url}?httpMethod=GET&allowInternal=true"),
4595                &endpoint_ctx,
4596            )
4597            .unwrap();
4598        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4599
4600        let mut exchange = Exchange::new(Message::default());
4601        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4602
4603        let result = producer.oneshot(exchange).await.unwrap();
4604
4605        let status = result
4606            .input
4607            .header("CamelHttpResponseCode")
4608            .and_then(|v| v.as_u64())
4609            .unwrap();
4610        assert_eq!(status, 200);
4611
4612        let captured = captured.lock().unwrap();
4613        assert_eq!(captured.len(), 1, "expected exactly one captured request");
4614        let req = &captured[0];
4615        assert_eq!(req.method, "GET");
4616        // `httpMethod`/`allowInternal` are URI options, not request-target
4617        // query params, so the origin-form target is just "/".
4618        assert_eq!(req.path, "/");
4619        assert!(req.body.is_empty(), "GET must not carry a body");
4620        assert!(
4621            req.content_length.is_none(),
4622            "suppressed request must not carry Content-Length"
4623        );
4624        assert!(
4625            req.transfer_encoding.is_none(),
4626            "suppressed request must not carry Transfer-Encoding"
4627        );
4628
4629        // The exchange body is consumed by the producer (std::mem::take).
4630        assert!(
4631            result.input.body.is_empty(),
4632            "exchange body must be consumed"
4633        );
4634    }
4635
4636    #[tokio::test]
4637    async fn test_head_with_body_suppressed_via_header() {
4638        use tower::ServiceExt;
4639
4640        let (url, _handle, captured) = start_capture_server().await;
4641        let ctx = test_producer_ctx();
4642
4643        let component = HttpComponent::with_config(HttpConfig::default());
4644        let endpoint_ctx = NoOpComponentContext;
4645        let endpoint = component
4646            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4647            .unwrap();
4648        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4649
4650        let mut exchange = Exchange::new(Message::default());
4651        exchange.input.set_header(
4652            "CamelHttpMethod",
4653            serde_json::Value::String("HEAD".to_string()),
4654        );
4655        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4656
4657        let result = producer.oneshot(exchange).await.unwrap();
4658        let status = result
4659            .input
4660            .header("CamelHttpResponseCode")
4661            .and_then(|v| v.as_u64())
4662            .unwrap();
4663        assert_eq!(status, 200);
4664
4665        let captured = captured.lock().unwrap();
4666        assert_eq!(captured.len(), 1);
4667        let req = &captured[0];
4668        assert_eq!(req.method, "HEAD");
4669        assert!(req.body.is_empty(), "HEAD must not carry a body");
4670    }
4671
4672    #[tokio::test]
4673    async fn test_delete_options_trace_with_body_suppressed() {
4674        use tower::ServiceExt;
4675
4676        let (url, _handle, captured) = start_capture_server().await;
4677        let ctx = test_producer_ctx();
4678        let component = HttpComponent::with_config(HttpConfig::default());
4679        let endpoint_ctx = NoOpComponentContext;
4680
4681        for method in ["DELETE", "OPTIONS", "TRACE"] {
4682            let endpoint = component
4683                .create_endpoint(
4684                    &format!("{url}?httpMethod={method}&allowInternal=true"),
4685                    &endpoint_ctx,
4686                )
4687                .unwrap();
4688            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4689
4690            let mut exchange = Exchange::new(Message::default());
4691            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4692
4693            let result = producer.oneshot(exchange).await.unwrap();
4694            let status = result
4695                .input
4696                .header("CamelHttpResponseCode")
4697                .and_then(|v| v.as_u64())
4698                .unwrap();
4699            assert_eq!(status, 200, "method {method} should succeed");
4700        }
4701
4702        let captured = captured.lock().unwrap();
4703        assert_eq!(captured.len(), 3, "expected three captured requests");
4704        for method in ["DELETE", "OPTIONS", "TRACE"] {
4705            let req = captured
4706                .iter()
4707                .find(|r| r.method == method)
4708                .unwrap_or_else(|| panic!("missing captured request for {method}"));
4709            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
4710        }
4711    }
4712
4713    #[tokio::test]
4714    async fn test_post_put_patch_with_body_still_sent() {
4715        use tower::ServiceExt;
4716
4717        let (url, _handle, captured) = start_capture_server().await;
4718        let ctx = test_producer_ctx();
4719        let component = HttpComponent::with_config(HttpConfig::default());
4720        let endpoint_ctx = NoOpComponentContext;
4721
4722        for method in ["POST", "PUT", "PATCH"] {
4723            let endpoint = component
4724                .create_endpoint(
4725                    &format!("{url}?httpMethod={method}&allowInternal=true"),
4726                    &endpoint_ctx,
4727                )
4728                .unwrap();
4729            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4730
4731            let payload = format!("body-for-{method}");
4732            let mut exchange = Exchange::new(Message::default());
4733            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
4734
4735            let result = producer.oneshot(exchange).await.unwrap();
4736            let status = result
4737                .input
4738                .header("CamelHttpResponseCode")
4739                .and_then(|v| v.as_u64())
4740                .unwrap();
4741            assert_eq!(status, 200, "method {method} should succeed");
4742        }
4743
4744        let captured = captured.lock().unwrap();
4745        assert_eq!(captured.len(), 3, "expected three captured requests");
4746        for method in ["POST", "PUT", "PATCH"] {
4747            let req = captured
4748                .iter()
4749                .find(|r| r.method == method)
4750                .unwrap_or_else(|| panic!("missing captured request for {method}"));
4751            let expected = format!("body-for-{method}");
4752            assert!(!req.body.is_empty(), "{method} must still carry its body");
4753            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
4754        }
4755    }
4756
4757    /// A GET with a stream body must not attach the stream: the entity-enclosing
4758    /// gate drops the stream (mem::take) before the request is built, leaving
4759    /// the exchange body Empty instead of a partially-consumed Body::Stream.
4760    #[tokio::test]
4761    async fn test_stream_body_under_get_not_attached() {
4762        use tower::ServiceExt;
4763
4764        let (url, _handle, captured) = start_capture_server().await;
4765        let ctx = test_producer_ctx();
4766
4767        let component = HttpComponent::with_config(HttpConfig::default());
4768        let endpoint_ctx = NoOpComponentContext;
4769        let endpoint = component
4770            .create_endpoint(
4771                &format!("{url}?httpMethod=GET&allowInternal=true"),
4772                &endpoint_ctx,
4773            )
4774            .unwrap();
4775        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4776
4777        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
4778            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
4779        let stream = Box::pin(futures::stream::iter(chunks));
4780        let mut exchange = Exchange::new(Message::default());
4781        exchange.input.body = Body::Stream(StreamBody {
4782            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
4783            metadata: StreamMetadata::default(),
4784        });
4785
4786        let result = producer.oneshot(exchange).await.unwrap();
4787
4788        let status = result
4789            .input
4790            .header("CamelHttpResponseCode")
4791            .and_then(|v| v.as_u64())
4792            .unwrap();
4793        assert_eq!(status, 200);
4794
4795        let captured = captured.lock().unwrap();
4796        assert_eq!(captured.len(), 1, "expected exactly one captured request");
4797        assert!(
4798            captured[0].body.is_empty(),
4799            "GET must not carry a stream body"
4800        );
4801        assert!(
4802            captured[0].transfer_encoding.is_none(),
4803            "suppressed request must not carry Transfer-Encoding"
4804        );
4805        assert!(
4806            captured[0].content_length.is_none(),
4807            "suppressed request must not carry Content-Length"
4808        );
4809        assert!(
4810            result.input.body.is_empty(),
4811            "exchange body must be consumed to Empty, not left as a stream"
4812        );
4813    }
4814
4815    /// A suppressed body must never be replayed across 307/308 redirect hops:
4816    /// the gate empties `materialized_body` before the redirect loop runs, so
4817    /// neither the first hop nor the final hop carries the body.
4818    #[tokio::test]
4819    async fn test_redirect_hops_never_replay_suppressed_body() {
4820        use tower::ServiceExt;
4821
4822        let (url, _handle, captured) = start_redirect_capture_server().await;
4823        let ctx = test_producer_ctx();
4824
4825        let component =
4826            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4827        let endpoint_ctx = NoOpComponentContext;
4828
4829        for path in ["/hop307", "/hop308"] {
4830            let endpoint = component
4831                .create_endpoint(
4832                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
4833                    &endpoint_ctx,
4834                )
4835                .unwrap();
4836            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4837
4838            let mut exchange = Exchange::new(Message::default());
4839            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4840
4841            let result = producer.oneshot(exchange).await.unwrap();
4842            let status = result
4843                .input
4844                .header("CamelHttpResponseCode")
4845                .and_then(|v| v.as_u64())
4846                .unwrap();
4847            assert_eq!(
4848                status, 200,
4849                "redirect chain for {path} should end at /final"
4850            );
4851        }
4852
4853        // Two chains (307 and 308), each with two hops (redirect + final).
4854        let captured = captured.lock().unwrap();
4855        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
4856        for req in captured.iter() {
4857            assert!(
4858                req.body.is_empty(),
4859                "hop {} {} must not carry a body",
4860                req.method,
4861                req.path
4862            );
4863        }
4864    }
4865
4866    /// The warn! emitted on a suppressed body renders three distinguishable
4867    /// substrings in the log line (tracing-subscriber default field format):
4868    ///   - the message:       "dropping request body ..."
4869    ///   - `method = %method_str`            → `method=GET`
4870    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
4871    /// The closure matches all three so exactly one warn per suppressed
4872    /// request is required (the "HTTP request" debug! also carries
4873    /// `method=GET` and the same `correlation_id=`, but not the message).
4874    #[tracing_test::traced_test]
4875    #[tokio::test]
4876    async fn test_suppressed_body_logs_exactly_one_warn() {
4877        use tower::ServiceExt;
4878
4879        let (url, _handle, _captured) = start_capture_server().await;
4880        let ctx = test_producer_ctx();
4881
4882        let component = HttpComponent::with_config(HttpConfig::default());
4883        let endpoint_ctx = NoOpComponentContext;
4884        let endpoint = component
4885            .create_endpoint(
4886                &format!("{url}?httpMethod=GET&allowInternal=true"),
4887                &endpoint_ctx,
4888            )
4889            .unwrap();
4890        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4891
4892        let mut exchange = Exchange::new(Message::default());
4893        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4894        let correlation_id = exchange.correlation_id().to_string();
4895
4896        let result = producer.oneshot(exchange).await.unwrap();
4897        let status = result
4898            .input
4899            .header("CamelHttpResponseCode")
4900            .and_then(|v| v.as_u64())
4901            .unwrap();
4902        assert_eq!(status, 200);
4903
4904        logs_assert(|lines: &[&str]| {
4905            let hits = lines
4906                .iter()
4907                .filter(|l| {
4908                    l.contains("dropping request body")
4909                        && l.contains("method=GET")
4910                        && l.contains(&format!("correlation_id={correlation_id}"))
4911                })
4912                .count();
4913            match hits {
4914                1 => Ok(()),
4915                n => Err(format!("expected exactly one body-drop warn, found {n}")),
4916            }
4917        });
4918    }
4919
4920    #[tracing_test::traced_test]
4921    #[tokio::test]
4922    async fn test_empty_body_get_emits_no_warn() {
4923        use tower::ServiceExt;
4924
4925        let (url, _handle, _captured) = start_capture_server().await;
4926        let ctx = test_producer_ctx();
4927
4928        let component = HttpComponent::with_config(HttpConfig::default());
4929        let endpoint_ctx = NoOpComponentContext;
4930        let endpoint = component
4931            .create_endpoint(
4932                &format!("{url}?httpMethod=GET&allowInternal=true"),
4933                &endpoint_ctx,
4934            )
4935            .unwrap();
4936        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4937
4938        let exchange = Exchange::new(Message::default());
4939        let result = producer.oneshot(exchange).await.unwrap();
4940        let status = result
4941            .input
4942            .header("CamelHttpResponseCode")
4943            .and_then(|v| v.as_u64())
4944            .unwrap();
4945        assert_eq!(status, 200);
4946
4947        logs_assert(|lines: &[&str]| {
4948            let hits = lines
4949                .iter()
4950                .filter(|l| l.contains("dropping request body"))
4951                .count();
4952            match hits {
4953                0 => Ok(()),
4954                n => Err(format!("expected no body-drop warn, found {n}")),
4955            }
4956        });
4957    }
4958
4959    #[tokio::test]
4960    async fn test_follow_redirects_false_does_not_follow() {
4961        use tower::ServiceExt;
4962
4963        let (url, _handle) = start_redirect_server().await;
4964        let ctx = test_producer_ctx();
4965
4966        let component =
4967            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
4968        let endpoint_ctx = NoOpComponentContext;
4969        let endpoint = component
4970            .create_endpoint(
4971                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
4972                &endpoint_ctx,
4973            )
4974            .unwrap();
4975        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4976
4977        let exchange = Exchange::new(Message::default());
4978        let result = producer.oneshot(exchange).await.unwrap();
4979
4980        // Should get 302, NOT follow redirect to 200
4981        let status = result
4982            .input
4983            .header("CamelHttpResponseCode")
4984            .and_then(|v| v.as_u64())
4985            .unwrap();
4986        assert_eq!(
4987            status, 302,
4988            "Should NOT follow redirect when followRedirects=false"
4989        );
4990    }
4991
4992    #[tokio::test]
4993    async fn test_follow_redirects_true_follows_redirect() {
4994        use tower::ServiceExt;
4995
4996        let (url, _handle) = start_redirect_server().await;
4997        let ctx = test_producer_ctx();
4998
4999        let component =
5000            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5001        let endpoint_ctx = NoOpComponentContext;
5002        let endpoint = component
5003            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5004            .unwrap();
5005        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5006
5007        let exchange = Exchange::new(Message::default());
5008        let result = producer.oneshot(exchange).await.unwrap();
5009
5010        // Should follow redirect and get 200
5011        let status = result
5012            .input
5013            .header("CamelHttpResponseCode")
5014            .and_then(|v| v.as_u64())
5015            .unwrap();
5016        assert_eq!(
5017            status, 200,
5018            "Should follow redirect when followRedirects=true"
5019        );
5020    }
5021
5022    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
5023    /// This verifies the manual redirect loop executes correctly.
5024    #[tokio::test]
5025    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
5026        use tower::ServiceExt;
5027
5028        // Use the existing redirect server which redirects to /final on the same server
5029        let (url, _handle) = start_redirect_server().await;
5030        let ctx = test_producer_ctx();
5031
5032        let component =
5033            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5034        let endpoint_ctx = NoOpComponentContext;
5035        let endpoint = component
5036            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5037            .unwrap();
5038        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5039
5040        let exchange = Exchange::new(Message::default());
5041        let result = producer.oneshot(exchange).await;
5042
5043        // With allowInternal=true, the redirect should succeed
5044        assert!(
5045            result.is_ok(),
5046            "Redirect should succeed with allowInternal=true, got: {:?}",
5047            result
5048        );
5049        let exchange = result.unwrap();
5050        let status = exchange
5051            .input
5052            .header("CamelHttpResponseCode")
5053            .and_then(|v| v.as_u64())
5054            .unwrap();
5055        assert_eq!(status, 200, "Should follow redirect to /final");
5056    }
5057
5058    /// With allowInternal=true, redirects to private IPs should be followed.
5059    #[tokio::test]
5060    async fn test_redirect_to_private_ip_allowed_when_configured() {
5061        use tower::ServiceExt;
5062
5063        // Start a server that redirects to /final on the same server (127.0.0.1)
5064        let (url, _handle) = start_redirect_server().await;
5065        let ctx = test_producer_ctx();
5066
5067        let component =
5068            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5069        let endpoint_ctx = NoOpComponentContext;
5070        let endpoint = component
5071            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5072            .unwrap();
5073        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5074
5075        let exchange = Exchange::new(Message::default());
5076        let result = producer.oneshot(exchange).await.unwrap();
5077
5078        let status = result
5079            .input
5080            .header("CamelHttpResponseCode")
5081            .and_then(|v| v.as_u64())
5082            .unwrap();
5083        assert_eq!(
5084            status, 200,
5085            "Should follow redirect to private IP when allowInternal=true"
5086        );
5087    }
5088
5089    /// Integration test: with allowInternal=false (default), a redirect to a
5090    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
5091    #[tokio::test]
5092    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
5093        use tower::ServiceExt;
5094
5095        // Server that redirects to the AWS metadata endpoint (link-local private IP)
5096        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5097        let addr = listener.local_addr().unwrap();
5098        let url = format!("http://127.0.0.1:{}", addr.port());
5099
5100        let handle = tokio::spawn(async move {
5101            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5102            loop {
5103                if let Ok((mut stream, _)) = listener.accept().await {
5104                    tokio::spawn(async move {
5105                        let mut buf = vec![0u8; 4096];
5106                        let _ = stream.read(&mut buf).await;
5107                        // Always redirect to the metadata endpoint
5108                        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";
5109                        let _ = stream.write_all(response.as_bytes()).await;
5110                    });
5111                }
5112            }
5113        });
5114
5115        let ctx = test_producer_ctx();
5116        let component =
5117            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5118        let endpoint_ctx = NoOpComponentContext;
5119        // allowInternal=false is the default — do NOT set it
5120        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
5121        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5122
5123        let exchange = Exchange::new(Message::default());
5124        let result = producer.oneshot(exchange).await;
5125
5126        // Must be an error — SSRF guard blocks the redirect target
5127        assert!(
5128            result.is_err(),
5129            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
5130        );
5131        let err = result.unwrap_err().to_string();
5132        assert!(
5133            err.contains("blocked IP")
5134                || err.contains("private IP")
5135                || err.contains("SSRF")
5136                || err.contains("not allowed"),
5137            "Error should mention SSRF/IP blocking, got: {err}"
5138        );
5139
5140        handle.abort();
5141    }
5142
5143    /// Integration test: exceeding maxRedirects produces a clear error.
5144    #[tokio::test]
5145    async fn test_too_many_redirects_returns_error() {
5146        use tower::ServiceExt;
5147
5148        // Server that always redirects to itself (infinite loop)
5149        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5150        let addr = listener.local_addr().unwrap();
5151        let url = format!("http://127.0.0.1:{}", addr.port());
5152
5153        let handle = tokio::spawn(async move {
5154            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5155            loop {
5156                if let Ok((mut stream, _)) = listener.accept().await {
5157                    tokio::spawn(async move {
5158                        let mut buf = vec![0u8; 4096];
5159                        let _ = stream.read(&mut buf).await;
5160                        // Always redirect to /loop
5161                        // Connection: close stops the client pooling the
5162                        // connection the server drops right after this
5163                        // response (pooled-race, rc-u3aw).
5164                        let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5165                        let _ = stream.write_all(response.as_bytes()).await;
5166                    });
5167                }
5168            }
5169        });
5170
5171        let ctx = test_producer_ctx();
5172        let component =
5173            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5174        let endpoint_ctx = NoOpComponentContext;
5175        let endpoint = component
5176            .create_endpoint(
5177                &format!("{url}?allowInternal=true&maxRedirects=2"),
5178                &endpoint_ctx,
5179            )
5180            .unwrap();
5181        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5182
5183        let exchange = Exchange::new(Message::default());
5184        let result = producer.oneshot(exchange).await;
5185
5186        // With the fix, exceeding max redirects returns the redirect response
5187        // as-is instead of erroring. The 302 redirect response is returned
5188        // after followRedirects exhausts the allowed redirect count (2).
5189        // Disable throwExceptionOnFailure to inspect the raw response status.
5190        //
5191        // Old behavior: Err("Too many redirects (max 2)")
5192        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
5193        match result {
5194            Err(e) => {
5195                // If throw_exception_on_failure is on, we get HttpOperationFailed
5196                let msg = e.to_string();
5197                assert!(
5198                    msg.contains("HTTP operation failed") || msg.contains("302"),
5199                    "expected redirect-after-exhaustion error, got: {msg}"
5200                );
5201            }
5202            Ok(ex) => {
5203                let response_code = ex
5204                    .input
5205                    .header("CamelHttpResponseCode")
5206                    .and_then(|v| v.as_u64());
5207                assert_eq!(
5208                    response_code,
5209                    Some(302),
5210                    "expected 302 after exhausting redirects"
5211                );
5212            }
5213        }
5214
5215        handle.abort();
5216    }
5217
5218    #[tokio::test]
5219    async fn test_query_params_forwarded_to_http_request() {
5220        use tower::ServiceExt;
5221
5222        let (url, _handle) = start_test_server().await;
5223        let ctx = test_producer_ctx();
5224
5225        let component = HttpComponent::new();
5226        let endpoint_ctx = NoOpComponentContext;
5227        // apiKey is NOT a Camel option, should be forwarded as query param
5228        let endpoint = component
5229            .create_endpoint(
5230                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
5231                &endpoint_ctx,
5232            )
5233            .unwrap();
5234        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5235
5236        let exchange = Exchange::new(Message::default());
5237        let result = producer.oneshot(exchange).await.unwrap();
5238
5239        // The test server returns the request info in response
5240        // We just verify it succeeds (the query param was sent)
5241        let status = result
5242            .input
5243            .header("CamelHttpResponseCode")
5244            .and_then(|v| v.as_u64())
5245            .unwrap();
5246        assert_eq!(status, 200);
5247    }
5248
5249    #[test]
5250    fn test_non_camel_query_params_are_forwarded() {
5251        // Authored pairs ride raw_query (the sole carrier); query_params is
5252        // programmatic-only (http-query-wire-fidelity).
5253        let config = HttpEndpointConfig::from_uri(
5254            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
5255        )
5256        .unwrap();
5257
5258        // apiKey and token are NOT camel-http options: the authored bytes
5259        // (including the interleaved httpMethod) ride raw_query verbatim.
5260        assert_eq!(
5261            config.raw_query.as_deref(),
5262            Some("apiKey=secret123&httpMethod=GET&token=abc456")
5263        );
5264        assert!(config.query_params.is_empty());
5265    }
5266
5267    #[test]
5268    fn test_authored_query_bytes_survive_resolve_url() {
5269        let config =
5270            HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
5271        let exchange = Exchange::new(Message::default());
5272
5273        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
5274
5275        // Authored bytes ride verbatim: `%20` stays `%20` (never re-encoded
5276        // to `+` or double-encoded) and `+` stays `+`.
5277        assert!(url.contains("q=hello%20world"), "url was: {url}");
5278        assert!(url.contains("tag=a+b"), "url was: {url}");
5279    }
5280
5281    // -----------------------------------------------------------------------
5282    // Timeout tests (HTTP-004)
5283    // -----------------------------------------------------------------------
5284
5285    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
5286        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5287        let addr = listener.local_addr().unwrap();
5288        let url = format!("http://127.0.0.1:{}", addr.port());
5289
5290        let handle = tokio::spawn(async move {
5291            loop {
5292                if let Ok((mut stream, _)) = listener.accept().await {
5293                    let delay = delay_ms;
5294                    tokio::spawn(async move {
5295                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5296                        let mut buf = vec![0u8; 4096];
5297                        let _ = stream.read(&mut buf).await;
5298                        // Send headers immediately (no Content-Length → chunked)
5299                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
5300                        let _ = stream.write_all(headers.as_bytes()).await;
5301                        // Delay before sending body chunk
5302                        tokio::time::sleep(Duration::from_millis(delay)).await;
5303                        let body = r#"{"status":"slow"}"#;
5304                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
5305                        let _ = stream.write_all(chunk.as_bytes()).await;
5306                    });
5307                }
5308            }
5309        });
5310
5311        (url, handle)
5312    }
5313
5314    #[tokio::test]
5315    async fn test_http_producer_timeout() {
5316        use tower::ServiceExt;
5317
5318        // Server delays 500ms, client timeout is 100ms → should timeout
5319        let (url, _handle) = start_slow_server(500).await;
5320        let ctx = test_producer_ctx();
5321
5322        let component = HttpComponent::with_config(
5323            HttpConfig::default()
5324                .with_read_timeout_ms(100)
5325                .with_response_timeout_ms(30_000), // generous response timeout
5326        );
5327        let endpoint_ctx = NoOpComponentContext;
5328        let endpoint = component
5329            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
5330            .unwrap();
5331        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5332
5333        let exchange = Exchange::new(Message::default());
5334        let result = producer.oneshot(exchange).await;
5335
5336        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
5337        let err = result.unwrap_err().to_string();
5338        assert!(
5339            err.contains("Read timeout") || err.contains("timeout"),
5340            "Error should mention timeout, got: {}",
5341            err
5342        );
5343    }
5344
5345    #[tokio::test]
5346    async fn test_http_producer_no_timeout_when_fast() {
5347        use tower::ServiceExt;
5348
5349        let (url, _handle) = start_test_server().await;
5350        let ctx = test_producer_ctx();
5351
5352        let component =
5353            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
5354        let endpoint_ctx = NoOpComponentContext;
5355        let endpoint = component
5356            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5357            .unwrap();
5358        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5359
5360        let exchange = Exchange::new(Message::default());
5361        let result = producer.oneshot(exchange).await.unwrap();
5362
5363        let status = result
5364            .input
5365            .header("CamelHttpResponseCode")
5366            .and_then(|v| v.as_u64())
5367            .unwrap();
5368        assert_eq!(status, 200);
5369    }
5370
5371    // -----------------------------------------------------------------------
5372    // SSRF Protection tests
5373    // -----------------------------------------------------------------------
5374
5375    #[tokio::test]
5376    async fn test_http_producer_blocks_metadata_endpoint() {
5377        use tower::ServiceExt;
5378
5379        let ctx = test_producer_ctx();
5380        let component = HttpComponent::new();
5381        let endpoint_ctx = NoOpComponentContext;
5382        let endpoint = component
5383            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
5384            .unwrap();
5385        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5386
5387        let mut exchange = Exchange::new(Message::default());
5388        exchange.input.set_header(
5389            "CamelHttpUri",
5390            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
5391        );
5392
5393        let result = producer.oneshot(exchange).await;
5394        assert!(result.is_err(), "Should block AWS metadata endpoint");
5395
5396        let err = result.unwrap_err();
5397        assert!(
5398            err.to_string().contains("Private IP"),
5399            "Error should mention private IP blocking, got: {}",
5400            err
5401        );
5402    }
5403
5404    #[test]
5405    fn test_ssrf_config_defaults() {
5406        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
5407        assert!(
5408            !config.allow_internal,
5409            "Private IPs should be blocked by default"
5410        );
5411        assert!(
5412            config.blocked_hosts.is_empty(),
5413            "Blocked hosts should be empty by default"
5414        );
5415    }
5416
5417    #[test]
5418    fn test_ssrf_config_allow_internal() {
5419        let config =
5420            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
5421        assert!(
5422            config.allow_internal,
5423            "Private IPs should be allowed when explicitly set"
5424        );
5425    }
5426
5427    #[test]
5428    fn test_ssrf_config_blocked_hosts() {
5429        let config = HttpEndpointConfig::from_uri(
5430            "http://example.com/api?blockedHosts=evil.com,malware.net",
5431        )
5432        .unwrap();
5433        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
5434    }
5435
5436    #[tokio::test]
5437    async fn test_http_producer_blocks_localhost() {
5438        use tower::ServiceExt;
5439
5440        let ctx = test_producer_ctx();
5441        let component = HttpComponent::new();
5442        let endpoint_ctx = NoOpComponentContext;
5443        let endpoint = component
5444            .create_endpoint("http://example.com/api", &endpoint_ctx)
5445            .unwrap();
5446        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5447
5448        let mut exchange = Exchange::new(Message::default());
5449        exchange.input.set_header(
5450            "CamelHttpUri",
5451            serde_json::Value::String("http://localhost:8080/internal".to_string()),
5452        );
5453
5454        let result = producer.oneshot(exchange).await;
5455        assert!(result.is_err(), "Should block localhost");
5456    }
5457
5458    #[tokio::test]
5459    async fn test_http_producer_blocks_loopback_ip() {
5460        use tower::ServiceExt;
5461
5462        let ctx = test_producer_ctx();
5463        let component = HttpComponent::new();
5464        let endpoint_ctx = NoOpComponentContext;
5465        let endpoint = component
5466            .create_endpoint("http://example.com/api", &endpoint_ctx)
5467            .unwrap();
5468        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5469
5470        let mut exchange = Exchange::new(Message::default());
5471        exchange.input.set_header(
5472            "CamelHttpUri",
5473            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
5474        );
5475
5476        let result = producer.oneshot(exchange).await;
5477        assert!(result.is_err(), "Should block loopback IP");
5478    }
5479
5480    #[tokio::test]
5481    async fn test_http_producer_allows_private_ip_when_enabled() {
5482        use tower::ServiceExt;
5483
5484        let ctx = test_producer_ctx();
5485        let component = HttpComponent::new();
5486        let endpoint_ctx = NoOpComponentContext;
5487        // With allowInternal=true, the validation should pass
5488        // (actual connection will fail, but that's expected)
5489        let endpoint = component
5490            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
5491            .unwrap();
5492        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5493
5494        let exchange = Exchange::new(Message::default());
5495
5496        // The request will fail because we can't connect, but it should NOT fail
5497        // due to SSRF protection
5498        let result = producer.oneshot(exchange).await;
5499        // We expect connection error, not SSRF error
5500        if let Err(ref e) = result {
5501            let err_str = e.to_string();
5502            assert!(
5503                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
5504                "Should not be SSRF error, got: {}",
5505                err_str
5506            );
5507        }
5508    }
5509
5510    // -----------------------------------------------------------------------
5511    // HttpServerConfig tests
5512    // -----------------------------------------------------------------------
5513
5514    #[test]
5515    fn test_http_server_config_parse() {
5516        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
5517        assert_eq!(cfg.host, "0.0.0.0");
5518        assert_eq!(cfg.port, 8080);
5519        assert_eq!(cfg.path, "/orders");
5520        assert_eq!(cfg.max_inflight_requests, 1024);
5521    }
5522
5523    #[test]
5524    fn test_http_server_config_scheme() {
5525        // UriConfig trait method returns "http" as primary scheme
5526        assert_eq!(HttpServerConfig::scheme(), "http");
5527    }
5528
5529    #[test]
5530    fn test_http_server_config_from_components() {
5531        // Test from_components directly (trait method)
5532        let components = camel_component_api::UriComponents {
5533            scheme: "https".to_string(),
5534            path: "//0.0.0.0:8443/api".to_string(),
5535            params: std::collections::HashMap::from([
5536                ("maxRequestBody".to_string(), "5242880".to_string()),
5537                ("maxInflightRequests".to_string(), "7".to_string()),
5538            ]),
5539            raw_query: None,
5540        };
5541        let cfg = HttpServerConfig::from_components(components).unwrap();
5542        assert_eq!(cfg.host, "0.0.0.0");
5543        assert_eq!(cfg.port, 8443);
5544        assert_eq!(cfg.path, "/api");
5545        assert_eq!(cfg.max_request_body, 5242880);
5546        assert_eq!(cfg.max_inflight_requests, 7);
5547    }
5548
5549    #[test]
5550    fn test_http_server_config_default_path() {
5551        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
5552        assert_eq!(cfg.path, "/");
5553    }
5554
5555    #[test]
5556    fn test_http_server_config_wrong_scheme() {
5557        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
5558    }
5559
5560    #[test]
5561    fn test_http_server_config_invalid_port() {
5562        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
5563    }
5564
5565    #[test]
5566    fn test_http_server_config_default_port_by_scheme() {
5567        // HTTP without explicit port should default to 80
5568        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
5569        assert_eq!(cfg_http.port, 80);
5570
5571        // HTTPS without explicit port should default to 443
5572        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
5573        assert_eq!(cfg_https.port, 443);
5574    }
5575
5576    #[test]
5577    fn test_request_envelope_and_reply_are_send() {
5578        fn assert_send<T: Send>() {}
5579        assert_send::<RequestEnvelope>();
5580        assert_send::<HttpReply>();
5581    }
5582
5583    // -----------------------------------------------------------------------
5584    // ServerRegistry tests
5585    // -----------------------------------------------------------------------
5586
5587    #[test]
5588    fn test_server_registry_global_is_singleton() {
5589        let r1 = ServerRegistry::global();
5590        let r2 = ServerRegistry::global();
5591        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
5592    }
5593
5594    #[allow(clippy::await_holding_lock)]
5595    #[tokio::test]
5596    async fn test_concurrent_get_or_spawn_returns_same_registry() {
5597        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5598        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5599        let port = listener.local_addr().unwrap().port();
5600        drop(listener);
5601
5602        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
5603            Arc::new(std::sync::Mutex::new(Vec::new()));
5604
5605        let mut handles = Vec::new();
5606        for _ in 0..4 {
5607            let results = results.clone();
5608            handles.push(tokio::spawn(async move {
5609                let registry = ServerRegistry::global()
5610                    .get_or_spawn(
5611                        "127.0.0.1",
5612                        port,
5613                        2 * 1024 * 1024,
5614                        10 * 1024 * 1024,
5615                        1024,
5616                        test_rt(),
5617                        "test-route".into(),
5618                        None,
5619                    )
5620                    .await
5621                    .unwrap();
5622                results.lock().unwrap().push(registry);
5623            }));
5624        }
5625
5626        for h in handles {
5627            h.await.unwrap();
5628        }
5629
5630        let registries = results.lock().unwrap();
5631        assert_eq!(registries.len(), 4);
5632        for i in 1..registries.len() {
5633            assert!(
5634                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
5635                "all concurrent callers should get same route registry"
5636            );
5637        }
5638    }
5639
5640    #[test]
5641    fn test_server_registry_distinguishes_host_and_port() {
5642        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5643        let rt = tokio::runtime::Runtime::new().expect("runtime");
5644        rt.block_on(async {
5645            let registry = ServerRegistry::global();
5646            // Use two distinct host values with same configured port key.
5647            // Port 0 is acceptable here because the registry key uses the configured
5648            // tuple, not the OS-assigned ephemeral port.
5649            let d1 = registry
5650                .get_or_spawn(
5651                    "127.0.0.1",
5652                    0,
5653                    1024 * 1024,
5654                    10 * 1024 * 1024,
5655                    1024,
5656                    test_rt(),
5657                    "test-route-1".into(),
5658                    None,
5659                )
5660                .await;
5661            let d2 = registry
5662                .get_or_spawn(
5663                    "0.0.0.0",
5664                    0,
5665                    1024 * 1024,
5666                    10 * 1024 * 1024,
5667                    1024,
5668                    test_rt(),
5669                    "test-route-2".into(),
5670                    None,
5671                )
5672                .await;
5673            assert!(d1.is_ok());
5674            assert!(d2.is_ok());
5675            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
5676        });
5677    }
5678
5679    #[allow(clippy::await_holding_lock)]
5680    #[tokio::test]
5681    async fn test_shared_server_max_request_body_policy_is_deterministic() {
5682        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5683        let registry = ServerRegistry::global();
5684        // First registration: maxRequestBody = 1 MB
5685        let d1 = registry
5686            .get_or_spawn(
5687                "127.0.0.1",
5688                9991,
5689                1024 * 1024,
5690                10 * 1024 * 1024,
5691                1024,
5692                test_rt(),
5693                "test-route".into(),
5694                None,
5695            )
5696            .await;
5697        assert!(d1.is_ok());
5698
5699        // Second registration on same (host,port): maxRequestBody = 2 MB
5700        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
5701        let d2 = registry
5702            .get_or_spawn(
5703                "127.0.0.1",
5704                9991,
5705                2 * 1024 * 1024,
5706                10 * 1024 * 1024,
5707                1024,
5708                test_rt(),
5709                "test-route-2".into(),
5710                None,
5711            )
5712            .await;
5713        assert!(d2.is_err());
5714        let err = d2.unwrap_err();
5715        assert!(
5716            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
5717            "Expected incompatible maxRequestBody error, got: {}",
5718            err
5719        );
5720    }
5721
5722    #[test]
5723    fn test_server_registry_reset_clears_entries() {
5724        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5725        let rt = tokio::runtime::Runtime::new().expect("runtime");
5726        rt.block_on(async {
5727            // Register something on a unique port
5728            let d1 = ServerRegistry::global()
5729                .get_or_spawn(
5730                    "127.0.0.1",
5731                    9992,
5732                    1024 * 1024,
5733                    10 * 1024 * 1024,
5734                    1024,
5735                    test_rt(),
5736                    "test-route".into(),
5737                    None,
5738                )
5739                .await;
5740            assert!(d1.is_ok());
5741
5742            // Verify entry exists
5743            let guard = ServerRegistry::global().inner.lock().expect("lock");
5744            assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
5745            drop(guard);
5746
5747            // Reset
5748            ServerRegistry::reset();
5749
5750            // Verify cleared
5751            let guard = ServerRegistry::global().inner.lock().expect("lock");
5752            assert!(
5753                guard.entries.is_empty(),
5754                "registry should be empty after reset, has {} entries",
5755                guard.entries.len()
5756            );
5757        });
5758    }
5759
5760    #[tokio::test]
5761    async fn registry_rejects_tls_on_plain_port() {
5762        ServerRegistry::reset();
5763        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
5764
5765        // First route: plain HTTP
5766        let _r1 = ServerRegistry::global()
5767            .get_or_spawn(
5768                "127.0.0.1",
5769                0,
5770                1024,
5771                1024,
5772                16,
5773                Arc::clone(&rt),
5774                "route-1".into(),
5775                None, // plain
5776            )
5777            .await;
5778
5779        // Second route: TLS on same port → must fail
5780        let result = ServerRegistry::global()
5781            .get_or_spawn(
5782                "127.0.0.1",
5783                0,
5784                1024,
5785                1024,
5786                16,
5787                Arc::clone(&rt),
5788                "route-2".into(),
5789                Some(crate::config::ServerTlsConfig {
5790                    cert_path: "/x.pem".into(),
5791                    key_path: "/y.pem".into(),
5792                }),
5793            )
5794            .await;
5795        assert!(result.is_err(), "must reject TLS on plain port");
5796    }
5797
5798    // -----------------------------------------------------------------------
5799    // D-L10: HTTP monitor_axum_task refcounted shutdown
5800    // -----------------------------------------------------------------------
5801
5802    #[allow(clippy::await_holding_lock)]
5803    #[tokio::test]
5804    async fn test_unregister_last_http_route_keeps_server_alive() {
5805        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5806        ServerRegistry::reset();
5807        let registry = ServerRegistry::global();
5808
5809        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5810        let port = listener.local_addr().unwrap().port();
5811        drop(listener); // Release — ServerRegistry will rebind
5812        let rt = test_rt();
5813
5814        // Register 2 routes on the same (host, port) — OnceCell returns the
5815        // same ServerHandle.
5816        let _r1 = registry
5817            .get_or_spawn(
5818                "127.0.0.1",
5819                port,
5820                1024 * 1024,
5821                10 * 1024 * 1024,
5822                16,
5823                rt.clone(),
5824                "test-route-1".into(),
5825                None,
5826            )
5827            .await
5828            .unwrap();
5829        let _r2 = registry
5830            .get_or_spawn(
5831                "127.0.0.1",
5832                port,
5833                1024 * 1024,
5834                10 * 1024 * 1024,
5835                16,
5836                rt,
5837                "test-route-2".into(),
5838                None,
5839            )
5840            .await
5841            .unwrap();
5842
5843        let key = ("127.0.0.1".to_string(), port);
5844        let cell = {
5845            let guard = registry.inner.lock().expect("lock");
5846            guard.entries.get(&key).expect("entry should exist").clone()
5847        };
5848
5849        // Unregister first route -> monitor still alive (count = 1).
5850        registry.unregister("127.0.0.1", port).await;
5851        {
5852            let handle = cell
5853                .get()
5854                .expect("handle should still exist after first unregister");
5855            assert!(
5856                !handle.monitor_task.is_finished(),
5857                "monitor task should still be alive after first unregister"
5858            );
5859        }
5860
5861        // Unregister second route -> server stays alive (process-lifetime).
5862        registry.unregister("127.0.0.1", port).await;
5863        tokio::time::sleep(Duration::from_millis(20)).await;
5864        {
5865            let handle = cell
5866                .get()
5867                .expect("handle should still exist after last unregister");
5868            assert!(
5869                !handle.monitor_task.is_finished(),
5870                "monitor task should still be alive — server is process-lifetime"
5871            );
5872        }
5873
5874        // Entry stays in registry for potential restart.
5875        {
5876            let guard = registry.inner.lock().expect("lock");
5877            assert!(
5878                guard.entries.contains_key(&key),
5879                "entry should remain in registry — server kept alive for restart"
5880            );
5881        }
5882    }
5883
5884    // -----------------------------------------------------------------------
5885    // Staged listeners (itest-bound-ports Task 1)
5886    // -----------------------------------------------------------------------
5887
5888    /// CLONE-FIXTURE: bind a std listener on `127.0.0.1:0`, retain a blocking
5889    /// std clone (`probe`) so the port stays reserved, and hand the original
5890    /// socket to tokio as a non-blocking listener. `tokio::net::TcpListener`
5891    /// has no `try_clone`, so clones come from the std handle.
5892    async fn clone_fixture_listener() -> (
5893        tokio::net::TcpListener,
5894        std::net::TcpListener,
5895        std::net::SocketAddr,
5896    ) {
5897        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
5898        let probe = l.try_clone().expect("clone probe");
5899        l.set_nonblocking(true).expect("set_nonblocking");
5900        let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
5901        let addr = listener.local_addr().expect("local_addr");
5902        (listener, probe, addr)
5903    }
5904
5905    /// Default-limit constants the existing registry tests in this file use.
5906    fn staged_limits() -> (usize, usize, usize) {
5907        (1024 * 1024, 10 * 1024 * 1024, 1024)
5908    }
5909
5910    #[allow(clippy::await_holding_lock)]
5911    #[tokio::test]
5912    async fn staged_listener_first_spawn_serves_without_second_bind() {
5913        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5914        ServerRegistry::reset();
5915        let registry = ServerRegistry::global();
5916        let (listener, _probe, addr) = clone_fixture_listener().await;
5917        let port = addr.port();
5918        registry
5919            .stage_listener(listener)
5920            .await
5921            .expect("stage listener");
5922
5923        let (max_req, max_res, max_inflight) = staged_limits();
5924        let routes = registry
5925            .get_or_spawn(
5926                "127.0.0.1",
5927                port,
5928                max_req,
5929                max_res,
5930                max_inflight,
5931                test_rt(),
5932                "staged-first-spawn".into(),
5933                None,
5934            )
5935            .await
5936            .expect("spawn from staged listener must succeed");
5937
5938        assert_eq!(
5939            registry.bound_addr("127.0.0.1", port),
5940            Some(addr),
5941            "served socket must be the staged listener's addr"
5942        );
5943        // The probe clone shares the socket, so service is proven by an HTTP
5944        // response, not by accepting on the probe.
5945        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
5946            .await
5947            .expect("http request against staged listener must connect");
5948        assert!(
5949            resp.status().as_u16() >= 200,
5950            "any status proves the staged socket serves"
5951        );
5952        drop(routes);
5953    }
5954
5955    #[allow(clippy::await_holding_lock)]
5956    #[tokio::test]
5957    async fn staged_entry_reused_by_second_caller() {
5958        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5959        ServerRegistry::reset();
5960        let registry = ServerRegistry::global();
5961        let (listener, _probe, addr) = clone_fixture_listener().await;
5962        let port = addr.port();
5963        registry
5964            .stage_listener(listener)
5965            .await
5966            .expect("stage listener");
5967
5968        let (max_req, max_res, max_inflight) = staged_limits();
5969        let first = registry
5970            .get_or_spawn(
5971                "127.0.0.1",
5972                port,
5973                max_req,
5974                max_res,
5975                max_inflight,
5976                test_rt(),
5977                "staged-reuse-1".into(),
5978                None,
5979            )
5980            .await
5981            .expect("first spawn from staged listener");
5982        let second = registry
5983            .get_or_spawn(
5984                "127.0.0.1",
5985                port,
5986                max_req,
5987                max_res,
5988                max_inflight,
5989                test_rt(),
5990                "staged-reuse-2".into(),
5991                None,
5992            )
5993            .await
5994            .expect("second caller must reuse the entry");
5995        assert_eq!(
5996            registry.bound_addr("127.0.0.1", port),
5997            Some(addr),
5998            "entry reused — bound addr unchanged, no second bind"
5999        );
6000        drop(first);
6001        drop(second);
6002    }
6003
6004    #[allow(clippy::await_holding_lock)]
6005    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6006    async fn staged_race_two_callers_single_resolver() {
6007        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6008        ServerRegistry::reset();
6009        let registry = ServerRegistry::global();
6010        let (listener, _probe, addr) = clone_fixture_listener().await;
6011        let port = addr.port();
6012        registry
6013            .stage_listener(listener)
6014            .await
6015            .expect("stage listener");
6016
6017        // Two racing callers for the exact staged key: the staged listener
6018        // must be consumed by the single cell-init winner and served to
6019        // both — never leave the winner binding a port the loser still
6020        // holds (EADDRINUSE).
6021        let (max_req, max_res, max_inflight) = staged_limits();
6022        let (first, second) = tokio::join!(
6023            registry.get_or_spawn(
6024                "127.0.0.1",
6025                port,
6026                max_req,
6027                max_res,
6028                max_inflight,
6029                test_rt(),
6030                "staged-race-1".into(),
6031                None,
6032            ),
6033            registry.get_or_spawn(
6034                "127.0.0.1",
6035                port,
6036                max_req,
6037                max_res,
6038                max_inflight,
6039                test_rt(),
6040                "staged-race-2".into(),
6041                None,
6042            ),
6043        );
6044        let first = first.expect("first racing caller must succeed");
6045        let second = second.expect("second racing caller must succeed");
6046        assert_eq!(
6047            registry.bound_addr("127.0.0.1", port),
6048            Some(addr),
6049            "single entry must be served from the staged socket — no EADDRINUSE path"
6050        );
6051        drop(first);
6052        drop(second);
6053    }
6054
6055    #[allow(clippy::await_holding_lock)]
6056    #[tokio::test]
6057    async fn unstaged_spawn_binds_legacy() {
6058        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6059        ServerRegistry::reset();
6060        let registry = ServerRegistry::global();
6061        // Fresh port P2: reserve then release — the legacy path rebinds.
6062        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
6063        let port = probe.local_addr().expect("local addr").port();
6064        drop(probe);
6065
6066        let (max_req, max_res, max_inflight) = staged_limits();
6067        registry
6068            .get_or_spawn(
6069                "127.0.0.1",
6070                port,
6071                max_req,
6072                max_res,
6073                max_inflight,
6074                test_rt(),
6075                "legacy-bind".into(),
6076                None,
6077            )
6078            .await
6079            .expect("legacy bind spawn");
6080        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
6081            .await
6082            .expect("connect to freshly bound port must succeed");
6083        assert!(resp.status().as_u16() >= 200);
6084        assert_eq!(
6085            registry.bound_addr("127.0.0.1", port),
6086            Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
6087            "bound addr must be the legacy bound (host, port)"
6088        );
6089    }
6090
6091    #[allow(clippy::await_holding_lock)]
6092    #[tokio::test]
6093    async fn wrong_host_staged_port_fails_deterministically() {
6094        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6095        ServerRegistry::reset();
6096        let registry = ServerRegistry::global();
6097        let (listener, _probe, addr) = clone_fixture_listener().await;
6098        let port = addr.port();
6099        registry
6100            .stage_listener(listener)
6101            .await
6102            .expect("stage listener under 127.0.0.1");
6103
6104        let (max_req, max_res, max_inflight) = staged_limits();
6105        let err = registry
6106            .get_or_spawn(
6107                "localhost",
6108                port,
6109                max_req,
6110                max_res,
6111                max_inflight,
6112                test_rt(),
6113                "conflict-probe".into(),
6114                None,
6115            )
6116            .await
6117            .expect_err("wrong host on staged port must fail deterministically");
6118        assert!(
6119            err.to_string().contains("staged listener conflict on port"),
6120            "unexpected error: {err}"
6121        );
6122
6123        // Slot untouched by the failed call: the correct host now consumes it.
6124        registry
6125            .get_or_spawn(
6126                "127.0.0.1",
6127                port,
6128                max_req,
6129                max_res,
6130                max_inflight,
6131                test_rt(),
6132                "conflict-after".into(),
6133                None,
6134            )
6135            .await
6136            .expect("correct host must serve the staged listener");
6137        assert_eq!(
6138            registry.bound_addr("127.0.0.1", port),
6139            Some(addr),
6140            "staged slot must be untouched by the conflicting call"
6141        );
6142    }
6143
6144    #[allow(clippy::await_holding_lock)]
6145    #[tokio::test]
6146    async fn duplicate_stage_same_key_rejected() {
6147        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6148        ServerRegistry::reset();
6149        let registry = ServerRegistry::global();
6150        let (listener, probe, addr) = clone_fixture_listener().await;
6151        registry
6152            .stage_listener(listener)
6153            .await
6154            .expect("stage listener A");
6155
6156        // Second tokio handle to the SAME socket: clone the std probe handle.
6157        let dup = probe.try_clone().expect("clone2");
6158        dup.set_nonblocking(true).expect("set_nonblocking2");
6159        let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
6160
6161        let err = registry
6162            .stage_listener(b)
6163            .await
6164            .expect_err("duplicate stage must be rejected");
6165        assert!(
6166            err.to_string().contains("listener already staged"),
6167            "unexpected error: {err}"
6168        );
6169
6170        let (max_req, max_res, max_inflight) = staged_limits();
6171        registry
6172            .get_or_spawn(
6173                "127.0.0.1",
6174                addr.port(),
6175                max_req,
6176                max_res,
6177                max_inflight,
6178                test_rt(),
6179                "dup-stage-after".into(),
6180                None,
6181            )
6182            .await
6183            .expect("spawn from first staged listener");
6184        assert_eq!(
6185            registry.bound_addr("127.0.0.1", addr.port()),
6186            Some(addr),
6187            "first staged listener retained"
6188        );
6189    }
6190
6191    #[allow(clippy::await_holding_lock)]
6192    #[tokio::test]
6193    async fn distinct_keys_stage_independently() {
6194        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6195        ServerRegistry::reset();
6196        let registry = ServerRegistry::global();
6197        let (l1, _p1, addr1) = clone_fixture_listener().await;
6198        let (l2, _p2, addr2) = clone_fixture_listener().await;
6199        registry.stage_listener(l1).await.expect("stage P1");
6200        registry.stage_listener(l2).await.expect("stage P2");
6201
6202        let (max_req, max_res, max_inflight) = staged_limits();
6203        registry
6204            .get_or_spawn(
6205                "127.0.0.1",
6206                addr1.port(),
6207                max_req,
6208                max_res,
6209                max_inflight,
6210                test_rt(),
6211                "distinct-1".into(),
6212                None,
6213            )
6214            .await
6215            .expect("spawn P1");
6216        registry
6217            .get_or_spawn(
6218                "127.0.0.1",
6219                addr2.port(),
6220                max_req,
6221                max_res,
6222                max_inflight,
6223                test_rt(),
6224                "distinct-2".into(),
6225                None,
6226            )
6227            .await
6228            .expect("spawn P2");
6229        assert_eq!(
6230            registry.bound_addr("127.0.0.1", addr1.port()),
6231            Some(addr1),
6232            "P1 bound addr must be its own listener"
6233        );
6234        assert_eq!(
6235            registry.bound_addr("127.0.0.1", addr2.port()),
6236            Some(addr2),
6237            "P2 bound addr must be its own listener"
6238        );
6239        let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
6240            .await
6241            .expect("connect P1");
6242        assert!(r1.status().as_u16() >= 200);
6243        let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
6244            .await
6245            .expect("connect P2");
6246        assert!(r2.status().as_u16() >= 200);
6247    }
6248
6249    #[allow(clippy::await_holding_lock)]
6250    #[tokio::test]
6251    async fn tls_prebound_listener_served() {
6252        use camel_component_api::test_support::tls;
6253
6254        // Install rustls crypto provider (aws-lc-rs — matches the existing
6255        // TLS registry tests).
6256        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6257
6258        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6259        ServerRegistry::reset();
6260        let registry = ServerRegistry::global();
6261        let (listener, _probe, addr) = clone_fixture_listener().await;
6262        let port = addr.port();
6263
6264        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6265        let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
6266        let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
6267        let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
6268
6269        let (max_req, max_res, max_inflight) = staged_limits();
6270        let routes = registry
6271            .get_or_spawn_with_listener(
6272                listener,
6273                max_req,
6274                max_res,
6275                max_inflight,
6276                test_rt(),
6277                "staged-tls".into(),
6278                Some(crate::config::ServerTlsConfig {
6279                    cert_path: cert_path.to_string_lossy().into_owned(),
6280                    key_path: key_path.to_string_lossy().into_owned(),
6281                }),
6282            )
6283            .await
6284            .expect("spawn TLS server from pre-bound listener");
6285
6286        // Client with CA cert — REAL verification (no danger_accept_invalid),
6287        // same helper pattern as the existing TLS registry tests.
6288        let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
6289        let client = reqwest::Client::builder()
6290            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
6291            .build()
6292            .expect("build tls client");
6293
6294        let resp = client
6295            .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
6296            .send()
6297            .await
6298            .expect("TLS handshake + request must succeed");
6299        assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
6300        assert_eq!(
6301            registry.bound_addr("127.0.0.1", port),
6302            Some(addr),
6303            "bound addr equals the pre-bound listener addr"
6304        );
6305        drop(routes);
6306    }
6307
6308    #[allow(clippy::await_holding_lock)]
6309    #[tokio::test]
6310    async fn with_listener_direct_spawn_keyed_by_actual_addr() {
6311        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6312        ServerRegistry::reset();
6313        let registry = ServerRegistry::global();
6314        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
6315            .await
6316            .expect("bind un-staged listener");
6317        let addr = listener.local_addr().expect("local addr");
6318        let port = addr.port();
6319
6320        let (max_req, max_res, max_inflight) = staged_limits();
6321        registry
6322            .get_or_spawn_with_listener(
6323                listener,
6324                max_req,
6325                max_res,
6326                max_inflight,
6327                test_rt(),
6328                "with-listener".into(),
6329                None,
6330            )
6331            .await
6332            .expect("direct spawn from un-staged listener");
6333        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
6334            .await
6335            .expect("connect on actual port");
6336        assert!(resp.status().as_u16() >= 200);
6337        assert_eq!(
6338            registry.bound_addr("127.0.0.1", port),
6339            Some(addr),
6340            "registry key is the listener's actual port"
6341        );
6342
6343        registry
6344            .get_or_spawn(
6345                "127.0.0.1",
6346                port,
6347                max_req,
6348                max_res,
6349                max_inflight,
6350                test_rt(),
6351                "with-listener-reuse".into(),
6352                None,
6353            )
6354            .await
6355            .expect("legacy caller must reuse the entry");
6356        assert_eq!(
6357            registry.bound_addr("127.0.0.1", port),
6358            Some(addr),
6359            "entry reused — no second bind"
6360        );
6361    }
6362
6363    // -----------------------------------------------------------------------
6364    // Axum dispatch handler tests
6365    // -----------------------------------------------------------------------
6366
6367    #[tokio::test]
6368    async fn test_dispatch_handler_returns_404_for_unknown_path() {
6369        let registry = HttpRouteRegistry::new();
6370        // Nothing registered in route registry
6371        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6372        let port = listener.local_addr().unwrap().port();
6373        tokio::spawn(run_axum_server(
6374            listener,
6375            registry,
6376            2 * 1024 * 1024,
6377            10 * 1024 * 1024,
6378            Arc::new(tokio::sync::Semaphore::new(1024)),
6379            test_rt(),
6380            "test-route".into(),
6381        ));
6382
6383        // Wait for server to start
6384        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6385
6386        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
6387            .await
6388            .unwrap();
6389        assert_eq!(resp.status().as_u16(), 404);
6390    }
6391
6392    // -----------------------------------------------------------------------
6393    // HttpConsumer tests
6394    // -----------------------------------------------------------------------
6395
6396    #[tokio::test]
6397    async fn test_http_consumer_start_registers_path() {
6398        use camel_component_api::ConsumerContext;
6399
6400        // Get an OS-assigned free port
6401        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6402        let port = listener.local_addr().unwrap().port();
6403        drop(listener); // Release port — ServerRegistry will rebind it
6404
6405        let consumer_cfg = HttpServerConfig {
6406            scheme: "http".to_string(),
6407            host: "127.0.0.1".to_string(),
6408            port,
6409            path: "/ping".to_string(),
6410            max_request_body: 2 * 1024 * 1024,
6411            max_response_body: 10 * 1024 * 1024,
6412            max_inflight_requests: 1024,
6413            method: None,
6414            tls_config: None,
6415        };
6416        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6417
6418        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6419        let token = tokio_util::sync::CancellationToken::new();
6420        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6421
6422        tokio::spawn(async move {
6423            consumer.start(ctx).await.unwrap();
6424        });
6425
6426        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6427
6428        let client = reqwest::Client::new();
6429        let resp_future = client
6430            .post(format!("http://127.0.0.1:{port}/ping"))
6431            .body("hello world")
6432            .send();
6433
6434        let (http_result, _) = tokio::join!(resp_future, async {
6435            if let Some(mut envelope) = rx.recv().await {
6436                // Set a custom status code
6437                envelope.exchange.input.set_header(
6438                    "CamelHttpResponseCode",
6439                    serde_json::Value::Number(201.into()),
6440                );
6441                if let Some(reply_tx) = envelope.reply_tx {
6442                    let _ = reply_tx.send(Ok(envelope.exchange));
6443                }
6444            }
6445        });
6446
6447        let resp = http_result.unwrap();
6448        assert_eq!(resp.status().as_u16(), 201);
6449
6450        token.cancel();
6451    }
6452
6453    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
6454    /// dispatcher's inflight semaphore so the semaphore stays the single
6455    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
6456    #[test]
6457    fn test_envelope_channel_capacity_follows_max_inflight() {
6458        assert_eq!(envelope_channel_capacity(0), 1);
6459        assert_eq!(envelope_channel_capacity(1), 1);
6460        assert_eq!(envelope_channel_capacity(7), 7);
6461        assert_eq!(envelope_channel_capacity(64), 64);
6462        assert_eq!(envelope_channel_capacity(1024), 1024);
6463    }
6464
6465    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
6466    /// configuration. Consumer start must not panic on it (the channel guard)
6467    /// and every request must get 503 from the empty semaphore.
6468    #[tokio::test]
6469    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
6470        use camel_component_api::ConsumerContext;
6471
6472        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6473        let port = listener.local_addr().unwrap().port();
6474        drop(listener);
6475
6476        let consumer_cfg = HttpServerConfig {
6477            scheme: "http".to_string(),
6478            host: "127.0.0.1".to_string(),
6479            port,
6480            path: "/ping".to_string(),
6481            max_request_body: 2 * 1024 * 1024,
6482            max_response_body: 10 * 1024 * 1024,
6483            max_inflight_requests: 0,
6484            method: None,
6485            tls_config: None,
6486        };
6487        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6488
6489        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6490        let token = tokio_util::sync::CancellationToken::new();
6491        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6492
6493        let start_handle = tokio::spawn(async move {
6494            consumer.start(ctx).await.unwrap();
6495        });
6496
6497        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6498
6499        let client = reqwest::Client::new();
6500        let resp = client
6501            .post(format!("http://127.0.0.1:{port}/ping"))
6502            .body("hello world")
6503            .send()
6504            .await
6505            .unwrap();
6506        assert_eq!(resp.status().as_u16(), 503);
6507
6508        token.cancel();
6509        let _ = start_handle.await;
6510    }
6511
6512    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
6513    /// waits for the listener bind before publishing RouteStarted.
6514    #[test]
6515    fn test_http_consumer_startup_mode_is_explicit() {
6516        use camel_component_api::ConsumerStartupMode;
6517        let consumer_cfg = HttpServerConfig {
6518            scheme: "http".to_string(),
6519            host: "127.0.0.1".to_string(),
6520            port: 0,
6521            path: "/x".to_string(),
6522            max_request_body: 2 * 1024 * 1024,
6523            max_response_body: 10 * 1024 * 1024,
6524            max_inflight_requests: 1024,
6525            method: None,
6526            tls_config: None,
6527        };
6528        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
6529        assert_eq!(
6530            consumer.startup_mode(),
6531            ConsumerStartupMode::Explicit,
6532            "HttpConsumer must opt into Explicit startup"
6533        );
6534    }
6535
6536    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
6537    /// + route registration. The StartupSignal resolves Ok only when that
6538    /// happens. Verified here by injecting our own signal pair into the
6539    /// ConsumerContext and asserting the receiver resolves within a bounded
6540    /// window even before any HTTP request is made.
6541    #[allow(clippy::await_holding_lock)]
6542    #[tokio::test]
6543    async fn test_http_consumer_emits_mark_ready_after_bind() {
6544        use camel_component_api::{ConsumerContext, StartupSignal};
6545
6546        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6547
6548        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6549        let port = listener.local_addr().unwrap().port();
6550        drop(listener);
6551
6552        let consumer_cfg = HttpServerConfig {
6553            scheme: "http".to_string(),
6554            host: "127.0.0.1".to_string(),
6555            port,
6556            path: "/ready-probe".to_string(),
6557            max_request_body: 2 * 1024 * 1024,
6558            max_response_body: 10 * 1024 * 1024,
6559            max_inflight_requests: 1024,
6560            method: None,
6561            tls_config: None,
6562        };
6563        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6564
6565        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6566        let token = tokio_util::sync::CancellationToken::new();
6567        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
6568
6569        // Inject our own startup signal so we can observe mark_ready.
6570        let (signal, startup_rx) = StartupSignal::pair();
6571        let ctx = ctx.with_startup(signal);
6572
6573        // Spawn start() — it MUST call mark_ready once the listener is bound
6574        // and the path is registered.
6575        tokio::spawn(async move {
6576            let _ = consumer.start(ctx).await;
6577        });
6578
6579        // The receiver MUST resolve Ok within a bounded window — proving
6580        // mark_ready was called by start(). A short timeout catches the
6581        // regression where mark_ready is never called (the old behaviour
6582        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
6583        let result =
6584            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
6585                .await
6586                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
6587        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
6588
6589        // Cancellation tears down the spawned start() loop.
6590        token.cancel();
6591    }
6592
6593    #[tokio::test]
6594    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
6595        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6596
6597        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6598        let port = listener.local_addr().unwrap().port();
6599        drop(listener);
6600
6601        let consumer_cfg = HttpServerConfig {
6602            scheme: "http".to_string(),
6603            host: "127.0.0.1".to_string(),
6604            port,
6605            path: "/saturation".to_string(),
6606            max_request_body: 2 * 1024 * 1024,
6607            max_response_body: 10 * 1024 * 1024,
6608            max_inflight_requests: 1,
6609            method: None,
6610            tls_config: None,
6611        };
6612        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6613
6614        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6615        let token = tokio_util::sync::CancellationToken::new();
6616        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6617        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6618        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6619
6620        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
6621        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
6622
6623        tokio::spawn(async move {
6624            let mut first_seen_tx = Some(first_seen_tx);
6625            let mut unblock_first_rx = Some(unblock_first_rx);
6626
6627            while let Some(envelope) = rx.recv().await {
6628                if let Some(tx) = first_seen_tx.take() {
6629                    let _ = tx.send(());
6630                    if let Some(rx_unblock) = unblock_first_rx.take() {
6631                        let _ = rx_unblock.await;
6632                    }
6633                }
6634
6635                if let Some(reply_tx) = envelope.reply_tx {
6636                    let _ = reply_tx.send(Ok(envelope.exchange));
6637                }
6638            }
6639        });
6640
6641        let client = reqwest::Client::new();
6642        let first_req = {
6643            let client = client.clone();
6644            async move {
6645                client
6646                    .get(format!("http://127.0.0.1:{port}/saturation"))
6647                    .send()
6648                    .await
6649                    .unwrap()
6650            }
6651        };
6652
6653        let first_handle = tokio::spawn(first_req);
6654        first_seen_rx.await.unwrap();
6655
6656        let second_resp = client
6657            .get(format!("http://127.0.0.1:{port}/saturation"))
6658            .send()
6659            .await
6660            .unwrap();
6661
6662        assert_eq!(second_resp.status().as_u16(), 503);
6663
6664        let _ = unblock_first_tx.send(());
6665        let first_resp = first_handle.await.unwrap();
6666        assert_eq!(first_resp.status().as_u16(), 200);
6667
6668        token.cancel();
6669    }
6670
6671    /// Audit 2026-08-31, F2-1: a chunked (no Content-Length) request body must
6672    /// still be capped — the byte limit travels with the stream, so any
6673    /// downstream materialization fails closed past `max_request_body`.
6674    #[tokio::test]
6675    async fn test_http_consumer_chunked_body_is_capped() {
6676        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6677
6678        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6679        let port = listener.local_addr().unwrap().port();
6680        drop(listener);
6681
6682        let consumer_cfg = HttpServerConfig {
6683            scheme: "http".to_string(),
6684            host: "127.0.0.1".to_string(),
6685            port,
6686            path: "/chunked-cap".to_string(),
6687            max_request_body: 1024, // tiny cap for the test
6688            max_response_body: 10 * 1024 * 1024,
6689            max_inflight_requests: 16,
6690            method: None,
6691            tls_config: None,
6692        };
6693        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6694
6695        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6696        let token = tokio_util::sync::CancellationToken::new();
6697        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6698        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6699        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6700
6701        // Chunked body: reqwest streams it without Content-Length.
6702        let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
6703            .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
6704            .collect();
6705        let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
6706
6707        let client = reqwest::Client::new();
6708        let send_fut = client
6709            .post(format!("http://127.0.0.1:{port}/chunked-cap"))
6710            .body(stream_body)
6711            .send();
6712
6713        let (http_result, _) = tokio::join!(send_fut, async {
6714            if let Some(mut envelope) = rx.recv().await {
6715                // The route materializes the body — the cap must fire.
6716                let materialized = envelope
6717                    .exchange
6718                    .input
6719                    .body
6720                    .clone()
6721                    .into_bytes(64 * 1024)
6722                    .await;
6723                assert!(
6724                    materialized.is_err(),
6725                    "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
6726                );
6727                let err = materialized.unwrap_err().to_string();
6728                assert!(
6729                    err.contains("limit") || err.contains("exceeds"),
6730                    "error should mention the limit: {err}"
6731                );
6732                if let Some(reply_tx) = envelope.reply_tx {
6733                    envelope.exchange.input.body =
6734                        camel_component_api::Body::Text("handled".to_string());
6735                    let _ = reply_tx.send(Ok(envelope.exchange));
6736                }
6737            }
6738        });
6739
6740        let resp = http_result.unwrap();
6741        assert_eq!(resp.status().as_u16(), 200);
6742
6743        token.cancel();
6744    }
6745
6746    #[tokio::test]
6747    #[allow(clippy::await_holding_lock)]
6748    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
6749        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6750
6751        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6752
6753        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6754        let port = listener.local_addr().unwrap().port();
6755        drop(listener);
6756
6757        let consumer_cfg = HttpServerConfig {
6758            scheme: "http".to_string(),
6759            host: "127.0.0.1".to_string(),
6760            port,
6761            path: "/limit-bytes".to_string(),
6762            max_request_body: 2 * 1024 * 1024,
6763            max_response_body: 16,
6764            max_inflight_requests: 1024,
6765            method: None,
6766            tls_config: None,
6767        };
6768        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6769
6770        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6771        let token = tokio_util::sync::CancellationToken::new();
6772        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6773        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6774        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6775
6776        let client = reqwest::Client::new();
6777        let send_fut = client
6778            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
6779            .send();
6780
6781        let (http_result, _) = tokio::join!(send_fut, async {
6782            if let Some(mut envelope) = rx.recv().await {
6783                envelope.exchange.input.body =
6784                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
6785                if let Some(reply_tx) = envelope.reply_tx {
6786                    let _ = reply_tx.send(Ok(envelope.exchange));
6787                }
6788            }
6789        });
6790
6791        let resp = http_result.unwrap();
6792        assert_eq!(resp.status().as_u16(), 500);
6793        let body = resp.text().await.unwrap();
6794        assert_eq!(body, "Response body exceeds configured limit");
6795        token.cancel();
6796    }
6797
6798    #[tokio::test]
6799    #[allow(clippy::await_holding_lock)]
6800    async fn test_http_consumer_enforces_max_response_body_for_json() {
6801        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6802
6803        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6804
6805        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6806        let port = listener.local_addr().unwrap().port();
6807        drop(listener);
6808
6809        let consumer_cfg = HttpServerConfig {
6810            scheme: "http".to_string(),
6811            host: "127.0.0.1".to_string(),
6812            port,
6813            path: "/limit-json".to_string(),
6814            max_request_body: 2 * 1024 * 1024,
6815            max_response_body: 16,
6816            max_inflight_requests: 1024,
6817            method: None,
6818            tls_config: None,
6819        };
6820        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6821
6822        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6823        let token = tokio_util::sync::CancellationToken::new();
6824        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6825        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6826        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6827
6828        let client = reqwest::Client::new();
6829        let send_fut = client
6830            .get(format!("http://127.0.0.1:{port}/limit-json"))
6831            .send();
6832
6833        let (http_result, _) = tokio::join!(send_fut, async {
6834            if let Some(mut envelope) = rx.recv().await {
6835                envelope.exchange.input.body = camel_component_api::Body::Json(
6836                    serde_json::json!({"message":"this response is bigger than sixteen"}),
6837                );
6838                if let Some(reply_tx) = envelope.reply_tx {
6839                    let _ = reply_tx.send(Ok(envelope.exchange));
6840                }
6841            }
6842        });
6843
6844        let resp = http_result.unwrap();
6845        assert_eq!(resp.status().as_u16(), 500);
6846        let body = resp.text().await.unwrap();
6847        assert_eq!(body, "Response body exceeds configured limit");
6848        token.cancel();
6849    }
6850
6851    #[tokio::test]
6852    #[allow(clippy::await_holding_lock)]
6853    async fn test_http_consumer_enforces_max_response_body_for_xml() {
6854        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6855
6856        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6857
6858        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6859        let port = listener.local_addr().unwrap().port();
6860        drop(listener);
6861
6862        let consumer_cfg = HttpServerConfig {
6863            scheme: "http".to_string(),
6864            host: "127.0.0.1".to_string(),
6865            port,
6866            path: "/limit-xml".to_string(),
6867            max_request_body: 2 * 1024 * 1024,
6868            max_response_body: 16,
6869            max_inflight_requests: 1024,
6870            method: None,
6871            tls_config: None,
6872        };
6873        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6874
6875        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6876        let token = tokio_util::sync::CancellationToken::new();
6877        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6878        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6879        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6880
6881        let client = reqwest::Client::new();
6882        let send_fut = client
6883            .get(format!("http://127.0.0.1:{port}/limit-xml"))
6884            .send();
6885
6886        let (http_result, _) = tokio::join!(send_fut, async {
6887            if let Some(mut envelope) = rx.recv().await {
6888                envelope.exchange.input.body = camel_component_api::Body::Xml(
6889                    "<root><value>way-too-large</value></root>".into(),
6890                );
6891                if let Some(reply_tx) = envelope.reply_tx {
6892                    let _ = reply_tx.send(Ok(envelope.exchange));
6893                }
6894            }
6895        });
6896
6897        let resp = http_result.unwrap();
6898        assert_eq!(resp.status().as_u16(), 500);
6899        let body = resp.text().await.unwrap();
6900        assert_eq!(body, "Response body exceeds configured limit");
6901        token.cancel();
6902    }
6903
6904    #[tokio::test]
6905    #[allow(clippy::await_holding_lock)]
6906    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
6907        use camel_component_api::{
6908            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
6909        };
6910        use futures::stream;
6911
6912        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6913
6914        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
6915        let port = listener.local_addr().unwrap().port();
6916        drop(listener);
6917
6918        let consumer_cfg = HttpServerConfig {
6919            scheme: "http".to_string(),
6920            host: "0.0.0.0".to_string(),
6921            port,
6922            path: "/limit-stream".to_string(),
6923            max_request_body: 2 * 1024 * 1024,
6924            max_response_body: 16,
6925            max_inflight_requests: 1024,
6926            method: None,
6927            tls_config: None,
6928        };
6929        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6930
6931        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6932        let token = tokio_util::sync::CancellationToken::new();
6933        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6934        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6935        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6936
6937        let client = reqwest::Client::new();
6938        let send_fut = client
6939            .get(format!("http://127.0.0.1:{port}/limit-stream"))
6940            .send();
6941
6942        let (http_result, _) = tokio::join!(send_fut, async {
6943            if let Some(mut envelope) = rx.recv().await {
6944                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6945                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
6946                let stream = Box::pin(stream::iter(chunks));
6947                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6948                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6949                    metadata: StreamMetadata {
6950                        size_hint: Some(32),
6951                        content_type: Some("application/octet-stream".into()),
6952                        origin: None,
6953                    },
6954                });
6955                if let Some(reply_tx) = envelope.reply_tx {
6956                    let _ = reply_tx.send(Ok(envelope.exchange));
6957                }
6958            }
6959        });
6960
6961        let resp = http_result.unwrap();
6962        assert_eq!(resp.status().as_u16(), 200);
6963        let body = resp.bytes().await.unwrap();
6964        assert_eq!(body.len(), 32);
6965        token.cancel();
6966    }
6967
6968    // -----------------------------------------------------------------------
6969    // Integration tests
6970    // -----------------------------------------------------------------------
6971
6972    #[tokio::test]
6973    #[allow(clippy::await_holding_lock)]
6974    async fn test_integration_single_consumer_round_trip() {
6975        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6976
6977        // Spawns an HTTP consumer on the global ServerRegistry
6978        // (HttpConsumer::start → get_or_spawn). Serialize against the other
6979        // registry tests so parallel runs do not race on shared global state.
6980        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6981
6982        // Get an OS-assigned free port (ephemeral)
6983        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6984        let port = listener.local_addr().unwrap().port();
6985        drop(listener); // Release — ServerRegistry will rebind
6986
6987        let component = HttpComponent::new();
6988        let endpoint_ctx = NoOpComponentContext;
6989        let endpoint = component
6990            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
6991            .unwrap();
6992        let mut consumer = endpoint.create_consumer(rt()).unwrap();
6993
6994        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6995        let token = tokio_util::sync::CancellationToken::new();
6996        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6997
6998        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6999        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7000
7001        let client = reqwest::Client::new();
7002        let send_fut = client
7003            .post(format!("http://127.0.0.1:{port}/echo"))
7004            .header("Content-Type", "text/plain")
7005            .body("ping")
7006            .send();
7007
7008        let (http_result, _) = tokio::join!(send_fut, async {
7009            if let Some(mut envelope) = rx.recv().await {
7010                assert_eq!(
7011                    envelope.exchange.input.header("CamelHttpMethod"),
7012                    Some(&serde_json::Value::String("POST".into()))
7013                );
7014                assert_eq!(
7015                    envelope.exchange.input.header("CamelHttpPath"),
7016                    Some(&serde_json::Value::String("/echo".into()))
7017                );
7018                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7019                if let Some(reply_tx) = envelope.reply_tx {
7020                    let _ = reply_tx.send(Ok(envelope.exchange));
7021                }
7022            }
7023        });
7024
7025        let resp = http_result.unwrap();
7026        assert_eq!(resp.status().as_u16(), 200);
7027        let body = resp.text().await.unwrap();
7028        assert_eq!(body, "pong");
7029
7030        token.cancel();
7031    }
7032
7033    #[tokio::test]
7034    #[allow(clippy::await_holding_lock)]
7035    async fn test_integration_two_consumers_shared_port() {
7036        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7037
7038        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7039
7040        // Get an OS-assigned free port (ephemeral)
7041        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7042        let port = listener.local_addr().unwrap().port();
7043        drop(listener);
7044
7045        let component = HttpComponent::new();
7046        let endpoint_ctx = NoOpComponentContext;
7047
7048        // Consumer A: /hello
7049        let endpoint_a = component
7050            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
7051            .unwrap();
7052        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
7053
7054        // Consumer B: /world
7055        let endpoint_b = component
7056            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
7057            .unwrap();
7058        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
7059
7060        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7061        let token_a = tokio_util::sync::CancellationToken::new();
7062        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
7063
7064        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7065        let token_b = tokio_util::sync::CancellationToken::new();
7066        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
7067
7068        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
7069        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
7070        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7071
7072        let client = reqwest::Client::new();
7073
7074        // Request to /hello
7075        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
7076        let (resp_hello, _) = tokio::join!(fut_hello, async {
7077            if let Some(mut envelope) = rx_a.recv().await {
7078                envelope.exchange.input.body =
7079                    camel_component_api::Body::Text("hello-response".to_string());
7080                if let Some(reply_tx) = envelope.reply_tx {
7081                    let _ = reply_tx.send(Ok(envelope.exchange));
7082                }
7083            }
7084        });
7085
7086        // Request to /world
7087        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
7088        let (resp_world, _) = tokio::join!(fut_world, async {
7089            if let Some(mut envelope) = rx_b.recv().await {
7090                envelope.exchange.input.body =
7091                    camel_component_api::Body::Text("world-response".to_string());
7092                if let Some(reply_tx) = envelope.reply_tx {
7093                    let _ = reply_tx.send(Ok(envelope.exchange));
7094                }
7095            }
7096        });
7097
7098        let body_a = resp_hello.unwrap().text().await.unwrap();
7099        let body_b = resp_world.unwrap().text().await.unwrap();
7100
7101        assert_eq!(body_a, "hello-response");
7102        assert_eq!(body_b, "world-response");
7103
7104        token_a.cancel();
7105        token_b.cancel();
7106    }
7107
7108    #[tokio::test]
7109    #[allow(clippy::await_holding_lock)]
7110    async fn test_integration_unregistered_path_returns_404() {
7111        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7112
7113        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7114
7115        // Get an OS-assigned free port (ephemeral)
7116        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7117        let port = listener.local_addr().unwrap().port();
7118        drop(listener);
7119
7120        let component = HttpComponent::new();
7121        let endpoint_ctx = NoOpComponentContext;
7122        let endpoint = component
7123            .create_endpoint(
7124                &format!("http://127.0.0.1:{port}/registered"),
7125                &endpoint_ctx,
7126            )
7127            .unwrap();
7128        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7129
7130        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7131        let token = tokio_util::sync::CancellationToken::new();
7132        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7133
7134        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7135
7136        // Wait until the server is actually accepting connections (CI runners can be slow).
7137        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7138        loop {
7139            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
7140                .await
7141                .is_ok()
7142            {
7143                break;
7144            }
7145            if std::time::Instant::now() >= deadline {
7146                panic!("HTTP server did not start within 5s on port {port}");
7147            }
7148            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7149        }
7150
7151        let client = reqwest::Client::new();
7152        let resp = client
7153            .get(format!("http://127.0.0.1:{port}/not-there"))
7154            .send()
7155            .await
7156            .unwrap();
7157        assert_eq!(resp.status().as_u16(), 404);
7158
7159        token.cancel();
7160    }
7161
7162    #[test]
7163    fn test_http_consumer_declares_concurrent() {
7164        use camel_component_api::ConcurrencyModel;
7165
7166        let config = HttpServerConfig {
7167            scheme: "http".to_string(),
7168            host: "127.0.0.1".to_string(),
7169            port: 19999,
7170            path: "/test".to_string(),
7171            max_request_body: 2 * 1024 * 1024,
7172            max_response_body: 10 * 1024 * 1024,
7173            max_inflight_requests: 1024,
7174            method: None,
7175            tls_config: None,
7176        };
7177        let consumer = HttpConsumer::new(config, test_rt());
7178        assert_eq!(
7179            consumer.concurrency_model(),
7180            ConcurrencyModel::Concurrent { max: None }
7181        );
7182    }
7183
7184    #[test]
7185    fn server_config_parses_tls_cert_and_key() {
7186        let cfg = HttpServerConfig::from_uri(
7187            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
7188        )
7189        .unwrap();
7190        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
7191        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
7192    }
7193
7194    #[test]
7195    fn server_config_no_tls_when_params_absent() {
7196        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
7197        assert!(cfg.tls_config.is_none());
7198    }
7199
7200    // -----------------------------------------------------------------------
7201    // HttpReplyBody streaming tests
7202    // -----------------------------------------------------------------------
7203
7204    #[tokio::test]
7205    async fn test_http_reply_body_stream_variant_exists() {
7206        use bytes::Bytes;
7207        use camel_component_api::CamelError;
7208        use futures::stream;
7209
7210        let chunks: Vec<Result<Bytes, CamelError>> =
7211            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
7212        let stream = Box::pin(stream::iter(chunks));
7213        let reply_body = HttpReplyBody::Stream(stream);
7214        // Si compila y el match funciona, el test pasa
7215        match reply_body {
7216            HttpReplyBody::Stream(_) => {}
7217            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
7218        }
7219    }
7220
7221    // -----------------------------------------------------------------------
7222    // OpenTelemetry propagation tests (only compiled with "otel" feature)
7223    // -----------------------------------------------------------------------
7224
7225    #[cfg(feature = "otel")]
7226    mod otel_tests {
7227        use super::*;
7228        use camel_component_api::Message;
7229        use tower::ServiceExt;
7230
7231        #[tokio::test]
7232        async fn test_producer_injects_traceparent_header() {
7233            let (url, _handle) = start_test_server_with_header_capture().await;
7234            let ctx = test_producer_ctx();
7235
7236            let component = HttpComponent::new();
7237            let endpoint_ctx = NoOpComponentContext;
7238            let endpoint = component
7239                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7240                .unwrap();
7241            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7242
7243            // Create exchange with an OTel context by extracting from a traceparent header
7244            let mut exchange = Exchange::new(Message::default());
7245            let mut headers = std::collections::HashMap::new();
7246            headers.insert(
7247                "traceparent".to_string(),
7248                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
7249            );
7250            camel_otel::extract_into_exchange(&mut exchange, &headers);
7251
7252            let result = producer.oneshot(exchange).await.unwrap();
7253
7254            // Verify request succeeded
7255            let status = result
7256                .input
7257                .header("CamelHttpResponseCode")
7258                .and_then(|v| v.as_u64())
7259                .unwrap();
7260            assert_eq!(status, 200);
7261
7262            // The test server echoes back the received traceparent header
7263            let traceparent = result.input.header("X-Received-Traceparent");
7264            assert!(
7265                traceparent.is_some(),
7266                "traceparent header should have been sent"
7267            );
7268
7269            let traceparent_str = traceparent.unwrap().as_str().unwrap();
7270            // Verify format: version-traceid-spanid-flags
7271            let parts: Vec<&str> = traceparent_str.split('-').collect();
7272            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7273            assert_eq!(parts[0], "00", "version should be 00");
7274            assert_eq!(
7275                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7276                "trace-id should match"
7277            );
7278            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
7279            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
7280        }
7281
7282        #[tokio::test]
7283        async fn test_consumer_extracts_traceparent_header() {
7284            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7285
7286            // Get an OS-assigned free port
7287            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7288            let port = listener.local_addr().unwrap().port();
7289            drop(listener);
7290
7291            let component = HttpComponent::new();
7292            let endpoint_ctx = NoOpComponentContext;
7293            let endpoint = component
7294                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7295                .unwrap();
7296            let mut consumer = endpoint.create_consumer(rt()).unwrap();
7297
7298            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7299            let token = tokio_util::sync::CancellationToken::new();
7300            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7301
7302            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7303            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7304
7305            // Send request with traceparent header
7306            let client = reqwest::Client::new();
7307            let send_fut = client
7308                .post(format!("http://127.0.0.1:{port}/trace"))
7309                .header(
7310                    "traceparent",
7311                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7312                )
7313                .body("test")
7314                .send();
7315
7316            let (http_result, _) = tokio::join!(send_fut, async {
7317                if let Some(envelope) = rx.recv().await {
7318                    // Verify the exchange has a valid OTel context by re-injecting it
7319                    // and checking the traceparent matches
7320                    let mut injected_headers = std::collections::HashMap::new();
7321                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7322
7323                    assert!(
7324                        injected_headers.contains_key("traceparent"),
7325                        "Exchange should have traceparent after extraction"
7326                    );
7327
7328                    let traceparent = injected_headers.get("traceparent").unwrap();
7329                    let parts: Vec<&str> = traceparent.split('-').collect();
7330                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7331                    assert_eq!(
7332                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7333                        "Trace ID should match the original traceparent header"
7334                    );
7335
7336                    if let Some(reply_tx) = envelope.reply_tx {
7337                        let _ = reply_tx.send(Ok(envelope.exchange));
7338                    }
7339                }
7340            });
7341
7342            let resp = http_result.unwrap();
7343            assert_eq!(resp.status().as_u16(), 200);
7344
7345            token.cancel();
7346        }
7347
7348        #[tokio::test]
7349        async fn test_consumer_extracts_mixed_case_traceparent_header() {
7350            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7351
7352            // Get an OS-assigned free port
7353            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7354            let port = listener.local_addr().unwrap().port();
7355            drop(listener);
7356
7357            let component = HttpComponent::new();
7358            let endpoint_ctx = NoOpComponentContext;
7359            let endpoint = component
7360                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7361                .unwrap();
7362            let mut consumer = endpoint.create_consumer(rt()).unwrap();
7363
7364            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7365            let token = tokio_util::sync::CancellationToken::new();
7366            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7367
7368            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7369            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7370
7371            // Send request with MIXED-CASE TraceParent header (not lowercase)
7372            let client = reqwest::Client::new();
7373            let send_fut = client
7374                .post(format!("http://127.0.0.1:{port}/trace"))
7375                .header(
7376                    "TraceParent",
7377                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7378                )
7379                .body("test")
7380                .send();
7381
7382            let (http_result, _) = tokio::join!(send_fut, async {
7383                if let Some(envelope) = rx.recv().await {
7384                    // Verify the exchange has a valid OTel context by re-injecting it
7385                    // and checking the traceparent matches
7386                    let mut injected_headers = HashMap::new();
7387                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7388
7389                    assert!(
7390                        injected_headers.contains_key("traceparent"),
7391                        "Exchange should have traceparent after extraction from mixed-case header"
7392                    );
7393
7394                    let traceparent = injected_headers.get("traceparent").unwrap();
7395                    let parts: Vec<&str> = traceparent.split('-').collect();
7396                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7397                    assert_eq!(
7398                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7399                        "Trace ID should match the original mixed-case TraceParent header"
7400                    );
7401
7402                    if let Some(reply_tx) = envelope.reply_tx {
7403                        let _ = reply_tx.send(Ok(envelope.exchange));
7404                    }
7405                }
7406            });
7407
7408            let resp = http_result.unwrap();
7409            assert_eq!(resp.status().as_u16(), 200);
7410
7411            token.cancel();
7412        }
7413
7414        #[tokio::test]
7415        async fn test_producer_no_trace_context_no_crash() {
7416            let (url, _handle) = start_test_server().await;
7417            let ctx = test_producer_ctx();
7418
7419            let component = HttpComponent::new();
7420            let endpoint_ctx = NoOpComponentContext;
7421            let endpoint = component
7422                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7423                .unwrap();
7424            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7425
7426            // Create exchange with default (empty) otel_context - no trace context
7427            let exchange = Exchange::new(Message::default());
7428
7429            // Should succeed without panic
7430            let result = producer.oneshot(exchange).await.unwrap();
7431
7432            // Verify request succeeded
7433            let status = result
7434                .input
7435                .header("CamelHttpResponseCode")
7436                .and_then(|v| v.as_u64())
7437                .unwrap();
7438            assert_eq!(status, 200);
7439        }
7440
7441        /// Test server that captures and echoes back the traceparent header
7442        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
7443            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7444            let addr = listener.local_addr().unwrap();
7445            let url = format!("http://127.0.0.1:{}", addr.port());
7446
7447            let handle = tokio::spawn(async move {
7448                loop {
7449                    if let Ok((mut stream, _)) = listener.accept().await {
7450                        tokio::spawn(async move {
7451                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
7452                            let mut buf = vec![0u8; 8192];
7453                            let n = stream.read(&mut buf).await.unwrap_or(0);
7454                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
7455
7456                            // Extract traceparent header from request
7457                            let traceparent = request
7458                                .lines()
7459                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
7460                                .map(|line| {
7461                                    line.split(':')
7462                                        .nth(1)
7463                                        .map(|s| s.trim().to_string())
7464                                        .unwrap_or_default()
7465                                })
7466                                .unwrap_or_default();
7467
7468                            let body =
7469                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
7470                            let response = format!(
7471                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
7472                                body.len(),
7473                                traceparent,
7474                                body
7475                            );
7476                            let _ = stream.write_all(response.as_bytes()).await;
7477                        });
7478                    }
7479                }
7480            });
7481
7482            (url, handle)
7483        }
7484    }
7485
7486    // -----------------------------------------------------------------------
7487    // Response streaming tests (Eje A - Task 2)
7488    // -----------------------------------------------------------------------
7489
7490    // -----------------------------------------------------------------------
7491    // Request streaming tests (Eje B - Task 3)
7492    // -----------------------------------------------------------------------
7493
7494    #[tokio::test]
7495    async fn test_request_body_arrives_as_stream() {
7496        use camel_component_api::Body;
7497        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7498
7499        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7500        let port = listener.local_addr().unwrap().port();
7501        drop(listener);
7502
7503        let component = HttpComponent::new();
7504        let endpoint_ctx = NoOpComponentContext;
7505        let endpoint = component
7506            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
7507            .unwrap();
7508        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7509
7510        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7511        let token = tokio_util::sync::CancellationToken::new();
7512        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7513
7514        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7515        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7516
7517        let client = reqwest::Client::new();
7518        let send_fut = client
7519            .post(format!("http://127.0.0.1:{port}/upload"))
7520            .body("hello streaming world")
7521            .send();
7522
7523        let (http_result, _) = tokio::join!(send_fut, async {
7524            if let Some(mut envelope) = rx.recv().await {
7525                // Body must be Body::Stream, not Body::Text or Body::Bytes
7526                assert!(
7527                    matches!(envelope.exchange.input.body, Body::Stream(_)),
7528                    "expected Body::Stream, got discriminant {:?}",
7529                    std::mem::discriminant(&envelope.exchange.input.body)
7530                );
7531                // Materialize to verify content
7532                let bytes = envelope
7533                    .exchange
7534                    .input
7535                    .body
7536                    .into_bytes(1024 * 1024)
7537                    .await
7538                    .unwrap();
7539                assert_eq!(&bytes[..], b"hello streaming world");
7540
7541                envelope.exchange.input.body = camel_component_api::Body::Empty;
7542                if let Some(reply_tx) = envelope.reply_tx {
7543                    let _ = reply_tx.send(Ok(envelope.exchange));
7544                }
7545            }
7546        });
7547
7548        let resp = http_result.unwrap();
7549        assert_eq!(resp.status().as_u16(), 200);
7550
7551        token.cancel();
7552    }
7553
7554    // -----------------------------------------------------------------------
7555    // Response streaming tests (Eje A - Task 2)
7556    // -----------------------------------------------------------------------
7557
7558    #[tokio::test]
7559    async fn test_streaming_response_chunked() {
7560        use bytes::Bytes;
7561        use camel_component_api::Body;
7562        use camel_component_api::CamelError;
7563        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7564        use camel_component_api::{StreamBody, StreamMetadata};
7565        use futures::stream;
7566        use std::sync::Arc;
7567        use tokio::sync::Mutex;
7568
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(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
7577            .unwrap();
7578        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7579
7580        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7581        let token = tokio_util::sync::CancellationToken::new();
7582        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7583
7584        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7585        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7586
7587        let client = reqwest::Client::new();
7588        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
7589
7590        let (http_result, _) = tokio::join!(send_fut, async {
7591            if let Some(mut envelope) = rx.recv().await {
7592                // Respond with Body::Stream
7593                let chunks: Vec<Result<Bytes, CamelError>> =
7594                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
7595                let stream = Box::pin(stream::iter(chunks));
7596                envelope.exchange.input.body = Body::Stream(StreamBody {
7597                    stream: Arc::new(Mutex::new(Some(stream))),
7598                    metadata: StreamMetadata::default(),
7599                });
7600                if let Some(reply_tx) = envelope.reply_tx {
7601                    let _ = reply_tx.send(Ok(envelope.exchange));
7602                }
7603            }
7604        });
7605
7606        let resp = http_result.unwrap();
7607        assert_eq!(resp.status().as_u16(), 200);
7608        let body = resp.text().await.unwrap();
7609        assert_eq!(body, "chunk1chunk2");
7610
7611        token.cancel();
7612    }
7613
7614    // -----------------------------------------------------------------------
7615    // 413 Content-Length limit test (Task 4)
7616    // -----------------------------------------------------------------------
7617
7618    #[tokio::test]
7619    async fn test_413_when_content_length_exceeds_limit() {
7620        use camel_component_api::ConsumerContext;
7621
7622        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7623        let port = listener.local_addr().unwrap().port();
7624        drop(listener);
7625
7626        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
7627        let component = HttpComponent::new();
7628        let endpoint_ctx = NoOpComponentContext;
7629        let endpoint = component
7630            .create_endpoint(
7631                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
7632                &endpoint_ctx,
7633            )
7634            .unwrap();
7635        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7636
7637        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7638        let token = tokio_util::sync::CancellationToken::new();
7639        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7640
7641        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7642        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7643
7644        let client = reqwest::Client::new();
7645        let resp = client
7646            .post(format!("http://127.0.0.1:{port}/upload"))
7647            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
7648            .body("x".repeat(1000))
7649            .send()
7650            .await
7651            .unwrap();
7652
7653        assert_eq!(resp.status().as_u16(), 413);
7654
7655        token.cancel();
7656    }
7657
7658    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
7659    /// The spec says: "If there is no Content-Length, the limit does not apply at the
7660    /// consumer level — the route is responsible."
7661    #[tokio::test]
7662    async fn test_chunked_upload_without_content_length_bypasses_limit() {
7663        use bytes::Bytes;
7664        use camel_component_api::Body;
7665        use camel_component_api::ConsumerContext;
7666        use futures::stream;
7667
7668        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7669        let port = listener.local_addr().unwrap().port();
7670        drop(listener);
7671
7672        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
7673        let component = HttpComponent::new();
7674        let endpoint_ctx = NoOpComponentContext;
7675        let endpoint = component
7676            .create_endpoint(
7677                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
7678                &endpoint_ctx,
7679            )
7680            .unwrap();
7681        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7682
7683        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7684        let token = tokio_util::sync::CancellationToken::new();
7685        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7686
7687        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7688        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7689
7690        let client = reqwest::Client::new();
7691
7692        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
7693        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
7694        // but since there's no Content-Length the 413 check must NOT fire.
7695        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
7696            Ok(Bytes::from("y".repeat(50))),
7697            Ok(Bytes::from("y".repeat(50))),
7698        ];
7699        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
7700        let send_fut = client
7701            .post(format!("http://127.0.0.1:{port}/upload"))
7702            .body(stream_body)
7703            .send();
7704
7705        let consumer_fut = async {
7706            // Use timeout to avoid deadlock if the handler rejects before enqueueing
7707            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
7708                Ok(Some(mut envelope)) => {
7709                    assert!(
7710                        matches!(envelope.exchange.input.body, Body::Stream(_)),
7711                        "expected Body::Stream"
7712                    );
7713                    envelope.exchange.input.body = camel_component_api::Body::Empty;
7714                    if let Some(reply_tx) = envelope.reply_tx {
7715                        let _ = reply_tx.send(Ok(envelope.exchange));
7716                    }
7717                }
7718                Ok(None) => panic!("consumer channel closed unexpectedly"),
7719                Err(_) => {
7720                    // Timeout: the request was rejected before reaching the consumer.
7721                    // The HTTP response will carry the real status code (we check below).
7722                }
7723            }
7724        };
7725
7726        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
7727
7728        let resp = http_result.unwrap();
7729        // Audit 2026-08-31 (F2-1): the request is no longer rejected at the door
7730        // (no Content-Length to pre-check), but the byte cap now travels with the
7731        // stream: ANY materialization past maxRequestBody fails closed. This test
7732        // does not consume the body, so the request still completes with 200 —
7733        // enforcement happens at consumption time (see
7734        // test_http_consumer_chunked_body_is_capped).
7735        assert_ne!(
7736            resp.status().as_u16(),
7737            413,
7738            "chunked upload has no Content-Length to pre-check"
7739        );
7740        assert_eq!(resp.status().as_u16(), 200);
7741
7742        token.cancel();
7743    }
7744
7745    #[test]
7746    fn test_is_private_ip_ranges() {
7747        use camel_api::is_ssrf_blocked_ip;
7748        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
7749        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
7750        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
7751        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
7752        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
7753        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
7754
7755        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
7756        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
7757        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
7758        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
7759        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
7760        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
7761        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
7762        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
7763
7764        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
7765        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
7766        assert!(!is_ssrf_blocked_ip(
7767            &"2001:4860:4860::8888".parse().unwrap()
7768        )); // allow-unwrap
7769    }
7770
7771    #[test]
7772    fn test_title_case_header() {
7773        assert_eq!(title_case_header("content-type"), "Content-Type");
7774        assert_eq!(title_case_header("authorization"), "Authorization");
7775        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
7776        assert_eq!(title_case_header("host"), "Host");
7777        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
7778        assert_eq!(title_case_header("single"), "Single");
7779        assert_eq!(title_case_header(""), "");
7780    }
7781
7782    #[test]
7783    fn test_resolve_url_combines_path_and_query_sources() {
7784        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
7785        let mut exchange = Exchange::new(Message::default());
7786        exchange.input.set_header(
7787            "CamelHttpPath",
7788            serde_json::Value::String("next".to_string()),
7789        );
7790        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7791        assert!(url.starts_with("http://example.com/base/next?"));
7792        assert!(url.contains("foo=bar"));
7793
7794        exchange.input.set_header(
7795            "CamelHttpUri",
7796            serde_json::Value::String("http://other.test/root".to_string()),
7797        );
7798        exchange.input.set_header(
7799            "CamelHttpQuery",
7800            serde_json::Value::String("a=1&b=2".to_string()),
7801        );
7802
7803        let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7804        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
7805    }
7806
7807    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
7808        let mut exchange = Exchange::new(Message::default());
7809        exchange
7810            .input
7811            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
7812        exchange.input.set_header(
7813            "CamelHttpQuery",
7814            serde_json::Value::String(query.to_string()),
7815        );
7816        exchange
7817    }
7818
7819    #[test]
7820    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
7821        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7822        cfg.bridge_endpoint = true;
7823        cfg.query_params
7824            .push(("token".to_string(), "secret".to_string()));
7825        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
7826        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7827        // Verbatim assembly: the old round-trip normalized the empty base
7828        // path to `/` (`http://x/?token=secret`); authored bytes end-to-end
7829        // no longer insert it.
7830        assert_eq!(url, "http://x?token=secret");
7831        assert!(!url.contains("/foo"));
7832        assert!(!url.contains("dropme"));
7833    }
7834
7835    #[test]
7836    fn resolve_url_bridge_endpoint_false_merges_path() {
7837        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7838        cfg.bridge_endpoint = false;
7839        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
7840        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7841        assert!(url.contains("/foo"), "url should contain /foo: {url}");
7842        assert!(
7843            url.contains("dropme=1"),
7844            "url should contain dropme=1: {url}"
7845        );
7846    }
7847
7848    #[test]
7849    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
7850        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7851        cfg.bridge_endpoint = true;
7852        let mut exchange = Exchange::new(Message::default());
7853        exchange.input.set_header(
7854            "CamelHttpPath",
7855            serde_json::Value::String("/foo".to_string()),
7856        );
7857        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7858        assert_eq!(url, "http://x");
7859        assert!(!url.contains("/foo"));
7860    }
7861
7862    #[test]
7863    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
7864        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7865        cfg.bridge_endpoint = true;
7866        // query_params stays empty ([])
7867        let mut exchange = Exchange::new(Message::default());
7868        exchange.input.set_header(
7869            "CamelHttpUri",
7870            serde_json::Value::String("http://dest/explicit".to_string()),
7871        );
7872        exchange.input.set_header(
7873            "CamelHttpPath",
7874            serde_json::Value::String("/foo".to_string()),
7875        );
7876        exchange.input.set_header(
7877            "CamelHttpQuery",
7878            serde_json::Value::String("x=1".to_string()),
7879        );
7880        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7881        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
7882        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
7883        // wins verbatim.
7884        assert_eq!(url, "http://x");
7885    }
7886
7887    #[test]
7888    fn bridge_programmatic_params_use_percent20() {
7889        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7890        cfg.bridge_endpoint = true;
7891        cfg.query_params = vec![("b".to_string(), "x y".to_string())];
7892        let exchange = Exchange::new(Message::default());
7893
7894        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7895
7896        // `%20 never +` is global for programmatic values — the bridge arm
7897        // uses the same encoder as the non-bridge path. Bridging
7898        // semantics (what gets bridged, precedence) are unchanged.
7899        assert_eq!(url, "http://x?b=x%20y");
7900        assert!(!url.contains('+'));
7901    }
7902
7903    #[test]
7904    fn bridge_arm_carries_authored_raw_query() {
7905        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
7906        // bridgeEndpoint is consumed as an endpoint option; a=1 is the
7907        // authored leftover riding raw_query.
7908        let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
7909
7910        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7911
7912        // Authored leftovers ride under bridging (Apache Camel semantics):
7913        // query is a=1 in authored bytes; exchange path/query stay ignored.
7914        assert_eq!(url, "http://h/p?a=1");
7915        assert!(!url.contains("dropme"), "exchange query leaked: {url}");
7916        assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
7917    }
7918
7919    // -----------------------------------------------------------------------
7920    // Bridge arm verbatim assembly (Papal Direction A): the bridged base is
7921    // never round-tripped through `url::Url` normalization — authored bytes
7922    // end-to-end, identical assembly to every other resolve_url arm.
7923    // -----------------------------------------------------------------------
7924
7925    #[test]
7926    fn resolve_url_bridge_preserves_dot_segments() {
7927        let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
7928        cfg.bridge_endpoint = true;
7929        cfg.query_params.push(("k".to_string(), "1".to_string()));
7930        let exchange = Exchange::new(Message::default());
7931
7932        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7933
7934        // Dot segments are authored bytes; the old round-trip collapsed
7935        // them (`/a/../b` → `/b`). Verbatim keeps them.
7936        assert_eq!(url, "http://h/a/../b?k=1");
7937    }
7938
7939    #[test]
7940    fn resolve_url_bridge_preserves_default_port() {
7941        let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
7942        cfg.bridge_endpoint = true;
7943        cfg.query_params.push(("k".to_string(), "1".to_string()));
7944        let exchange = Exchange::new(Message::default());
7945
7946        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7947
7948        // The old round-trip stripped the default port `:80`. Verbatim
7949        // keeps it.
7950        assert_eq!(url, "http://h:80/p?k=1");
7951    }
7952
7953    #[test]
7954    fn resolve_url_bridge_preserves_scheme_and_host_case() {
7955        let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
7956        cfg.bridge_endpoint = true;
7957        cfg.query_params.push(("k".to_string(), "1".to_string()));
7958        // `from_uri`'s scheme validation is case-sensitive, so the scheme
7959        // case is applied on the stored base directly — the resolve path
7960        // must carry whatever bytes the operator authored.
7961        cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
7962        let exchange = Exchange::new(Message::default());
7963
7964        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7965
7966        // The old round-trip lowercased scheme and host. Verbatim keeps
7967        // both authored.
7968        assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
7969    }
7970
7971    #[test]
7972    fn resolve_url_bridge_no_query_emits_base_verbatim() {
7973        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
7974        cfg.bridge_endpoint = true;
7975        let exchange = Exchange::new(Message::default());
7976
7977        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7978
7979        // No resolved query: exactly the authored base — no synthetic `/`,
7980        // no dangling `?`.
7981        assert_eq!(url, "http://h/p");
7982    }
7983
7984    #[test]
7985    fn resolve_url_bridge_and_non_bridge_byte_identical() {
7986        // (a) Bridged arm: the effective query comes from programmatic
7987        // query_params.
7988        let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
7989        bridged.bridge_endpoint = true;
7990        bridged
7991            .query_params
7992            .push(("k".to_string(), "1".to_string()));
7993        let bridge_url =
7994            HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
7995
7996        // (b) Non-bridge CamelHttpQuery composition path: same effective
7997        // query riding the exchange header.
7998        let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
7999        let mut exchange = Exchange::new(Message::default());
8000        exchange.input.set_header(
8001            "CamelHttpQuery",
8002            serde_json::Value::String("k=1".to_string()),
8003        );
8004        let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
8005
8006        assert_eq!(bridge_url, plain_url);
8007        assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
8008    }
8009
8010    #[test]
8011    fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
8012        let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
8013        cfg.bridge_endpoint = true;
8014        cfg.query_params.push(("k".to_string(), "1".to_string()));
8015        let exchange = Exchange::new(Message::default());
8016
8017        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8018
8019        assert_eq!(url, "http://[::1]:8080/p?k=1");
8020    }
8021
8022    #[test]
8023    fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
8024        let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
8025        let exchange = Exchange::new(Message::default());
8026
8027        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8028
8029        // Authored query on an empty base path: the old round-trip
8030        // inserted a synthetic `/` (`http://h/?x=1`); verbatim does not.
8031        assert_eq!(url, "http://h?x=1");
8032    }
8033
8034    // -----------------------------------------------------------------------
8035    // Raw-preserving outbound query serialization (http-query-wire-fidelity)
8036    // -----------------------------------------------------------------------
8037
8038    #[test]
8039    fn resolve_url_preserves_authored_query_order_and_bytes() {
8040        let config =
8041            HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
8042        let exchange = Exchange::new(Message::default());
8043
8044        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8045
8046        // Authored order, authored separators, no %2C/%3A re-encoding,
8047        // consumed option (connectTimeout) removed.
8048        assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
8049    }
8050
8051    #[test]
8052    fn resolve_url_consumes_encoded_option_key() {
8053        let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
8054        let exchange = Exchange::new(Message::default());
8055
8056        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8057
8058        // The raw filter matches the decoded key, not the encoded bytes.
8059        assert_eq!(url, "http://h/p?a=1");
8060    }
8061
8062    #[test]
8063    fn resolve_url_all_options_consumed_drops_query() {
8064        let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
8065        let exchange = Exchange::new(Message::default());
8066
8067        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8068
8069        // A non-empty query whose every pair was consumed drops the query
8070        // component entirely — no dangling `?`.
8071        assert_eq!(url, "http://h/p");
8072        assert!(!url.contains('?'));
8073    }
8074
8075    #[test]
8076    fn resolve_url_preserves_empty_query_marker() {
8077        let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
8078        let exchange = Exchange::new(Message::default());
8079
8080        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8081
8082        // A bare `?` marker is preserved distinctly, never conflated with
8083        // an all-consumed query.
8084        assert_eq!(url, "http://h/p?");
8085    }
8086
8087    #[test]
8088    fn resolve_url_raw_wrapper_not_re_encoded() {
8089        let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
8090        let exchange = Exchange::new(Message::default());
8091
8092        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8093
8094        // RAW(...) wrapper bytes survive exactly as authored (rc-g4isv).
8095        assert_eq!(url, "http://h/p?token=RAW(abc)");
8096        assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
8097    }
8098
8099    #[test]
8100    fn resolve_url_camel_http_query_composes_verbatim_span() {
8101        let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
8102        let mut exchange = Exchange::new(Message::default());
8103        exchange.input.set_header(
8104            "CamelHttpQuery",
8105            serde_json::Value::String("userFilter=a%2Cb".to_string()),
8106        );
8107
8108        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8109
8110        // Policy change (ADR-0071): the header no longer replaces the
8111        // endpoint query — it composes, the endpoint winning collisions.
8112        // The header span bytes still ride verbatim: `a%2Cb` is carried
8113        // as-authored, never re-encoded (no %252C).
8114        assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
8115        assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
8116    }
8117
8118    // -----------------------------------------------------------------------
8119    // Outbound query composition (http-contract-surface, ADR-0071)
8120    // -----------------------------------------------------------------------
8121
8122    #[test]
8123    fn header_composes_with_endpoint_query() {
8124        let config =
8125            HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
8126        let mut exchange = Exchange::new(Message::default());
8127        exchange.input.set_header(
8128            "CamelHttpQuery",
8129            serde_json::Value::String("lang=es&page=2".to_string()),
8130        );
8131
8132        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8133
8134        // Higher precedence (endpoint) wins collisions: `lang` stays `en`;
8135        // the header appends only its absent keys.
8136        assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
8137    }
8138
8139    #[test]
8140    fn header_alone_still_rides() {
8141        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8142        let mut exchange = Exchange::new(Message::default());
8143        exchange.input.set_header(
8144            "CamelHttpQuery",
8145            serde_json::Value::String("page=2".to_string()),
8146        );
8147
8148        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8149
8150        // No endpoint query: the header pairs are the whole query.
8151        assert_eq!(url, "http://upstream/api?page=2");
8152    }
8153
8154    #[test]
8155    fn empty_reflected_query_leaves_endpoint_query_intact() {
8156        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8157        let mut exchange = Exchange::new(Message::default());
8158        // The consumer installs an empty CamelHttpQuery on requests that
8159        // arrived without a query string.
8160        exchange
8161            .input
8162            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8163
8164        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8165
8166        // No second `?` marker, no dropped endpoint pair.
8167        assert_eq!(url, "http://upstream/api?apiKey=secret");
8168        assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
8169    }
8170
8171    #[test]
8172    fn forbidden_byte_in_header_query_errors() {
8173        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8174        let mut exchange = Exchange::new(Message::default());
8175        exchange.input.set_header(
8176            "CamelHttpQuery",
8177            serde_json::Value::String("q=ab<cd".to_string()),
8178        );
8179
8180        let err = HttpProducer::resolve_url(&exchange, &config)
8181            .unwrap_err()
8182            .to_string();
8183
8184        // Fail loud naming the forbidden byte (`<` = 0x3C); a resolve
8185        // error means no URL is emitted, never a re-encoded one.
8186        assert!(err.contains("0x3C"), "error must name the byte: {err}");
8187    }
8188
8189    #[test]
8190    fn override_uri_with_query_plus_header_query() {
8191        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8192        let mut exchange = Exchange::new(Message::default());
8193        exchange.input.set_header(
8194            "CamelHttpUri",
8195            serde_json::Value::String("http://host/api?a=1".to_string()),
8196        );
8197        exchange.input.set_header(
8198            "CamelHttpQuery",
8199            serde_json::Value::String("a=2&b=3".to_string()),
8200        );
8201
8202        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8203
8204        // Pair-level merge with a single `?`: the override's `a=1` wins
8205        // the collision, the header appends `b=3` — no `?a=1?a=2` concat.
8206        assert_eq!(url, "http://host/api?a=1&b=3");
8207    }
8208
8209    #[test]
8210    fn path_applies_before_query_composition() {
8211        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8212        let mut exchange = Exchange::new(Message::default());
8213        exchange.input.set_header(
8214            "CamelHttpUri",
8215            serde_json::Value::String("http://host/api?a=1".to_string()),
8216        );
8217        exchange.input.set_header(
8218            "CamelHttpPath",
8219            serde_json::Value::String("/extra".to_string()),
8220        );
8221        exchange.input.set_header(
8222            "CamelHttpQuery",
8223            serde_json::Value::String("b=2".to_string()),
8224        );
8225
8226        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8227
8228        // CamelHttpPath applies to the override base without its query,
8229        // then the query composes.
8230        assert_eq!(url, "http://host/api/extra?a=1&b=2");
8231    }
8232
8233    #[test]
8234    fn plain_proxy_reflection_composes() {
8235        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8236        // Headers as the consumer installs them from the wire.
8237        let exchange = exchange_with_path_and_query("/in/extra", "page=2");
8238
8239        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8240
8241        // Reflection rides by default and composes: the operator pair is
8242        // not replaced (rc-k3pir parity).
8243        assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
8244    }
8245
8246    #[test]
8247    fn bridge_endpoint_ignores_url_headers() {
8248        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8249        let mut exchange = Exchange::new(Message::default());
8250        exchange.input.set_header(
8251            "CamelHttpUri",
8252            serde_json::Value::String("http://evil.test/x".to_string()),
8253        );
8254        exchange.input.set_header(
8255            "CamelHttpPath",
8256            serde_json::Value::String("/foo".to_string()),
8257        );
8258        exchange.input.set_header(
8259            "CamelHttpQuery",
8260            serde_json::Value::String("z=9".to_string()),
8261        );
8262
8263        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8264
8265        // All three URL headers ignored; the endpoint base plus its own
8266        // (consumed-option-filtered) query is sent, exactly as before.
8267        assert_eq!(url, "http://h/p?a=1");
8268        assert!(!url.contains("evil"), "override leaked: {url}");
8269        assert!(!url.contains("z=9"), "header query leaked: {url}");
8270        assert!(!url.contains("/foo"), "header path leaked: {url}");
8271    }
8272
8273    #[test]
8274    fn resolve_url_programmatic_params_use_percent20_deterministic() {
8275        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8276        config.query_params = vec![
8277            ("b".to_string(), "x y".to_string()),
8278            ("a".to_string(), "1".to_string()),
8279        ];
8280        let exchange = Exchange::new(Message::default());
8281
8282        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8283
8284        // Declaration order (not lexical), minimal RFC-3986 encoding,
8285        // `%20` — never `+` — for spaces.
8286        assert_eq!(url, "http://h/p?b=x%20y&a=1");
8287        assert!(!url.contains('+'));
8288    }
8289
8290    #[test]
8291    fn resolve_url_authored_and_programmatic_merge() {
8292        let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
8293        config.query_params = vec![
8294            ("b".to_string(), "2".to_string()),
8295            ("a".to_string(), "9".to_string()),
8296        ];
8297        let exchange = Exchange::new(Message::default());
8298
8299        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8300
8301        // Programmatic `b` appended (absent from raw); programmatic `a=9`
8302        // ignored (authored key wins); no duplication.
8303        assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
8304    }
8305
8306    #[test]
8307    fn from_uri_no_longer_fills_query_params_from_uri() {
8308        let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
8309
8310        // Authored pairs live in raw_query ONLY (provenance pin).
8311        assert!(
8312            config.query_params.is_empty(),
8313            "query_params is programmatic-only: {:?}",
8314            config.query_params
8315        );
8316        assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
8317    }
8318
8319    #[test]
8320    fn resolve_url_forbidden_raw_byte_errors() {
8321        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8322        config.raw_query = Some("a=x y".to_string());
8323        let exchange = Exchange::new(Message::default());
8324
8325        let err = HttpProducer::resolve_url(&exchange, &config)
8326            .expect_err("literal space in raw query must error");
8327
8328        // The error names the forbidden byte; no output string is produced.
8329        assert!(
8330            err.to_string().contains("0x20"),
8331            "error must name the forbidden byte: {err}"
8332        );
8333    }
8334
8335    #[test]
8336    fn armed_fence_rejects_unknown_host_redacted() {
8337        let cfg = HttpEndpointConfig::from_uri(
8338            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8339        )
8340        .unwrap();
8341        let mut exchange = Exchange::new(Message::default());
8342        exchange.input.set_header(
8343            "CamelHttpUri",
8344            serde_json::Value::String(
8345                "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
8346            ),
8347        );
8348
8349        let err = HttpProducer::resolve_url(&exchange, &cfg)
8350            .expect_err("override host outside the fence must fail resolution");
8351
8352        let message = err.to_string();
8353        assert!(!message.contains("pass"), "userinfo leaked: {message}");
8354        assert!(!message.contains("s3cret"), "query leaked: {message}");
8355    }
8356
8357    #[test]
8358    fn armed_fence_allows_listed_host() {
8359        let cfg = HttpEndpointConfig::from_uri(
8360            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8361        )
8362        .unwrap();
8363        let mut exchange = Exchange::new(Message::default());
8364        exchange.input.set_header(
8365            "CamelHttpUri",
8366            serde_json::Value::String("http://cdn.example.com/x".to_string()),
8367        );
8368
8369        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8370        assert_eq!(url, "http://cdn.example.com/x");
8371    }
8372
8373    #[test]
8374    fn host_only_entry_permits_any_port() {
8375        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
8376        let mut exchange = Exchange::new(Message::default());
8377        exchange.input.set_header(
8378            "CamelHttpUri",
8379            serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
8380        );
8381
8382        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8383        assert_eq!(url, "http://cdn.example.com:9443/x");
8384    }
8385
8386    #[test]
8387    fn unarmed_endpoint_unchanged() {
8388        let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8389        let mut exchange = Exchange::new(Message::default());
8390        exchange.input.set_header(
8391            "CamelHttpUri",
8392            serde_json::Value::String("http://any.example.com/path".to_string()),
8393        );
8394
8395        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8396        assert_eq!(url, "http://any.example.com/path");
8397    }
8398
8399    #[test]
8400    fn empty_allowlist_fails_endpoint_creation() {
8401        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
8402    }
8403
8404    #[test]
8405    fn malformed_entry_fails_endpoint_creation() {
8406        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
8407    }
8408
8409    #[test]
8410    fn fence_entry_with_path_fails_creation() {
8411        // A trailing path is a typo'd entry: silently narrowing it to the
8412        // hostname would widen or skew the fence. Reject loudly.
8413        assert!(
8414            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
8415        );
8416    }
8417
8418    #[test]
8419    fn fence_entry_with_userinfo_fails_creation() {
8420        assert!(
8421            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
8422        );
8423    }
8424
8425    #[test]
8426    fn ipv6_fence_entry_allows_bracketed_host() {
8427        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
8428        // The textual host forms differ; both parse to the same bracketed
8429        // canonical host (`[::1]`) that the entry stores, so both ride.
8430        for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
8431            let mut exchange = Exchange::new(Message::default());
8432            exchange
8433                .input
8434                .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
8435            let url = HttpProducer::resolve_url(&exchange, &cfg)
8436                .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
8437            assert_eq!(url, uri, "bracketed IPv6 override not honored");
8438        }
8439    }
8440
8441    #[test]
8442    fn dns_case_insensitive_fence_match() {
8443        // The entry is stored ASCII-lowercased, so the mixed-case option
8444        // matches the lowercase override host.
8445        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
8446        let mut exchange = Exchange::new(Message::default());
8447        exchange.input.set_header(
8448            "CamelHttpUri",
8449            serde_json::Value::String("http://cdn.example.com/x".to_string()),
8450        );
8451        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8452        assert_eq!(url, "http://cdn.example.com/x");
8453    }
8454
8455    #[test]
8456    fn fence_allowed_override_query_merges_with_header() {
8457        // Fence pass plus full composition: the override URI query is the
8458        // higher-precedence source, the header pair appends.
8459        let cfg =
8460            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
8461        let mut exchange = Exchange::new(Message::default());
8462        exchange.input.set_header(
8463            "CamelHttpUri",
8464            serde_json::Value::String("http://host.example/api?a=1".to_string()),
8465        );
8466        exchange.input.set_header(
8467            "CamelHttpQuery",
8468            serde_json::Value::String("b=2".to_string()),
8469        );
8470
8471        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8472        assert_eq!(url, "http://host.example/api?a=1&b=2");
8473    }
8474
8475    #[test]
8476    fn empty_header_with_armed_fence_leaves_no_query() {
8477        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
8478        let mut exchange = Exchange::new(Message::default());
8479        exchange.input.set_header(
8480            "CamelHttpUri",
8481            serde_json::Value::String("http://host.example/api".to_string()),
8482        );
8483        exchange
8484            .input
8485            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8486
8487        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8488        assert_eq!(url, "http://host.example/api");
8489        assert!(!url.contains('?'), "query marker leaked: {url}");
8490    }
8491
8492    #[test]
8493    fn fence_option_is_consumed() {
8494        // A raw query on the base URI plus the fence option; no override
8495        // header. The option is consumed at parse time and must never
8496        // appear in the outbound query.
8497        let cfg =
8498            HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
8499        let exchange = Exchange::new(Message::default());
8500
8501        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8502        assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
8503        assert!(url.contains("x=1"), "authored query lost: {url}");
8504    }
8505
8506    #[tokio::test]
8507    async fn resolve_url_malformed_base_url_errors_no_panic() {
8508        use tower::ServiceExt;
8509
8510        let (url, _handle) = start_test_server().await;
8511        let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
8512        config.allow_internal = true; // test server binds 127.0.0.1
8513        let producer = HttpProducer {
8514            config: Arc::new(config),
8515            client: build_client(&HttpConfig::default(), None),
8516            pinned_cache: Arc::new(PinnedClientCache::new(
8517                PINNED_CLIENT_TTL,
8518                PINNED_CLIENT_MAX_ENTRIES,
8519            )),
8520            http_config: Arc::new(HttpConfig::default()),
8521            runtime: rt(),
8522        };
8523
8524        // First call: malformed base URL propagates as an error through the
8525        // real producer path — no panic, no poisoned state (rc-ph7z2).
8526        let first = producer
8527            .clone()
8528            .oneshot(Exchange::new(Message::default()))
8529            .await;
8530        let err = first.expect_err("malformed base URL must error, not panic");
8531        assert!(
8532            err.to_string().to_lowercase().contains("url"),
8533            "error must name the malformed URL: {err}"
8534        );
8535
8536        // Second call through the SAME producer succeeds — the failure
8537        // left no poisoned state.
8538        let mut exchange = Exchange::new(Message::default());
8539        exchange.input.set_header(
8540            "CamelHttpUri",
8541            serde_json::Value::String(format!("{url}/api")),
8542        );
8543        let response = producer
8544            .oneshot(exchange)
8545            .await
8546            .expect("valid request through same producer must succeed");
8547        let status = response
8548            .input
8549            .header("CamelHttpResponseCode")
8550            .and_then(|v| v.as_u64())
8551            .unwrap();
8552        assert_eq!(status, 200);
8553    }
8554
8555    #[test]
8556    fn resolve_url_bridge_malformed_base_errors_no_panic() {
8557        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8558        cfg.bridge_endpoint = true;
8559        cfg.query_params.push(("k".to_string(), "1".to_string()));
8560        // `from_uri` rejects the malformed authority, so the base is set on
8561        // the stored config directly (same build shape as the scheme-case
8562        // test). The bridge arm's validation-only parse (rc-ph7z2) must
8563        // surface it as an error — no panic.
8564        cfg.base_url = "http://[::1:bad".to_string();
8565        let exchange = Exchange::new(Message::default());
8566
8567        let err = HttpProducer::resolve_url(&exchange, &cfg)
8568            .expect_err("malformed bridge base URL must error");
8569        assert!(
8570            err.to_string().contains("invalid base URL"),
8571            "error must name the invalid base URL: {err}"
8572        );
8573    }
8574
8575    #[test]
8576    fn test_http_producer_helpers_status_and_size_boundaries() {
8577        assert!(HttpProducer::is_ok_status(200, (200, 299)));
8578        assert!(HttpProducer::is_ok_status(299, (200, 299)));
8579        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
8580        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
8581
8582        assert!(!exceeds_max_response_body(10, 10));
8583        assert!(exceeds_max_response_body(11, 10));
8584    }
8585
8586    // -----------------------------------------------------------------------
8587    // Content-Type inference tests
8588    // -----------------------------------------------------------------------
8589
8590    async fn setup_consumer_on_free_port(
8591        path: &str,
8592    ) -> (
8593        u16,
8594        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
8595        tokio_util::sync::CancellationToken,
8596    ) {
8597        use camel_component_api::ConsumerContext;
8598
8599        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8600        let port = listener.local_addr().unwrap().port();
8601        drop(listener);
8602
8603        let consumer_cfg = HttpServerConfig {
8604            scheme: "http".to_string(),
8605            host: "127.0.0.1".to_string(),
8606            port,
8607            path: path.to_string(),
8608            max_request_body: 2 * 1024 * 1024,
8609            max_response_body: 10 * 1024 * 1024,
8610            max_inflight_requests: 1024,
8611            method: None,
8612            tls_config: None,
8613        };
8614        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8615
8616        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8617        let token = tokio_util::sync::CancellationToken::new();
8618        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8619
8620        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8621        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8622
8623        (port, rx, token)
8624    }
8625
8626    #[tokio::test]
8627    async fn test_content_type_inferred_for_json_body() {
8628        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
8629
8630        let client = reqwest::Client::new();
8631        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
8632
8633        let (http_result, _) = tokio::join!(send_fut, async {
8634            if let Some(mut envelope) = rx.recv().await {
8635                envelope.exchange.input.body =
8636                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
8637                if let Some(reply_tx) = envelope.reply_tx {
8638                    let _ = reply_tx.send(Ok(envelope.exchange));
8639                }
8640            }
8641        });
8642
8643        let resp = http_result.unwrap();
8644        assert_eq!(resp.status().as_u16(), 200);
8645        let ct = resp
8646            .headers()
8647            .get("content-type")
8648            .expect("Content-Type header should be present");
8649        assert_eq!(ct, "application/json");
8650        let body = resp.text().await.unwrap();
8651        assert_eq!(body, r#"{"message":"hello"}"#);
8652
8653        token.cancel();
8654    }
8655
8656    #[tokio::test]
8657    async fn test_content_type_inferred_for_text_body() {
8658        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
8659
8660        let client = reqwest::Client::new();
8661        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
8662
8663        let (http_result, _) = tokio::join!(send_fut, async {
8664            if let Some(mut envelope) = rx.recv().await {
8665                envelope.exchange.input.body =
8666                    camel_component_api::Body::Text("plain text response".to_string());
8667                if let Some(reply_tx) = envelope.reply_tx {
8668                    let _ = reply_tx.send(Ok(envelope.exchange));
8669                }
8670            }
8671        });
8672
8673        let resp = http_result.unwrap();
8674        assert_eq!(resp.status().as_u16(), 200);
8675        let ct = resp
8676            .headers()
8677            .get("content-type")
8678            .expect("Content-Type header should be present");
8679        assert_eq!(ct, "text/plain; charset=utf-8");
8680        let body = resp.text().await.unwrap();
8681        assert_eq!(body, "plain text response");
8682
8683        token.cancel();
8684    }
8685
8686    #[tokio::test]
8687    async fn test_content_type_inferred_for_xml_body() {
8688        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
8689
8690        let client = reqwest::Client::new();
8691        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
8692
8693        let (http_result, _) = tokio::join!(send_fut, async {
8694            if let Some(mut envelope) = rx.recv().await {
8695                envelope.exchange.input.body =
8696                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
8697                if let Some(reply_tx) = envelope.reply_tx {
8698                    let _ = reply_tx.send(Ok(envelope.exchange));
8699                }
8700            }
8701        });
8702
8703        let resp = http_result.unwrap();
8704        assert_eq!(resp.status().as_u16(), 200);
8705        let ct = resp
8706            .headers()
8707            .get("content-type")
8708            .expect("Content-Type header should be present");
8709        assert_eq!(ct, "application/xml");
8710        let body = resp.text().await.unwrap();
8711        assert_eq!(body, "<root><item>value</item></root>");
8712
8713        token.cancel();
8714    }
8715
8716    #[tokio::test]
8717    async fn test_no_content_type_for_empty_body() {
8718        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
8719
8720        let client = reqwest::Client::new();
8721        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
8722
8723        let (http_result, _) = tokio::join!(send_fut, async {
8724            if let Some(mut envelope) = rx.recv().await {
8725                envelope.exchange.input.body = camel_component_api::Body::Empty;
8726                if let Some(reply_tx) = envelope.reply_tx {
8727                    let _ = reply_tx.send(Ok(envelope.exchange));
8728                }
8729            }
8730        });
8731
8732        let resp = http_result.unwrap();
8733        assert_eq!(resp.status().as_u16(), 200);
8734        assert!(
8735            resp.headers().get("content-type").is_none(),
8736            "Empty body should not set Content-Type"
8737        );
8738
8739        token.cancel();
8740    }
8741
8742    #[tokio::test]
8743    async fn test_no_content_type_for_raw_bytes_body() {
8744        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
8745
8746        let client = reqwest::Client::new();
8747        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
8748
8749        let (http_result, _) = tokio::join!(send_fut, async {
8750            if let Some(mut envelope) = rx.recv().await {
8751                envelope.exchange.input.body =
8752                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
8753                if let Some(reply_tx) = envelope.reply_tx {
8754                    let _ = reply_tx.send(Ok(envelope.exchange));
8755                }
8756            }
8757        });
8758
8759        let resp = http_result.unwrap();
8760        assert_eq!(resp.status().as_u16(), 200);
8761        assert!(
8762            resp.headers().get("content-type").is_none(),
8763            "Raw Bytes body should not set Content-Type"
8764        );
8765
8766        token.cancel();
8767    }
8768
8769    #[tokio::test]
8770    async fn test_content_type_from_stream_metadata() {
8771        use camel_component_api::{StreamBody, StreamMetadata};
8772        use futures::stream;
8773
8774        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
8775
8776        let client = reqwest::Client::new();
8777        let send_fut = client
8778            .get(format!("http://127.0.0.1:{port}/stream-ct"))
8779            .send();
8780
8781        let (http_result, _) = tokio::join!(send_fut, async {
8782            if let Some(mut envelope) = rx.recv().await {
8783                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
8784                    vec![Ok(bytes::Bytes::from("audio data"))];
8785                let stream = Box::pin(stream::iter(chunks));
8786                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
8787                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
8788                    metadata: StreamMetadata {
8789                        size_hint: None,
8790                        content_type: Some("audio/mpeg".to_string()),
8791                        origin: None,
8792                    },
8793                });
8794                if let Some(reply_tx) = envelope.reply_tx {
8795                    let _ = reply_tx.send(Ok(envelope.exchange));
8796                }
8797            }
8798        });
8799
8800        let resp = http_result.unwrap();
8801        assert_eq!(resp.status().as_u16(), 200);
8802        let ct = resp
8803            .headers()
8804            .get("content-type")
8805            .expect("Content-Type header should be present");
8806        assert_eq!(ct, "audio/mpeg");
8807        let body = resp.text().await.unwrap();
8808        assert_eq!(body, "audio data");
8809
8810        token.cancel();
8811    }
8812
8813    #[tokio::test]
8814    async fn test_user_content_type_overrides_inferred() {
8815        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
8816
8817        let client = reqwest::Client::new();
8818        let send_fut = client
8819            .get(format!("http://127.0.0.1:{port}/override-ct"))
8820            .send();
8821
8822        let (http_result, _) = tokio::join!(send_fut, async {
8823            if let Some(mut envelope) = rx.recv().await {
8824                envelope.exchange.input.body =
8825                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
8826                envelope.exchange.input.set_header(
8827                    "Content-Type",
8828                    serde_json::Value::String("text/html".to_string()),
8829                );
8830                if let Some(reply_tx) = envelope.reply_tx {
8831                    let _ = reply_tx.send(Ok(envelope.exchange));
8832                }
8833            }
8834        });
8835
8836        let resp = http_result.unwrap();
8837        assert_eq!(resp.status().as_u16(), 200);
8838        let ct = resp
8839            .headers()
8840            .get("content-type")
8841            .expect("Content-Type header should be present");
8842        assert_eq!(
8843            ct, "text/html",
8844            "User-set Content-Type should take precedence over inferred type"
8845        );
8846
8847        token.cancel();
8848    }
8849
8850    #[tokio::test]
8851    async fn test_user_content_type_with_bytes_body() {
8852        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
8853
8854        let client = reqwest::Client::new();
8855        let send_fut = client
8856            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
8857            .send();
8858
8859        let (http_result, _) = tokio::join!(send_fut, async {
8860            if let Some(mut envelope) = rx.recv().await {
8861                envelope.exchange.input.body =
8862                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
8863                envelope.exchange.input.set_header(
8864                    "Content-Type",
8865                    serde_json::Value::String("application/json".to_string()),
8866                );
8867                if let Some(reply_tx) = envelope.reply_tx {
8868                    let _ = reply_tx.send(Ok(envelope.exchange));
8869                }
8870            }
8871        });
8872
8873        let resp = http_result.unwrap();
8874        assert_eq!(resp.status().as_u16(), 200);
8875        let ct = resp
8876            .headers()
8877            .get("content-type")
8878            .expect("Content-Type header should be present for Bytes body with user header");
8879        assert_eq!(
8880            ct, "application/json",
8881            "User Content-Type should be sent for Bytes body"
8882        );
8883
8884        token.cancel();
8885    }
8886
8887    // -----------------------------------------------------------------------
8888    // Server monitor tests (GRL-005)
8889    // -----------------------------------------------------------------------
8890
8891    #[tokio::test]
8892    async fn monitor_task_silent_on_clean_exit() {
8893        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
8894        // Clean exit should complete without panicking or logging errors
8895        monitor_axum_task(
8896            handle,
8897            "127.0.0.1:0".to_string(),
8898            noop_rt(),
8899            "test-monitor".into(),
8900        )
8901        .await;
8902    }
8903
8904    #[tokio::test]
8905    async fn monitor_task_handles_panicked_task() {
8906        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
8907            panic!("simulated server crash");
8908        });
8909        // Should complete without panicking even though the inner task panicked
8910        monitor_axum_task(
8911            handle,
8912            "127.0.0.1:9999".to_string(),
8913            noop_rt(),
8914            "test-monitor".into(),
8915        )
8916        .await;
8917    }
8918
8919    // -----------------------------------------------------------------------
8920    // Credential redaction tests
8921    // -----------------------------------------------------------------------
8922
8923    #[test]
8924    fn http_auth_basic_debug_redacts_password() {
8925        let auth = HttpAuth::Basic {
8926            username: "admin".to_string(),
8927            password: "hunter2".to_string(),
8928        };
8929        let debug = format!("{:?}", auth);
8930        assert!(
8931            !debug.contains("hunter2"),
8932            "password must be redacted: {debug}"
8933        );
8934        assert!(debug.contains("admin"), "username should appear: {debug}");
8935    }
8936
8937    #[test]
8938    fn http_auth_bearer_debug_redacts_token() {
8939        let auth = HttpAuth::Bearer {
8940            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
8941        };
8942        let debug = format!("{:?}", auth);
8943        assert!(
8944            !debug.contains("eyJhbGci"),
8945            "token must be redacted: {debug}"
8946        );
8947    }
8948
8949    #[test]
8950    fn http_auth_none_debug_shows_variant() {
8951        let debug = format!("{:?}", HttpAuth::None);
8952        assert!(
8953            debug.contains("None"),
8954            "None variant should appear: {debug}"
8955        );
8956    }
8957
8958    #[test]
8959    fn http_endpoint_config_debug_redacts_auth_credentials() {
8960        let config = HttpEndpointConfig::from_uri(
8961            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
8962        )
8963        .unwrap();
8964        let debug = format!("{:?}", config);
8965        assert!(
8966            !debug.contains("secret123"),
8967            "password must be redacted in HttpEndpointConfig debug: {debug}"
8968        );
8969    }
8970
8971    #[test]
8972    fn debug_lists_all_public_fields() {
8973        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8974        let debug = format!("{:?}", config);
8975        for field in [
8976            "base_url",
8977            "http_method",
8978            "throw_exception_on_failure",
8979            "ok_status_code_range",
8980            "response_timeout",
8981            "query_params",
8982            "raw_query",
8983            "allow_internal",
8984            "blocked_hosts",
8985            "max_body_size",
8986            "read_timeout_ms",
8987            "max_response_bytes",
8988            "auth",
8989            "token_provider",
8990            "user_agent",
8991            "bridge_endpoint",
8992            "connection_close",
8993            "skip_request_headers",
8994            "skip_response_headers",
8995            "follow_redirects",
8996            "max_redirects",
8997        ] {
8998            assert!(
8999                debug.contains(field),
9000                "Debug output missing field '{field}': {debug}"
9001            );
9002        }
9003    }
9004
9005    // -----------------------------------------------------------------------
9006    // Static file serving tests (Task 5)
9007    // -----------------------------------------------------------------------
9008
9009    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
9010    use tower_http::services::ServeDir;
9011
9012    fn make_test_registry() -> HttpRouteRegistry {
9013        HttpRouteRegistry::new()
9014    }
9015
9016    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
9017        AppState {
9018            registry,
9019            max_request_body: 2 * 1024 * 1024,
9020            max_response_body: 10 * 1024 * 1024,
9021            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
9022        }
9023    }
9024
9025    #[allow(clippy::await_holding_lock)]
9026    #[tokio::test]
9027    async fn test_static_file_serving_serves_file_contents() {
9028        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9029        ServerRegistry::reset();
9030
9031        // Create temp dir with test files
9032        let temp_dir =
9033            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
9034        std::fs::create_dir_all(&temp_dir).unwrap();
9035        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
9036        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
9037
9038        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9039
9040        let registry = make_test_registry();
9041        let serve_dir = ServeDir::new(&canonical_dir)
9042            .precompressed_gzip()
9043            .precompressed_br()
9044            .append_index_html_on_directories(true);
9045
9046        let mount = StaticMount {
9047            mount_path: "/".to_string(),
9048            mode: MountMode::Static,
9049            dir: canonical_dir.clone(),
9050            cache_control: "public, max-age=3600".to_string(),
9051            error_pages: std::collections::HashMap::new(),
9052            serve_dir,
9053        };
9054        registry.register_static_mount(mount).await.unwrap();
9055
9056        let state = make_test_state(registry);
9057
9058        // Test serving hello.txt
9059        let req = Request::builder()
9060            .uri("/hello.txt")
9061            .body(AxumBody::empty())
9062            .unwrap();
9063        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
9064        assert_eq!(resp.status(), StatusCode::OK);
9065        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9066            .await
9067            .unwrap();
9068        assert_eq!(&body[..], b"Hello, static world!");
9069
9070        // Test serving style.css
9071        let req = Request::builder()
9072            .uri("/style.css")
9073            .body(AxumBody::empty())
9074            .unwrap();
9075        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9076        assert_eq!(resp.status(), StatusCode::OK);
9077        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9078            .await
9079            .unwrap();
9080        assert_eq!(&body[..], b"body { color: red; }");
9081
9082        // Test 404 for non-existent file
9083        let req = Request::builder()
9084            .uri("/missing.txt")
9085            .body(AxumBody::empty())
9086            .unwrap();
9087        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
9088        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9089
9090        // Cleanup
9091        std::fs::remove_dir_all(&temp_dir).ok();
9092    }
9093
9094    #[allow(clippy::await_holding_lock)]
9095    #[tokio::test]
9096    async fn test_spa_fallback_serves_index_for_unknown_paths() {
9097        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9098        ServerRegistry::reset();
9099
9100        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
9101        std::fs::create_dir_all(&temp_dir).unwrap();
9102        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
9103        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
9104
9105        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9106
9107        let registry = make_test_registry();
9108        let serve_dir = ServeDir::new(&canonical_dir)
9109            .precompressed_gzip()
9110            .precompressed_br()
9111            .append_index_html_on_directories(true);
9112
9113        let mount = StaticMount {
9114            mount_path: "/".to_string(),
9115            mode: MountMode::Spa,
9116            dir: canonical_dir.clone(),
9117            cache_control: "public, max-age=0".to_string(),
9118            error_pages: std::collections::HashMap::new(),
9119            serve_dir,
9120        };
9121        // Register as SPA mount
9122        registry.register_static_mount(mount).await.unwrap();
9123
9124        let state = make_test_state(registry);
9125
9126        // SPA fallback: GET /dashboard with Accept: text/html → index.html
9127        let req = Request::builder()
9128            .method("GET")
9129            .uri("/dashboard")
9130            .header("Accept", "text/html")
9131            .body(AxumBody::empty())
9132            .unwrap();
9133        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
9134        assert_eq!(resp.status(), StatusCode::OK);
9135        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9136            .await
9137            .unwrap();
9138        assert_eq!(&body[..], b"<h1>SPA App</h1>");
9139
9140        // Static file still works: GET /app.js
9141        let req = Request::builder()
9142            .method("GET")
9143            .uri("/app.js")
9144            .body(AxumBody::empty())
9145            .unwrap();
9146        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
9147        assert_eq!(resp.status(), StatusCode::OK);
9148        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9149            .await
9150            .unwrap();
9151        assert_eq!(&body[..], b"console.log('app')");
9152
9153        // No SPA fallback for JSON accept → 404
9154        let req = Request::builder()
9155            .method("GET")
9156            .uri("/api/data")
9157            .header("Accept", "application/json")
9158            .body(AxumBody::empty())
9159            .unwrap();
9160        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
9161        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9162
9163        // No SPA fallback for file extensions → 404
9164        let req = Request::builder()
9165            .method("GET")
9166            .uri("/style.css")
9167            .header("Accept", "text/html")
9168            .body(AxumBody::empty())
9169            .unwrap();
9170        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9171        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9172
9173        // Cleanup
9174        std::fs::remove_dir_all(&temp_dir).ok();
9175    }
9176
9177    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
9178    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
9179    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
9180    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
9181    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
9182    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
9183    #[allow(clippy::await_holding_lock)]
9184    async fn run_conditional_get_returns_304(mode: MountMode) {
9185        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9186        ServerRegistry::reset();
9187
9188        let temp_dir = std::env::temp_dir().join(format!(
9189            "http_cond_get_{}_{}",
9190            if mode == MountMode::Spa {
9191                "spa"
9192            } else {
9193                "static"
9194            },
9195            std::process::id()
9196        ));
9197        std::fs::create_dir_all(&temp_dir).unwrap();
9198        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9199
9200        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9201
9202        let registry = make_test_registry();
9203        let serve_dir = ServeDir::new(&canonical_dir)
9204            .precompressed_gzip()
9205            .precompressed_br()
9206            .append_index_html_on_directories(true);
9207
9208        let mount = StaticMount {
9209            mount_path: "/".to_string(),
9210            mode,
9211            dir: canonical_dir.clone(),
9212            cache_control: "public, max-age=3600".to_string(),
9213            error_pages: std::collections::HashMap::new(),
9214            serve_dir,
9215        };
9216        registry.register_static_mount(mount).await.unwrap();
9217
9218        let state = make_test_state(registry);
9219
9220        // 1st request: normal GET → 200, capture validators.
9221        let req = Request::builder()
9222            .method("GET")
9223            .uri("/index.html")
9224            .body(AxumBody::empty())
9225            .unwrap();
9226        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9227        assert_eq!(
9228            resp.status(),
9229            StatusCode::OK,
9230            "first GET should return 200, got {}",
9231            resp.status()
9232        );
9233        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
9234        assert!(
9235            resp.headers().contains_key(http::header::CACHE_CONTROL),
9236            "200 response missing Cache-Control"
9237        );
9238        let etag = resp
9239            .headers()
9240            .get(http::header::ETAG)
9241            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
9242            .clone();
9243        let last_modified = resp
9244            .headers()
9245            .get(http::header::LAST_MODIFIED)
9246            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
9247            .clone();
9248        // Consume the body so the response is fully drained.
9249        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
9250            .await
9251            .unwrap();
9252
9253        // 2nd request: If-None-Match with the captured ETag → 304.
9254        // Unconditional: ETag presence is required (asserted above) so this
9255        // sub-test cannot silently skip on a ServeDir etag_method change.
9256        let req = Request::builder()
9257            .method("GET")
9258            .uri("/index.html")
9259            .header(http::header::IF_NONE_MATCH, etag.clone())
9260            .body(AxumBody::empty())
9261            .unwrap();
9262        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9263        assert_eq!(
9264            resp.status(),
9265            StatusCode::NOT_MODIFIED,
9266            "If-None-Match with matching ETag should return 304, got {}",
9267            resp.status()
9268        );
9269        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
9270        assert!(
9271            resp.headers().contains_key(http::header::CACHE_CONTROL),
9272            "304 (If-None-Match) missing Cache-Control"
9273        );
9274        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
9275        // response parts rebuild in serve_via_serve_dir preserves them.
9276        assert_eq!(
9277            resp.headers().get(http::header::ETAG),
9278            Some(&etag),
9279            "304 (If-None-Match) must echo the ETag validator"
9280        );
9281        assert_eq!(
9282            resp.headers().get(http::header::LAST_MODIFIED),
9283            Some(&last_modified),
9284            "304 (If-None-Match) must carry Last-Modified"
9285        );
9286
9287        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
9288        let req = Request::builder()
9289            .method("GET")
9290            .uri("/index.html")
9291            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
9292            .body(AxumBody::empty())
9293            .unwrap();
9294        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9295        assert_eq!(
9296            resp.status(),
9297            StatusCode::NOT_MODIFIED,
9298            "If-Modified-Since with matching timestamp should return 304, got {}",
9299            resp.status()
9300        );
9301        assert!(
9302            resp.headers().contains_key(http::header::CACHE_CONTROL),
9303            "304 (If-Modified-Since) missing Cache-Control"
9304        );
9305        assert_eq!(
9306            resp.headers().get(http::header::ETAG),
9307            Some(&etag),
9308            "304 (If-Modified-Since) must carry the ETag validator"
9309        );
9310        assert_eq!(
9311            resp.headers().get(http::header::LAST_MODIFIED),
9312            Some(&last_modified),
9313            "304 (If-Modified-Since) must echo Last-Modified"
9314        );
9315
9316        // Negative control: a PAST If-Modified-Since (before the file's mtime)
9317        // MUST return 200 — proving the 304 path is validator-aware, not a
9318        // blanket "always 304" regression. A future date would correctly yield
9319        // 304 since the file's mtime precedes it; that is RFC-correct 304
9320        // behaviour, not a negative control.
9321        let req = Request::builder()
9322            .method("GET")
9323            .uri("/index.html")
9324            .header(
9325                http::header::IF_MODIFIED_SINCE,
9326                "Wed, 21 Oct 2000 07:28:00 GMT",
9327            )
9328            .body(AxumBody::empty())
9329            .unwrap();
9330        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9331        assert_eq!(
9332            resp.status(),
9333            StatusCode::OK,
9334            "past If-Modified-Since should return 200 (file modified after it), got {}",
9335            resp.status()
9336        );
9337
9338        // Cleanup
9339        std::fs::remove_dir_all(&temp_dir).ok();
9340    }
9341
9342    #[tokio::test]
9343    async fn test_conditional_get_returns_304_static_mode() {
9344        run_conditional_get_returns_304(MountMode::Static).await;
9345    }
9346
9347    #[tokio::test]
9348    async fn test_conditional_get_returns_304_spa_mode() {
9349        run_conditional_get_returns_304(MountMode::Spa).await;
9350    }
9351
9352    #[allow(clippy::await_holding_lock)]
9353    #[tokio::test]
9354    async fn test_error_page_mapping_serves_custom_404() {
9355        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9356        ServerRegistry::reset();
9357
9358        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
9359        let errors_dir = temp_dir.join("errors");
9360        std::fs::create_dir_all(&errors_dir).unwrap();
9361        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9362        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
9363
9364        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9365        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
9366
9367        let registry = make_test_registry();
9368        let serve_dir = ServeDir::new(&canonical_dir)
9369            .precompressed_gzip()
9370            .precompressed_br()
9371            .append_index_html_on_directories(true);
9372
9373        let mut error_pages = std::collections::HashMap::new();
9374        error_pages.insert(404, canonical_404);
9375
9376        let mount = StaticMount {
9377            mount_path: "/".to_string(),
9378            mode: MountMode::Static,
9379            dir: canonical_dir.clone(),
9380            cache_control: "public, max-age=0".to_string(),
9381            error_pages,
9382            serve_dir,
9383        };
9384        registry.register_static_mount(mount).await.unwrap();
9385
9386        let state = make_test_state(registry);
9387
9388        // Request non-existent file → custom 404 page
9389        let req = Request::builder()
9390            .method("GET")
9391            .uri("/missing.html")
9392            .body(AxumBody::empty())
9393            .unwrap();
9394        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
9395        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9396        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9397            .await
9398            .unwrap();
9399        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
9400
9401        // Existing file still works
9402        let req = Request::builder()
9403            .method("GET")
9404            .uri("/index.html")
9405            .body(AxumBody::empty())
9406            .unwrap();
9407        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9408        assert_eq!(resp.status(), StatusCode::OK);
9409        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9410            .await
9411            .unwrap();
9412        assert_eq!(&body[..], b"<h1>Home</h1>");
9413
9414        // Cleanup
9415        std::fs::remove_dir_all(&temp_dir).ok();
9416    }
9417
9418    #[tokio::test]
9419    async fn http_consumer_returns_body_and_code_on_stop() {
9420        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
9421        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
9422        use tower::ServiceExt;
9423
9424        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
9425        let set_body_step = CompiledStep::Process {
9426            kind_hint: camel_api::SpanKindHint::Internal,
9427            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
9428                ex.input.body = Body::Text("nope".into());
9429                Box::pin(async move { Ok(ex) })
9430            }),
9431            body_contract: None,
9432            lifecycle: None,
9433            label: None,
9434        };
9435        let set_status_step = CompiledStep::Process {
9436            kind_hint: camel_api::SpanKindHint::Internal,
9437            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
9438                ex.input.set_header(
9439                    "CamelHttpResponseCode",
9440                    serde_json::Value::Number(409.into()),
9441                );
9442                Box::pin(async move { Ok(ex) })
9443            }),
9444            body_contract: None,
9445            lifecycle: None,
9446            label: None,
9447        };
9448        let pipeline = compose_pipeline_with_handler(
9449            vec![set_body_step, set_status_step, CompiledStep::Stop],
9450            None,
9451            PipelineRuntimeCtx::compile_time(),
9452        );
9453
9454        let ex = Exchange::new(Message::default());
9455        let result = pipeline.oneshot(ex).await;
9456        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
9457        let returned = result.unwrap();
9458        assert_eq!(returned.input.body.as_text(), Some("nope"));
9459        assert_eq!(
9460            returned
9461                .input
9462                .header("CamelHttpResponseCode")
9463                .and_then(|v| v.as_u64()),
9464            Some(409)
9465        );
9466    }
9467
9468    #[tokio::test]
9469    async fn http_consumer_returns_200_when_body_empty_on_stop() {
9470        // After ADR-0024: Stop with no body + no status header produces 200 (same as
9471        // a normal completion with no body). The 204 default is gone — users who
9472        // want 204 set CamelHttpResponseCode=204 explicitly.
9473        //
9474        // This test stays at the pipeline level (consistent with the test above).
9475        // E2E coverage of the full HTTP dispatch path is in
9476        // crates/camel-test/tests/integration_test.rs.
9477        use camel_api::{Exchange, Message};
9478        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
9479        use tower::ServiceExt;
9480
9481        let pipeline = compose_pipeline_with_handler(
9482            vec![CompiledStep::Stop],
9483            None,
9484            PipelineRuntimeCtx::compile_time(),
9485        );
9486        let ex = Exchange::new(Message::default());
9487        let result = pipeline.oneshot(ex).await;
9488        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
9489        // Body is default (empty); no CamelHttpResponseCode header was set.
9490        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
9491    }
9492
9493    // -----------------------------------------------------------------------
9494    // Task 5: Method-aware REST dispatch tests
9495    // -----------------------------------------------------------------------
9496
9497    /// Spins up an axum server on a free port with a fresh registry.
9498    /// Returns the port plus the registry so the caller can register
9499    /// REST endpoints directly.
9500    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
9501        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9502        let port = listener.local_addr().unwrap().port();
9503        let registry = HttpRouteRegistry::new();
9504        tokio::spawn(run_axum_server(
9505            listener,
9506            registry.clone(),
9507            2 * 1024 * 1024,
9508            10 * 1024 * 1024,
9509            Arc::new(tokio::sync::Semaphore::new(1024)),
9510            test_rt(),
9511            "test-route".into(),
9512        ));
9513        // Give the server a moment to start accepting.
9514        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9515        (port, registry)
9516    }
9517
9518    /// Helper for REST integration tests: spawns a responder task that
9519    /// reads from `rx`, writes a fixed `(status, body)` back via the
9520    /// envelope's reply channel, and returns once the test request is
9521    /// satisfied.
9522    fn spawn_responder(
9523        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
9524        status: u16,
9525        body: String,
9526    ) -> tokio::task::JoinHandle<()> {
9527        tokio::spawn(async move {
9528            if let Some(envelope) = rx.recv().await {
9529                let _ = envelope.reply_tx.send(HttpReply {
9530                    status,
9531                    headers: vec![],
9532                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
9533                });
9534            }
9535        })
9536    }
9537
9538    #[tokio::test]
9539    async fn method_aware_dispatch_same_path_different_verbs() {
9540        let (port, registry) = spawn_test_server().await;
9541
9542        // Register two REST endpoints on the same path with different
9543        // methods. This is the core scenario REST DSL needs to support:
9544        // GET /users (list) and POST /users (create) must not overwrite
9545        // each other.
9546        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9547        registry
9548            .register_rest_endpoint(
9549                "GET".into(),
9550                vec![PathSegment::Literal("users".into())],
9551                get_tx,
9552            )
9553            .await;
9554
9555        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9556        registry
9557            .register_rest_endpoint(
9558                "POST".into(),
9559                vec![PathSegment::Literal("users".into())],
9560                post_tx,
9561            )
9562            .await;
9563
9564        let get_handle = spawn_responder(get_rx, 200, "list".into());
9565        let post_handle = spawn_responder(post_rx, 201, "create".into());
9566
9567        let client = reqwest::Client::new();
9568
9569        // GET /users → list route
9570        let resp = client
9571            .get(format!("http://127.0.0.1:{port}/users"))
9572            .send()
9573            .await
9574            .unwrap();
9575        assert_eq!(resp.status().as_u16(), 200);
9576        let body = resp.text().await.unwrap();
9577        assert_eq!(body, "list");
9578
9579        // POST /users → create route
9580        let resp = client
9581            .post(format!("http://127.0.0.1:{port}/users"))
9582            .send()
9583            .await
9584            .unwrap();
9585        assert_eq!(resp.status().as_u16(), 201);
9586        let body = resp.text().await.unwrap();
9587        assert_eq!(body, "create");
9588
9589        let _ = tokio::join!(get_handle, post_handle);
9590    }
9591
9592    #[tokio::test]
9593    async fn method_aware_dispatch_templated_path_extracts_params() {
9594        let (port, registry) = spawn_test_server().await;
9595
9596        // Register GET /users/{id} as a templated endpoint. The
9597        // dispatcher should match `/users/42` against the template and
9598        // attach `id=42` to the envelope's path_params.
9599        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9600        registry
9601            .register_rest_endpoint(
9602                "GET".into(),
9603                vec![
9604                    PathSegment::Literal("users".into()),
9605                    PathSegment::Param("id".into()),
9606                ],
9607                tx,
9608            )
9609            .await;
9610
9611        // Spawn a responder that echoes the captured id back in the body
9612        // so the test can verify the param was set.
9613        let handle = tokio::spawn(async move {
9614            if let Some(envelope) = rx.recv().await {
9615                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
9616                let _ = envelope.reply_tx.send(HttpReply {
9617                    status: 200,
9618                    headers: vec![],
9619                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
9620                });
9621            }
9622        });
9623
9624        let client = reqwest::Client::new();
9625        let resp = client
9626            .get(format!("http://127.0.0.1:{port}/users/42"))
9627            .send()
9628            .await
9629            .unwrap();
9630        assert_eq!(resp.status().as_u16(), 200);
9631        let body = resp.text().await.unwrap();
9632        assert_eq!(body, "id=42");
9633
9634        let _ = handle.await;
9635    }
9636
9637    #[tokio::test]
9638    async fn method_aware_dispatch_unmatched_method_falls_through() {
9639        // If no REST endpoint matches the method, dispatch must fall
9640        // through to the legacy api_routes lookup or static mounts. With
9641        // nothing else registered, the request gets 404 from static
9642        // dispatch.
9643        let (port, _registry) = spawn_test_server().await;
9644
9645        // Register only GET /users; a DELETE /users request has no match.
9646        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9647        _registry
9648            .register_rest_endpoint(
9649                "GET".into(),
9650                vec![PathSegment::Literal("users".into())],
9651                get_tx,
9652            )
9653            .await;
9654
9655        // Drain the GET channel in the background so the consumer side
9656        // doesn't block (we don't expect any envelopes here).
9657        let drain = tokio::spawn(async move {
9658            let mut get_rx = get_rx;
9659            while get_rx.recv().await.is_some() {}
9660        });
9661
9662        let client = reqwest::Client::new();
9663        let resp = client
9664            .delete(format!("http://127.0.0.1:{port}/users"))
9665            .send()
9666            .await
9667            .unwrap();
9668        assert_eq!(resp.status().as_u16(), 404);
9669
9670        drop(drain);
9671    }
9672
9673    #[tokio::test]
9674    async fn regression_legacy_exact_api_route_still_works() {
9675        // A `http:` route registered without an `httpMethod=` URI param
9676        // lands in the legacy api_routes registry. The dispatcher must
9677        // still find it via exact path lookup. This guards against
9678        // regressions introduced by the new REST-aware dispatch.
9679        let (port, registry) = spawn_test_server().await;
9680
9681        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9682        registry.register_api_route("/legacy/path".into(), tx).await;
9683
9684        let handle = tokio::spawn(async move {
9685            if let Some(envelope) = rx.recv().await {
9686                let _ = envelope.reply_tx.send(HttpReply {
9687                    status: 200,
9688                    headers: vec![],
9689                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
9690                });
9691            }
9692        });
9693
9694        let client = reqwest::Client::new();
9695        let resp = client
9696            .get(format!("http://127.0.0.1:{port}/legacy/path"))
9697            .send()
9698            .await
9699            .unwrap();
9700        assert_eq!(resp.status().as_u16(), 200);
9701        let body = resp.text().await.unwrap();
9702        assert_eq!(body, "legacy ok");
9703
9704        let _ = handle.await;
9705    }
9706
9707    #[allow(clippy::await_holding_lock)]
9708    #[tokio::test]
9709    async fn regression_static_mount_still_works() {
9710        // Verify that static file serving still works after the
9711        // dispatch refactor. We register a temp-dir mount and request
9712        // a file from it; the static dispatcher should serve it.
9713        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9714        ServerRegistry::reset();
9715
9716        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
9717        std::fs::create_dir_all(&temp_dir).unwrap();
9718        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
9719        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9720
9721        let registry = make_test_registry();
9722        let serve_dir = ServeDir::new(&canonical_dir)
9723            .precompressed_gzip()
9724            .precompressed_br()
9725            .append_index_html_on_directories(true);
9726        let mount = StaticMount {
9727            mount_path: "/".to_string(),
9728            mode: MountMode::Static,
9729            dir: canonical_dir.clone(),
9730            cache_control: "public, max-age=3600".to_string(),
9731            error_pages: std::collections::HashMap::new(),
9732            serve_dir,
9733        };
9734        registry.register_static_mount(mount).await.unwrap();
9735
9736        let state = make_test_state(registry);
9737        let req = Request::builder()
9738            .uri("/regress.txt")
9739            .body(AxumBody::empty())
9740            .unwrap();
9741        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
9742        assert_eq!(resp.status(), StatusCode::OK);
9743        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9744            .await
9745            .unwrap();
9746        assert_eq!(&body[..], b"static works");
9747
9748        std::fs::remove_dir_all(&temp_dir).ok();
9749    }
9750
9751    // -----------------------------------------------------------------------
9752    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
9753    // templated from-URI round-trip. These exercise the real axum dispatch
9754    // path (register → HTTP request → reply) so a regression in any of the
9755    // three critical fixes surfaces as a test failure rather than a silent
9756    // production 404/500.
9757    // -----------------------------------------------------------------------
9758
9759    #[tokio::test]
9760    async fn deregister_one_method_keeps_sibling_verbs() {
9761        // Review C1: stopping the GET /users consumer must NOT tear down the
9762        // live POST /users endpoint. Register both, deregister GET only,
9763        // then verify POST still dispatches.
9764        let (port, registry) = spawn_test_server().await;
9765
9766        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9767        registry
9768            .register_rest_endpoint(
9769                "GET".into(),
9770                vec![PathSegment::Literal("users".into())],
9771                get_tx,
9772            )
9773            .await;
9774
9775        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9776        registry
9777            .register_rest_endpoint(
9778                "POST".into(),
9779                vec![PathSegment::Literal("users".into())],
9780                post_tx,
9781            )
9782            .await;
9783
9784        // Drain GET in the background (no requests expected after deregister).
9785        let drain = tokio::spawn(async move {
9786            let mut get_rx = get_rx;
9787            while get_rx.recv().await.is_some() {}
9788        });
9789
9790        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
9791        registry.unregister_rest_endpoint("GET", "/users").await;
9792        drop(drain);
9793
9794        let post_handle = spawn_responder(post_rx, 201, "create".into());
9795
9796        let client = reqwest::Client::new();
9797        // POST /users must still reach its consumer after GET was removed.
9798        let resp = client
9799            .post(format!("http://127.0.0.1:{port}/users"))
9800            .send()
9801            .await
9802            .unwrap();
9803        assert_eq!(resp.status().as_u16(), 201);
9804        assert_eq!(resp.text().await.unwrap(), "create");
9805
9806        let _ = post_handle.await;
9807    }
9808
9809    #[tokio::test]
9810    async fn dispatch_exact_legacy_beats_rest_template() {
9811        // Review C2: an exact legacy API route (`GET /api/users`, no
9812        // httpMethod) must win over a templated REST route
9813        // (`GET /api/{resource}`) for the request `/api/users`, per spec
9814        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
9815        let (port, registry) = spawn_test_server().await;
9816
9817        // Exact legacy route.
9818        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9819        registry
9820            .register_api_route("/api/users".into(), exact_tx)
9821            .await;
9822        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
9823
9824        // Templated REST route that would ALSO match /api/users.
9825        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9826        registry
9827            .register_rest_endpoint(
9828                "GET".into(),
9829                vec![
9830                    PathSegment::Literal("api".into()),
9831                    PathSegment::Param("resource".into()),
9832                ],
9833                tpl_tx,
9834            )
9835            .await;
9836        // The templated handler must NOT receive the /api/users request. If
9837        // it does, it replies "template-leak" so a future assertion could
9838        // catch it. We do NOT await this task: the exact-match branch wins
9839        // and the templated channel never receives, so awaiting would block
9840        // until the test runtime tears down.
9841        let _tpl_drain = tokio::spawn(async move {
9842            let mut tpl_rx = tpl_rx;
9843            if let Some(env) = tpl_rx.recv().await {
9844                let _ = env.reply_tx.send(HttpReply {
9845                    status: 200,
9846                    headers: vec![],
9847                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
9848                });
9849            }
9850        });
9851
9852        let client = reqwest::Client::new();
9853        let resp = client
9854            .get(format!("http://127.0.0.1:{port}/api/users"))
9855            .send()
9856            .await
9857            .unwrap();
9858        assert_eq!(resp.status().as_u16(), 200);
9859        // Exact-match handler answered — not the templated one.
9860        assert_eq!(resp.text().await.unwrap(), "exact");
9861
9862        let _ = exact_handle.await;
9863    }
9864
9865    #[tokio::test]
9866    async fn ambiguous_rest_templates_return_500_not_silent_404() {
9867        // Review C3: two equal-specificity templates that both match one
9868        // request are an ambiguous registration. At runtime this must
9869        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
9870        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
9871        let (port, registry) = spawn_test_server().await;
9872
9873        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9874        registry
9875            .register_rest_endpoint(
9876                "GET".into(),
9877                vec![
9878                    PathSegment::Literal("users".into()),
9879                    PathSegment::Param("id".into()),
9880                ],
9881                a_tx,
9882            )
9883            .await;
9884
9885        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9886        registry
9887            .register_rest_endpoint(
9888                "GET".into(),
9889                vec![
9890                    PathSegment::Literal("users".into()),
9891                    PathSegment::Param("name".into()),
9892                ],
9893                b_tx,
9894            )
9895            .await;
9896
9897        let client = reqwest::Client::new();
9898        let resp = client
9899            .get(format!("http://127.0.0.1:{port}/users/42"))
9900            .send()
9901            .await
9902            .unwrap();
9903        // Ambiguous → 500 (previously a silent 404).
9904        assert_eq!(resp.status().as_u16(), 500);
9905    }
9906
9907    #[test]
9908    fn from_uri_round_trips_templated_path_with_http_method() {
9909        // Review I4: a REST-lowered from-URI like
9910        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
9911        // through HttpServerConfig::from_uri, preserving the templated path
9912        // and the (uppercased) method. This is the binding the DSL lowering
9913        // emits and the consumer reads; it was previously unasserted.
9914        use crate::UriConfig;
9915        let cfg =
9916            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
9917        assert_eq!(cfg.host, "0.0.0.0");
9918        assert_eq!(cfg.port, 8080);
9919        assert_eq!(cfg.path, "/users/{id}");
9920        assert_eq!(cfg.method.as_deref(), Some("GET"));
9921
9922        // Lower-case httpMethod is uppercased (review I5).
9923        let cfg_lc =
9924            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
9925        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
9926        assert_eq!(cfg_lc.path, "/orders");
9927    }
9928
9929    // -----------------------------------------------------------------------
9930    // rc-1dk4: TypeConversionFailed → 400 Bad Request
9931    // -----------------------------------------------------------------------
9932
9933    #[test]
9934    fn type_conversion_failed_maps_to_400() {
9935        let reply = pipeline_error_to_reply(
9936            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
9937            "/api/users",
9938        );
9939        assert_eq!(reply.status, 400);
9940        // Content-Type must be application/json
9941        let ct = reply
9942            .headers
9943            .iter()
9944            .find(|(k, _)| k == "Content-Type")
9945            .map(|(_, v)| v.as_str());
9946        assert_eq!(ct, Some("application/json"));
9947        // Body must contain structured error JSON
9948        let body = match &reply.body {
9949            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
9950            _ => panic!("expected bytes body"),
9951        };
9952        assert!(body.contains("\"error\""));
9953        assert!(body.contains("bad_request"));
9954        assert!(body.contains("invalid JSON at line 1"));
9955    }
9956
9957    #[test]
9958    fn other_error_still_maps_to_500() {
9959        let reply =
9960            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
9961        assert_eq!(reply.status, 500);
9962    }
9963
9964    #[test]
9965    fn unauthenticated_maps_to_401() {
9966        let reply = pipeline_error_to_reply(
9967            CamelError::Unauthenticated("no token".to_string()),
9968            "/api/users",
9969        );
9970        assert_eq!(reply.status, 401);
9971    }
9972
9973    #[test]
9974    fn unauthorized_maps_to_403() {
9975        let reply = pipeline_error_to_reply(
9976            CamelError::Unauthorized("forbidden".to_string()),
9977            "/api/users",
9978        );
9979        assert_eq!(reply.status, 403);
9980    }
9981
9982    #[test]
9983    fn validation_error_maps_to_400() {
9984        let reply = pipeline_error_to_reply(
9985            CamelError::ValidationError("body does not match schema".to_string()),
9986            "/api/users",
9987        );
9988        assert_eq!(reply.status, 400);
9989        let ct = reply
9990            .headers
9991            .iter()
9992            .find(|(k, _)| k == "Content-Type")
9993            .map(|(_, v)| v.as_str());
9994        assert_eq!(ct, Some("application/json"));
9995        let body = match &reply.body {
9996            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
9997            _ => panic!("expected bytes body"),
9998        };
9999        assert!(body.contains("\"error\""));
10000        assert!(body.contains("validation_error"));
10001        assert!(body.contains("body does not match schema"));
10002    }
10003
10004    #[test]
10005    fn https_consumer_without_tls_cert_errors() {
10006        let endpoint = HttpEndpoint {
10007            uri: "https://0.0.0.0:8443/api".to_string(),
10008            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10009            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10010            client: reqwest::Client::new(),
10011            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10012                PINNED_CLIENT_TTL,
10013                PINNED_CLIENT_MAX_ENTRIES,
10014            )),
10015            http_config: HttpConfig::default(),
10016        };
10017        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10018        let result = endpoint.create_consumer(rt);
10019        assert!(result.is_err(), "expected error for https without tls cert");
10020        if let Err(e) = result {
10021            let msg = e.to_string();
10022            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
10023        }
10024    }
10025
10026    #[test]
10027    fn http_consumer_with_tls_config_errors() {
10028        let endpoint = HttpEndpoint {
10029            uri: "http://0.0.0.0:8080/api".to_string(),
10030            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
10031            server_config: HttpServerConfig::from_uri(
10032                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
10033            )
10034            .unwrap(),
10035            client: reqwest::Client::new(),
10036            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10037                PINNED_CLIENT_TTL,
10038                PINNED_CLIENT_MAX_ENTRIES,
10039            )),
10040            http_config: HttpConfig::default(),
10041        };
10042        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10043        let result = endpoint.create_consumer(rt);
10044        assert!(result.is_err(), "expected error for http with tls config");
10045        if let Err(e) = result {
10046            let msg = e.to_string();
10047            assert!(msg.contains("https"), "error must mention https: {msg}");
10048        }
10049    }
10050
10051    #[test]
10052    fn https_consumer_with_partial_tls_cert_only_errors() {
10053        // tlsCert without tlsKey → tls_config is None at parse time
10054        // → create_consumer sees https:// + no TLS → must error
10055        let server_config =
10056            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10057        assert!(
10058            server_config.tls_config.is_none(),
10059            "partial tlsCert must not create ServerTlsConfig"
10060        );
10061        let endpoint = HttpEndpoint {
10062            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
10063            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
10064                .unwrap(),
10065            server_config,
10066            client: reqwest::Client::new(),
10067            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10068                PINNED_CLIENT_TTL,
10069                PINNED_CLIENT_MAX_ENTRIES,
10070            )),
10071            http_config: HttpConfig::default(),
10072        };
10073        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10074        let result = endpoint.create_consumer(rt);
10075        assert!(
10076            result.is_err(),
10077            "must error: https:// requires both tlsCert and tlsKey"
10078        );
10079    }
10080
10081    #[test]
10082    fn load_tls_config_parses_valid_pem() {
10083        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
10084        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10085        use camel_component_api::test_support::tls;
10086        let (_, cert_pem, key_pem) = tls::gen_server_cert();
10087        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
10088        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
10089
10090        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
10091        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
10092    }
10093
10094    #[tokio::test(flavor = "multi_thread")]
10095    #[allow(clippy::await_holding_lock)]
10096    async fn consumer_tls_handshake_roundtrip() {
10097        use camel_component_api::test_support::tls;
10098        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10099
10100        // Install rustls crypto provider (aws-lc-rs)
10101        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10102
10103        // Serialize against global ServerRegistry singleton
10104        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10105
10106        // Generate CA + server cert
10107        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
10108        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
10109        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
10110        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
10111
10112        // Get ephemeral port
10113        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10114        let port = probe.local_addr().unwrap().port();
10115        drop(probe);
10116
10117        ServerRegistry::reset();
10118
10119        // Create real HttpComponent + endpoint with TLS URI
10120        let component = HttpComponent::new();
10121        let endpoint_ctx = NoOpComponentContext;
10122        let uri = format!(
10123            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10124            cert_path.to_string_lossy(),
10125            key_path.to_string_lossy(),
10126        );
10127        let endpoint = component
10128            .create_endpoint(&uri, &endpoint_ctx)
10129            .expect("create TLS endpoint");
10130        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
10131
10132        // Start consumer — this calls get_or_spawn with tls_config
10133        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10134        let token = tokio_util::sync::CancellationToken::new();
10135        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
10136        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10137
10138        // Give server time to start
10139        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10140
10141        // Client with CA cert — REAL verification (no danger_accept_invalid)
10142        let ca_bytes = std::fs::read(&ca_path).unwrap();
10143        let client = reqwest::Client::builder()
10144            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
10145            .build()
10146            .unwrap();
10147
10148        let send_fut = client
10149            .post(format!("https://localhost:{port}/test"))
10150            .body("ping")
10151            .send();
10152
10153        // Handler: receive envelope, reply 200 with "pong" body
10154        let (http_result, _) = tokio::join!(send_fut, async {
10155            if let Some(mut envelope) = rx.recv().await {
10156                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
10157                if let Some(reply_tx) = envelope.reply_tx {
10158                    let _ = reply_tx.send(Ok(envelope.exchange));
10159                }
10160            }
10161        });
10162
10163        let resp = http_result.expect("TLS handshake + request must succeed");
10164
10165        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
10166        let body = resp.text().await.unwrap();
10167        assert_eq!(body, "pong");
10168
10169        token.cancel();
10170    }
10171
10172    #[tokio::test(flavor = "multi_thread")]
10173    #[allow(clippy::await_holding_lock)]
10174    async fn consumer_tls_rejects_client_without_ca() {
10175        use camel_component_api::test_support::tls;
10176        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10177
10178        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10179
10180        // Serialize against global ServerRegistry singleton
10181        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10182
10183        let (_, cert_pem, key_pem) = tls::gen_server_cert();
10184        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
10185        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
10186
10187        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10188        let port = probe.local_addr().unwrap().port();
10189        drop(probe);
10190
10191        ServerRegistry::reset();
10192
10193        // Spawn TLS server via real HttpComponent path
10194        let component = HttpComponent::new();
10195        let endpoint_ctx = NoOpComponentContext;
10196        let uri = format!(
10197            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10198            cert_path.to_string_lossy(),
10199            key_path.to_string_lossy(),
10200        );
10201        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
10202        let mut consumer = endpoint.create_consumer(rt()).unwrap();
10203        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10204        let token = tokio_util::sync::CancellationToken::new();
10205        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
10206        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10207
10208        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10209
10210        // Client WITHOUT CA cert — must fail TLS verification
10211        let client = reqwest::Client::builder().build().unwrap();
10212
10213        let result = client
10214            .get(format!("https://localhost:{port}/test"))
10215            .send()
10216            .await;
10217
10218        assert!(
10219            result.is_err(),
10220            "must reject without CA — proves real verification"
10221        );
10222
10223        token.cancel();
10224    }
10225
10226    #[test]
10227    fn server_config_partial_tls_cert_without_key() {
10228        // Parse URI with only tlsCert (no tlsKey)
10229        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10230        // Partial params → tls_config must be None
10231        assert!(cfg.tls_config.is_none());
10232    }
10233
10234    #[test]
10235    fn endpoint_uri_options_count_parity() {
10236        // Mirror struct must stay in sync with bespoke from_components parser.
10237        assert_eq!(
10238            HttpEndpointConfig::uri_options().len(),
10239            22,
10240            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
10241        );
10242    }
10243
10244    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
10245        pairs
10246            .iter()
10247            .map(|(k, v)| {
10248                (
10249                    (*k).to_string(),
10250                    serde_json::Value::String((*v).to_string()),
10251                )
10252            })
10253            .collect()
10254    }
10255
10256    #[test]
10257    fn response_emits_cache_control_via_pragma_warning() {
10258        let headers = make_headers(&[
10259            ("Cache-Control", "public, max-age=3600"),
10260            ("Via", "1.1 myproxy"),
10261            ("Pragma", "no-cache"),
10262            ("Warning", "199 misc"),
10263        ]);
10264        let selected = select_response_headers(&headers, None, None);
10265        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10266        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
10267            assert!(
10268                names.contains(&expected),
10269                "{expected} should pass through to the response"
10270            );
10271        }
10272    }
10273
10274    #[test]
10275    fn response_excludes_request_only_and_server_owned() {
10276        let headers = make_headers(&[
10277            ("User-Agent", "x"),
10278            ("Accept", "*/*"),
10279            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
10280        ]);
10281        let selected = select_response_headers(&headers, None, None);
10282        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10283        for excluded in ["User-Agent", "Accept", "Date"] {
10284            assert!(
10285                !names.contains(&excluded),
10286                "{excluded} should NOT appear in the response"
10287            );
10288        }
10289    }
10290
10291    #[test]
10292    fn response_re_derives_content_type() {
10293        let headers = make_headers(&[("Content-Type", "text/plain")]);
10294        let selected = select_response_headers(&headers, Some("application/json".into()), None);
10295        let ct_entries: Vec<&str> = selected
10296            .iter()
10297            .filter(|(k, _)| k == "Content-Type")
10298            .map(|(_, v)| v.as_str())
10299            .collect();
10300        assert_eq!(
10301            ct_entries,
10302            ["application/json"],
10303            "exactly one Content-Type entry, re-derived from user_content_type"
10304        );
10305    }
10306
10307    #[test]
10308    fn response_excludes_camel_headers() {
10309        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
10310        let selected = select_response_headers(&headers, None, None);
10311        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10312        assert!(
10313            !names.contains(&"CamelHttpPath"),
10314            "Camel-namespace headers must be excluded"
10315        );
10316        assert!(
10317            names.contains(&"Cache-Control"),
10318            "Cache-Control must pass through"
10319        );
10320    }
10321
10322    #[test]
10323    fn response_stringifies_scalar_header_values() {
10324        let mut headers = make_headers(&[("X-Label", "keep")]);
10325        headers.insert("X-Retries".to_string(), serde_json::json!(3));
10326        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
10327        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
10328        let selected = select_response_headers(&headers, None, None);
10329        let get = |name: &str| -> Option<&str> {
10330            selected
10331                .iter()
10332                .find(|(k, _)| k == name)
10333                .map(|(_, v)| v.as_str())
10334        };
10335        assert_eq!(
10336            get("X-Retries"),
10337            Some("3"),
10338            "integer header must be stringified"
10339        );
10340        assert_eq!(
10341            get("X-Ratio"),
10342            Some("3.5"),
10343            "float header must be stringified"
10344        );
10345        assert_eq!(
10346            get("X-Enabled"),
10347            Some("true"),
10348            "bool header must be stringified"
10349        );
10350        assert_eq!(
10351            get("X-Label"),
10352            Some("keep"),
10353            "string header must pass through"
10354        );
10355    }
10356
10357    #[test]
10358    fn response_drops_null_and_structured_header_values() {
10359        let mut headers = make_headers(&[("X-Keep", "yes")]);
10360        headers.insert("X-Null".to_string(), serde_json::Value::Null);
10361        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
10362        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
10363        let selected = select_response_headers(&headers, None, None);
10364        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10365        for dropped in ["X-Null", "X-Obj", "X-Arr"] {
10366            assert!(
10367                !names.contains(&dropped),
10368                "{dropped} must not be emitted: no single-value form"
10369            );
10370        }
10371        assert!(names.contains(&"X-Keep"), "scalar headers must survive");
10372    }
10373
10374    #[test]
10375    fn response_stringifies_scalars_despite_excluded_names() {
10376        // Excluded names stay excluded regardless of value type: the policy
10377        // filter runs before stringification, so numeric values cannot smuggle
10378        // content-length or server-owned headers into the reply.
10379        let mut headers = HashMap::new();
10380        headers.insert("Content-Length".to_string(), serde_json::json!(999));
10381        headers.insert("Date".to_string(), serde_json::json!(12345));
10382        let selected = select_response_headers(&headers, None, None);
10383        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10384        assert!(
10385            !names.contains(&"Content-Length"),
10386            "content-length is re-derived by the server"
10387        );
10388        assert!(!names.contains(&"Date"), "date is server-owned");
10389    }
10390
10391    #[test]
10392    fn outbound_stringifies_scalar_header_values() {
10393        let mut headers = make_headers(&[("X-Label", "keep")]);
10394        headers.insert("X-Retries".to_string(), serde_json::json!(3));
10395        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
10396        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
10397        let outbound = select_outbound_headers(&headers, &[], &[]);
10398        // HeaderName construction lowercases; lookups compare case-blind.
10399        let get = |name: &str| -> Option<String> {
10400            outbound
10401                .accepted
10402                .iter()
10403                .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10404                .map(|(_, v)| v.to_str().unwrap().to_string())
10405        };
10406        assert_eq!(
10407            get("X-Retries").as_deref(),
10408            Some("3"),
10409            "integer header must be stringified"
10410        );
10411        assert_eq!(
10412            get("X-Ratio").as_deref(),
10413            Some("3.5"),
10414            "float header must be stringified"
10415        );
10416        assert_eq!(
10417            get("X-Enabled").as_deref(),
10418            Some("true"),
10419            "bool header must be stringified"
10420        );
10421        assert_eq!(
10422            get("X-Label").as_deref(),
10423            Some("keep"),
10424            "string header must pass through"
10425        );
10426        assert!(outbound.drops.is_empty(), "scalar headers must not drop");
10427    }
10428
10429    #[test]
10430    fn outbound_drops_null_and_structured_header_values() {
10431        let mut headers = make_headers(&[("X-Keep", "yes")]);
10432        headers.insert("X-Null".to_string(), serde_json::Value::Null);
10433        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
10434        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
10435        let outbound = select_outbound_headers(&headers, &[], &[]);
10436        let has = |name: &str| {
10437            outbound
10438                .accepted
10439                .iter()
10440                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10441        };
10442        assert!(has("X-Keep"), "scalar headers must survive");
10443        for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
10444            let dropped = outbound
10445                .drops
10446                .iter()
10447                .find(|d| d.name == name)
10448                .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
10449            assert_eq!(
10450                dropped.reason, "no scalar string form",
10451                "{name} drop reason must name the value kind absence"
10452            );
10453            assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
10454        }
10455    }
10456
10457    #[test]
10458    fn outbound_stringifies_scalars_despite_excluded_names() {
10459        // Excluded names stay excluded regardless of value type: the policy
10460        // filter runs before stringification, so numeric values cannot smuggle
10461        // hop-by-hop or client-derived headers onto the wire.
10462        let mut headers = HashMap::new();
10463        headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
10464        headers.insert("Host".to_string(), serde_json::json!(12345));
10465        headers.insert("X-Ok".to_string(), serde_json::json!(7));
10466        let outbound = select_outbound_headers(&headers, &[], &[]);
10467        let has = |name: &str| {
10468            outbound
10469                .accepted
10470                .iter()
10471                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10472        };
10473        assert!(
10474            !has("Transfer-Encoding"),
10475            "hop-by-hop header must stay excluded"
10476        );
10477        assert!(!has("Host"), "host is destination-derived");
10478        assert!(has("X-Ok"), "non-excluded scalar must be stringified");
10479        assert!(
10480            outbound
10481                .drops
10482                .iter()
10483                .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
10484            "policy drop must be recorded before coercion"
10485        );
10486    }
10487
10488    #[test]
10489    fn outbound_drops_invalid_names_values_and_skip_config() {
10490        let mut headers = make_headers(&[("X-Good", "fine")]);
10491        headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
10492        headers.insert(
10493            "X-Control-Value".to_string(),
10494            serde_json::json!("line1\nline2"),
10495        );
10496        headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
10497        headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
10498        let skip = vec!["x-secret".to_string()];
10499        let outbound = select_outbound_headers(&headers, &skip, &[]);
10500        let has = |name: &str| {
10501            outbound
10502                .accepted
10503                .iter()
10504                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10505        };
10506        assert!(has("X-Good"), "valid header must survive");
10507        assert!(!has("X Bad Name"), "invalid header name must drop");
10508        assert!(!has("X-Control-Value"), "control-char value must drop");
10509        assert!(!has("X-Secret"), "skipped header must drop");
10510        assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
10511        let reason = |n: &str| {
10512            outbound
10513                .drops
10514                .iter()
10515                .find(|d| d.name == n)
10516                .map(|d| d.reason)
10517        };
10518        assert_eq!(reason("X Bad Name"), Some("invalid header name"));
10519        assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
10520        assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
10521        assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
10522    }
10523
10524    // -----------------------------------------------------------------------
10525    // Bridge proxy end-to-end integration tests (Task 4.1)
10526    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
10527    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
10528    // -----------------------------------------------------------------------
10529
10530    /// Destination server that captures the outbound request line and the
10531    /// `Host:` header the producer actually sent on the wire. Returns
10532    /// `(host_value, request_line)` so a bridge-proxy test can assert that
10533    /// the producer derived `Host` from the destination (not the exchange)
10534    /// and honoured bridging semantics for the path.
10535    async fn start_host_capturing_destination() -> (
10536        String,
10537        Arc<std::sync::Mutex<Option<(String, String)>>>,
10538        tokio::task::JoinHandle<()>,
10539    ) {
10540        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10541        let port = listener.local_addr().unwrap().port();
10542        let url = format!("http://127.0.0.1:{port}");
10543        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
10544            Arc::new(std::sync::Mutex::new(None));
10545        let captured_clone = Arc::clone(&captured);
10546        let handle = tokio::spawn(async move {
10547            use tokio::io::{AsyncReadExt, AsyncWriteExt};
10548            if let Ok((mut stream, _)) = listener.accept().await {
10549                let mut buf = vec![0u8; 16384];
10550                let n = stream.read(&mut buf).await.unwrap_or(0);
10551                let request = String::from_utf8_lossy(&buf[..n]).to_string();
10552                if request.contains("\r\n\r\n") {
10553                    let request_line = request.lines().next().unwrap_or("").to_string();
10554                    let host_value = request
10555                        .lines()
10556                        .find(|l| l.to_lowercase().starts_with("host:"))
10557                        .and_then(|l| l.split_once(':'))
10558                        .map(|(_, v)| v.trim().to_string())
10559                        .unwrap_or_default();
10560                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
10561                }
10562                let body = r#"{"echo":"ok"}"#;
10563                let resp = format!(
10564                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
10565                    body.len(),
10566                    body
10567                );
10568                let _ = stream.write_all(resp.as_bytes()).await;
10569            }
10570        });
10571        (url, captured, handle)
10572    }
10573
10574    /// A bridging producer must derive `Host` from the destination URL and
10575    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
10576    /// semantics. The wire-level proof is the raw `Host:` header and request
10577    /// line captured at the destination TCP socket.
10578    #[tokio::test]
10579    async fn bridge_proxy_outbound_host_matches_destination() {
10580        use tower::ServiceExt;
10581
10582        let (url, captured, _handle) = start_host_capturing_destination().await;
10583        // The Host header reqwest derives for http://127.0.0.1:{port} is the
10584        // authority, scheme-stripped: "127.0.0.1:{port}".
10585        let expected_host = url.strip_prefix("http://").unwrap();
10586
10587        let ctx = test_producer_ctx();
10588        let component = HttpComponent::new();
10589        let endpoint_ctx = NoOpComponentContext;
10590        let endpoint = component
10591            .create_endpoint(
10592                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
10593                &endpoint_ctx,
10594            )
10595            .unwrap();
10596        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
10597
10598        // Exchange carries a stale Host and a CamelHttpPath that bridging
10599        // must drop.
10600        let mut exchange = Exchange::new(Message::default());
10601        exchange.input.set_header("Host", "localhost");
10602        exchange.input.set_header("CamelHttpPath", "/foo");
10603
10604        let result = producer.oneshot(exchange).await;
10605        assert!(result.is_ok(), "producer call failed: {:?}", result);
10606
10607        tokio::time::sleep(Duration::from_millis(100)).await;
10608        let (host_value, request_line) = captured
10609            .lock()
10610            .unwrap()
10611            .take()
10612            .expect("destination capture mutex empty — producer did not reach the destination");
10613
10614        assert_ne!(
10615            host_value, "localhost",
10616            "bridge producer must not forward the exchange Host: localhost"
10617        );
10618        assert_eq!(
10619            host_value, expected_host,
10620            "Host must be derived from the destination authority (no scheme)"
10621        );
10622        assert!(
10623            !request_line.contains("/foo"),
10624            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
10625        );
10626    }
10627
10628    /// A response header set by the route (`Cache-Control`) must survive to
10629    /// the wire. The assertion is on the reqwest HTTP response — not an
10630    /// in-process HttpReply struct — so it proves the consumer's reply
10631    /// finaliser emitted the header over the socket.
10632    #[tokio::test]
10633    async fn bridge_proxy_route_set_response_header_survives() {
10634        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10635
10636        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10637        let port = listener.local_addr().unwrap().port();
10638        drop(listener);
10639
10640        let component = HttpComponent::new();
10641        let endpoint_ctx = NoOpComponentContext;
10642        let endpoint = component
10643            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
10644            .unwrap();
10645        let mut consumer = endpoint.create_consumer(rt()).unwrap();
10646
10647        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10648        let token = tokio_util::sync::CancellationToken::new();
10649        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10650
10651        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10652        tokio::time::sleep(Duration::from_millis(50)).await;
10653
10654        let client = reqwest::Client::new();
10655        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
10656
10657        // Route sets Cache-Control on the outbound reply (exchange.input is
10658        // the message the reply finaliser reads — see select_response_headers
10659        // at the dispatch site).
10660        let (http_result, _) = tokio::join!(send_fut, async {
10661            if let Some(mut envelope) = rx.recv().await {
10662                envelope
10663                    .exchange
10664                    .input
10665                    .set_header("Cache-Control", "public, max-age=3600");
10666                if let Some(reply_tx) = envelope.reply_tx {
10667                    let _ = reply_tx.send(Ok(envelope.exchange));
10668                }
10669            }
10670        });
10671
10672        let resp = http_result.unwrap();
10673        assert_eq!(resp.status().as_u16(), 200);
10674
10675        let cache_control = resp.headers().get("cache-control");
10676        assert!(
10677            cache_control.is_some(),
10678            "Cache-Control header must survive to the wire response"
10679        );
10680        assert_eq!(
10681            cache_control.unwrap().to_str().unwrap(),
10682            "public, max-age=3600"
10683        );
10684
10685        token.cancel();
10686    }
10687
10688    // -----------------------------------------------------------------------
10689    // credential-sources task 2.3: credential values stay out of diagnostics
10690    // -----------------------------------------------------------------------
10691    //
10692    // camel-http has no request access log (design.md "Redaction sinks",
10693    // ADR-0051). The only diagnostic sink on the failed-auth path is
10694    // `pipeline_error_to_reply`, which renders the (generic) error message and
10695    // the *configured* route path — never the request URI, query string, or
10696    // extracted credential. These tests pin that redact-by-construction
10697    // contract: a sentinel credential presented in a declared source must not
10698    // appear in the reply body nor in any tracing record emitted while the
10699    // request is handled.
10700    //
10701    // Capture scope: `#[traced_test]` installs a per-crate env filter
10702    // (`camel_component_http=trace`), so records from OTHER targets
10703    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
10704    // redaction contract for those crates is guarded by their own tests.
10705    // Revisit this capture scope if camel-auth ever logs on the auth path.
10706    use camel_api::security_policy::CredentialSource;
10707    use camel_auth::credential_source::extract_token_from_exchange;
10708    use camel_auth::native_auth::NativeCredentialStore;
10709    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
10710
10711    // Sentinel credential values — test fixtures only, not real secrets.
10712    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
10713    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
10714    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
10715
10716    /// Build the exchange the consumer would build for a request envelope:
10717    /// standard Camel HTTP headers plus title-cased forwarded request headers.
10718    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
10719        let mut msg = Message::default();
10720        msg.set_header(
10721            "CamelHttpMethod",
10722            serde_json::Value::String(envelope.method.clone()),
10723        );
10724        msg.set_header(
10725            "CamelHttpPath",
10726            serde_json::Value::String(envelope.path.clone()),
10727        );
10728        msg.set_header(
10729            "CamelHttpQuery",
10730            serde_json::Value::String(envelope.query.clone()),
10731        );
10732        for (k, v) in &envelope.headers {
10733            if let Ok(val_str) = v.to_str() {
10734                msg.set_header(
10735                    title_case_header(k.as_str()),
10736                    serde_json::Value::String(val_str.to_string()),
10737                );
10738            }
10739        }
10740        Exchange::new(msg)
10741    }
10742
10743    /// Register a route whose responder authenticates each request against an
10744    /// empty native store, so every presented credential fails lookup with
10745    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
10746    /// authentication step (extract per `sources` → authenticate → deny) so the
10747    /// credential-extraction redaction contract is exercised on a real
10748    /// authentication failure.
10749    async fn spawn_failing_auth_route(
10750        registry: &HttpRouteRegistry,
10751        path: &str,
10752        sources: Vec<CredentialSource>,
10753    ) {
10754        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
10755            NativeCredentialStore::try_new(vec![]).unwrap(),
10756        ));
10757        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10758        registry.register_api_route(path.to_string(), tx).await;
10759        let path_owned = path.to_string();
10760        tokio::spawn(async move {
10761            while let Some(envelope) = rx.recv().await {
10762                let exchange = envelope_to_exchange(&envelope);
10763                let reply_tx = envelope.reply_tx;
10764                let result: Result<(), CamelError> = async {
10765                    let token = extract_token_from_exchange(&exchange, &sources)
10766                        .map(|extracted| extracted.token)
10767                        .ok_or_else(|| {
10768                            CamelError::Unauthenticated("no credential in any source".into())
10769                        })?;
10770                    authenticator.authenticate_bearer(&token).await?;
10771                    Ok(())
10772                }
10773                .await;
10774                let reply = match result {
10775                    Ok(()) => HttpReply {
10776                        status: 200,
10777                        headers: vec![],
10778                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
10779                    },
10780                    Err(e) => pipeline_error_to_reply(e, &path_owned),
10781                };
10782                let _ = reply_tx.send(reply);
10783            }
10784        });
10785    }
10786
10787    /// Whether any tracing record captured so far (process-wide) contains
10788    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
10789    /// shared buffer, so logs from spawned request-handling tasks are included.
10790    fn captured_logs_contain(needle: &str) -> bool {
10791        let buf = tracing_test::internal::global_buf().lock().unwrap();
10792        String::from_utf8_lossy(&buf).contains(needle)
10793    }
10794
10795    #[tracing_test::traced_test]
10796    #[tokio::test]
10797    async fn error_context_redacts_query_sentinel() {
10798        let (port, registry) = spawn_test_server().await;
10799        spawn_failing_auth_route(
10800            &registry,
10801            "/secure-query",
10802            vec![CredentialSource::QueryParam {
10803                param: "token".to_string(),
10804            }],
10805        )
10806        .await;
10807
10808        let client = reqwest::Client::new();
10809        let resp = client
10810            // allow-secret: `token` is the declared query-source param name, not a credential
10811            .get(format!(
10812                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
10813            ))
10814            .send()
10815            .await
10816            .unwrap();
10817
10818        assert_eq!(resp.status().as_u16(), 401);
10819        let body = resp.text().await.unwrap();
10820        assert_eq!(body, "Unauthorized");
10821        assert!(
10822            !body.contains(SENTINEL_QRY_42),
10823            "reply body must not contain the query credential"
10824        );
10825        assert!(
10826            !captured_logs_contain(SENTINEL_QRY_42),
10827            "no tracing record during request handling may render the query credential"
10828        );
10829        // Permanent positive control: the failed-auth warn! must be captured.
10830        // If the per-crate env filter ever stops matching, this fails loudly
10831        // instead of letting the sentinel assertions pass vacuously.
10832        assert!(
10833            captured_logs_contain("Authentication failed"),
10834            "positive control: the failed-auth warn! must be captured by the test subscriber"
10835        );
10836    }
10837
10838    #[tracing_test::traced_test]
10839    #[tokio::test]
10840    async fn error_context_redacts_cookie_sentinel() {
10841        let (port, registry) = spawn_test_server().await;
10842        spawn_failing_auth_route(
10843            &registry,
10844            "/secure-cookie",
10845            vec![CredentialSource::Cookie {
10846                name: "session".to_string(),
10847            }],
10848        )
10849        .await;
10850
10851        let client = reqwest::Client::new();
10852        let resp = client
10853            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
10854            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
10855            .send()
10856            .await
10857            .unwrap();
10858
10859        assert_eq!(resp.status().as_u16(), 401);
10860        let body = resp.text().await.unwrap();
10861        assert_eq!(body, "Unauthorized");
10862        assert!(
10863            !body.contains(SENTINEL_CKY_7),
10864            "reply body must not contain the cookie credential"
10865        );
10866        assert!(
10867            !captured_logs_contain(SENTINEL_CKY_7),
10868            "no tracing record during request handling may render the cookie credential"
10869        );
10870    }
10871
10872    #[tracing_test::traced_test]
10873    #[tokio::test]
10874    async fn error_reply_no_credential_value() {
10875        let (port, registry) = spawn_test_server().await;
10876        spawn_failing_auth_route(
10877            &registry,
10878            "/secure-bad",
10879            vec![CredentialSource::Cookie {
10880                name: "session".to_string(),
10881            }],
10882        )
10883        .await;
10884
10885        let client = reqwest::Client::new();
10886        let resp = client
10887            .get(format!("http://127.0.0.1:{port}/secure-bad"))
10888            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
10889            .send()
10890            .await
10891            .unwrap();
10892
10893        assert_eq!(resp.status().as_u16(), 401);
10894        let body = resp.text().await.unwrap();
10895        assert_eq!(body, "Unauthorized");
10896        assert!(
10897            !body.contains(SENTINEL_BAD_1),
10898            "reply body must not contain the credential value"
10899        );
10900        assert!(
10901            !captured_logs_contain(SENTINEL_BAD_1),
10902            "error logs must not render the credential value"
10903        );
10904    }
10905
10906    // -----------------------------------------------------------------------
10907    // Pinned-client-cache producer-path behavioral tests
10908    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
10909    // the endpoint cache, hostname requests build one client while the entry
10910    // stays retrievable, IP-literal requests bypass the cache)
10911    // -----------------------------------------------------------------------
10912
10913    /// Local responder that accepts any number of HTTP/1.1 connections on an
10914    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
10915    /// Unlike [`start_host_capturing_destination`], which serves exactly one
10916    /// connection, this loop keeps accepting so cache-reuse tests can drive
10917    /// several requests through one destination. Returns
10918    /// `(base_url, JoinHandle)`.
10919    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
10920        use tokio::io::AsyncWriteExt;
10921
10922        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
10923            .await
10924            .expect("bind ephemeral 127.0.0.1 listener");
10925        let port = listener.local_addr().expect("local addr").port();
10926        let base_url = format!("http://localhost:{port}");
10927        let handle = tokio::spawn(async move {
10928            while let Ok((mut conn, _)) = listener.accept().await {
10929                let _ = conn
10930                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
10931                    .await;
10932                let _ = conn.shutdown().await;
10933            }
10934        });
10935        (base_url, handle)
10936    }
10937
10938    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
10939    /// target a different authority (the 127.0.0.1 literal) on the same
10940    /// listener.
10941    fn responder_port(base_url: &str) -> u16 {
10942        url::Url::parse(base_url)
10943            .expect("responder base URL parses")
10944            .port()
10945            .expect("responder base URL carries an explicit port")
10946    }
10947
10948    /// Build an endpoint literal whose outbound config points at
10949    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
10950    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
10951    /// build counts stay observable across producers.
10952    fn endpoint_with_shared_cache(
10953        base_url: &str,
10954        pinned_cache: &Arc<PinnedClientCache>,
10955    ) -> HttpEndpoint {
10956        let uri = format!("{base_url}?allowInternal=true");
10957        HttpEndpoint {
10958            uri: uri.clone(),
10959            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
10960            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
10961            client: reqwest::Client::new(),
10962            pinned_cache: Arc::clone(pinned_cache),
10963            http_config: HttpConfig::default(),
10964        }
10965    }
10966
10967    #[tokio::test]
10968    async fn producers_share_endpoint_cache() {
10969        use tower::ServiceExt;
10970
10971        let (base_url, _handle) = spawn_multi_accept_200().await;
10972        let pinned_cache = Arc::new(PinnedClientCache::new(
10973            PINNED_CLIENT_TTL,
10974            PINNED_CLIENT_MAX_ENTRIES,
10975        ));
10976
10977        let ctx = test_producer_ctx();
10978        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
10979        let producer_a = endpoint.create_producer(rt(), &ctx);
10980        let producer_b = endpoint.create_producer(rt(), &ctx);
10981
10982        // Each producer sends one exchange whose resolved URL is the
10983        // endpoint's localhost base URL (a domain name → pinned-client path).
10984        for producer in [producer_a, producer_b] {
10985            let producer = producer.expect("create producer");
10986            let exchange = Exchange::new(Message::default());
10987            let reply = producer.oneshot(exchange).await;
10988            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
10989        }
10990
10991        assert_eq!(
10992            pinned_cache.build_count(),
10993            1,
10994            "both producers must hit the same shared cache entry; a second \
10995             build means sharing is broken"
10996        );
10997    }
10998
10999    #[tokio::test]
11000    async fn producer_repeated_hostname_requests_build_one_client() {
11001        use tower::ServiceExt;
11002
11003        let (base_url, _handle) = spawn_multi_accept_200().await;
11004        let pinned_cache = Arc::new(PinnedClientCache::new(
11005            PINNED_CLIENT_TTL,
11006            PINNED_CLIENT_MAX_ENTRIES,
11007        ));
11008        let ctx = test_producer_ctx();
11009        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11010        let producer = endpoint
11011            .create_producer(rt(), &ctx)
11012            .expect("create producer");
11013
11014        // Two sequential hostname requests — the cached pinned client stays
11015        // retrievable between them, so no second build may happen.
11016        for i in 0..2 {
11017            let exchange = Exchange::new(Message::default());
11018            let reply = producer.clone().oneshot(exchange).await;
11019            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
11020        }
11021
11022        assert_eq!(
11023            pinned_cache.build_count(),
11024            1,
11025            "repeated hostname requests must reuse the one pinned client; \
11026             0 builds means the producer bypassed the cache, more than 1 \
11027             means the entry was dropped"
11028        );
11029    }
11030
11031    #[tokio::test]
11032    async fn ip_literal_request_never_enters_cache() {
11033        use tower::ServiceExt;
11034
11035        let (base_url, _handle) = spawn_multi_accept_200().await;
11036        let pinned_cache = Arc::new(PinnedClientCache::new(
11037            PINNED_CLIENT_TTL,
11038            PINNED_CLIENT_MAX_ENTRIES,
11039        ));
11040
11041        let ctx = test_producer_ctx();
11042        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
11043        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
11044        let producer = endpoint
11045            .create_producer(rt(), &ctx)
11046            .expect("create producer");
11047
11048        let exchange = Exchange::new(Message::default());
11049        let reply = producer.oneshot(exchange).await;
11050        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11051
11052        assert_eq!(
11053            pinned_cache.build_count(),
11054            0,
11055            "an IP-literal URL must use the shared unpinned client and \
11056             never enter the pinned cache"
11057        );
11058    }
11059
11060    #[tokio::test]
11061    async fn test_component_endpoints_share_pinned_cache() {
11062        use tower::ServiceExt;
11063
11064        let component = HttpComponent::new();
11065        let (base_url, _handle) = spawn_multi_accept_200().await;
11066        let baseline = component.pinned_cache.build_count();
11067
11068        let ctx = test_producer_ctx();
11069        let endpoint_ctx = NoOpComponentContext;
11070        for uri in [
11071            format!("{base_url}/a?allowInternal=true&k=a"),
11072            format!("{base_url}/b?allowInternal=true&k=b"),
11073        ] {
11074            let endpoint = component
11075                .create_endpoint(&uri, &endpoint_ctx)
11076                .expect("create endpoint");
11077            let producer = endpoint
11078                .create_producer(rt(), &ctx)
11079                .expect("create producer");
11080            let exchange = Exchange::new(Message::default());
11081            let reply = producer.oneshot(exchange).await;
11082            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11083        }
11084
11085        assert_eq!(
11086            component.pinned_cache.build_count() - baseline,
11087            1,
11088            "endpoints created by one component must share its pinned cache; \
11089             0 builds means the endpoints bypassed it, more than 1 means \
11090             per-endpoint caches came back"
11091        );
11092    }
11093
11094    #[tokio::test]
11095    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
11096        use tower::ServiceExt;
11097
11098        let component = HttpComponent::new();
11099        let (base_url, _handle) = spawn_multi_accept_200().await;
11100        let baseline = component.pinned_cache.build_count();
11101
11102        let ctx = test_producer_ctx();
11103        let endpoint_ctx = NoOpComponentContext;
11104        for i in 0..3 {
11105            let endpoint = component
11106                .create_endpoint(
11107                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
11108                    &endpoint_ctx,
11109                )
11110                .expect("create endpoint");
11111            let producer = endpoint
11112                .create_producer(rt(), &ctx)
11113                .expect("create producer");
11114            let exchange = Exchange::new(Message::default());
11115            let reply = producer.oneshot(exchange).await;
11116            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
11117        }
11118
11119        assert_eq!(
11120            component.pinned_cache.build_count() - baseline,
11121            1,
11122            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
11123             must reuse the component's one pinned cache entry; 0 builds \
11124             means the endpoints bypassed it, more than 1 means \
11125             per-endpoint caches came back"
11126        );
11127    }
11128
11129    #[test]
11130    fn test_https_component_owns_distinct_cache() {
11131        let http = HttpComponent::new();
11132        let https = HttpsComponent::new();
11133
11134        assert!(
11135            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
11136            "http and https components must each own their own pinned cache"
11137        );
11138
11139        let endpoint_ctx = NoOpComponentContext;
11140        let _ = http
11141            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
11142            .expect("http endpoint");
11143        let _ = https
11144            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
11145            .expect("https endpoint");
11146
11147        assert_eq!(
11148            http.pinned_cache.build_count(),
11149            0,
11150            "endpoint creation must not build a pinned client"
11151        );
11152        assert_eq!(
11153            https.pinned_cache.build_count(),
11154            0,
11155            "endpoint creation must not build a pinned client"
11156        );
11157    }
11158
11159    #[test]
11160    fn test_component_constructor_builds_one_unpinned_client() {
11161        let baseline = build_client_call_count();
11162
11163        let _http = HttpComponent::new();
11164        assert_eq!(
11165            build_client_call_count() - baseline,
11166            1,
11167            "HttpComponent::new() must build exactly one shared unpinned client"
11168        );
11169
11170        let _https = HttpsComponent::new();
11171        assert_eq!(
11172            build_client_call_count() - baseline,
11173            2,
11174            "HttpsComponent::new() must build exactly one more shared unpinned client"
11175        );
11176    }
11177
11178    #[test]
11179    fn test_component_endpoints_share_unpinned_client() {
11180        let component = HttpComponent::new();
11181        let baseline = build_client_call_count();
11182
11183        let endpoint_ctx = NoOpComponentContext;
11184        for uri in [
11185            "http://localhost:1/a?allowInternal=true",
11186            "http://localhost:1/b?allowInternal=true",
11187        ] {
11188            let _endpoint = component
11189                .create_endpoint(uri, &endpoint_ctx)
11190                .expect("create endpoint");
11191        }
11192
11193        assert_eq!(
11194            build_client_call_count() - baseline,
11195            0,
11196            "create_endpoint must clone the component's shared unpinned client, \
11197             never build a fresh one"
11198        );
11199    }
11200
11201    #[test]
11202    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
11203        let component = HttpComponent::new();
11204        let baseline = build_client_call_count();
11205
11206        let ctx = test_producer_ctx();
11207        let endpoint_ctx = NoOpComponentContext;
11208        for i in 0..3 {
11209            let endpoint = component
11210                .create_endpoint(
11211                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
11212                    &endpoint_ctx,
11213                )
11214                .expect("create endpoint");
11215            let _producer = endpoint
11216                .create_producer(rt(), &ctx)
11217                .expect("create producer");
11218        }
11219
11220        assert_eq!(
11221            build_client_call_count() - baseline,
11222            0,
11223            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
11224             must reuse the component's shared unpinned client and build \
11225             no additional clients"
11226        );
11227    }
11228}