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