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};
46use futures::TryStreamExt;
47use futures::stream::BoxStream;
48
49// ---------------------------------------------------------------------------
50// HttpEndpointConfig
51// ---------------------------------------------------------------------------
52
53/// Configuration for an HTTP client (producer) endpoint.
54///
55/// # Memory Limits
56///
57/// HTTP operations enforce conservative memory limits to prevent denial-of-service
58/// attacks from untrusted network sources. These limits are significantly lower than
59/// file component limits (100MB) because HTTP typically handles API responses rather
60/// than large file transfers, and clients may be untrusted.
61///
62/// ## Default Limits
63///
64/// - **HTTP client body**: 10MB (typical API responses)
65/// - **HTTP server request**: 2MB (untrusted network input - see `HttpServerConfig`)
66/// - **HTTP server response**: 10MB (same as client - see `HttpServerConfig`)
67///
68/// ## Rationale
69///
70/// The 10MB limit for HTTP client responses is appropriate for most API interactions
71/// while providing protection against:
72/// - Malicious servers sending oversized responses
73/// - Runaway processes generating unexpectedly large payloads
74/// - Memory exhaustion attacks
75///
76/// The 2MB server request limit is even more conservative because it handles input
77/// from potentially untrusted clients on the public internet.
78///
79/// ## Overriding Limits
80///
81/// Override the default client body limit using the `maxBodySize` URI parameter:
82///
83/// ```text
84/// http://api.example.com/large-data?maxBodySize=52428800
85/// ```
86///
87/// For server endpoints, use `maxRequestBody` and `maxResponseBody` parameters:
88///
89/// ```text
90/// http://0.0.0.0:8080/upload?maxRequestBody=52428800
91/// ```
92///
93/// ## Behavior When Exceeded
94///
95/// When a body exceeds the configured limit:
96/// - An error is returned immediately
97/// - No memory is exhausted - the limit is checked before allocation
98/// - The HTTP connection is terminated cleanly
99///
100/// ## Security Considerations
101///
102/// HTTP endpoints should be treated with more caution than file endpoints because:
103/// - Clients may be unknown and untrusted
104/// - Network traffic can be spoofed or malicious
105/// - DoS attacks often exploit unbounded resource consumption
106///
107/// Only increase limits when you control both ends of the connection or when
108/// business requirements demand larger payloads.
109#[derive(Debug, Clone)]
110pub struct HttpEndpointConfig {
111    pub base_url: String,
112    pub http_method: Option<String>,
113    pub throw_exception_on_failure: bool,
114    pub ok_status_code_range: (u16, u16),
115    pub response_timeout: Option<Duration>,
116    pub query_params: HashMap<String, String>,
117    pub allow_internal: bool,
118    pub blocked_hosts: Vec<String>,
119    pub max_body_size: usize,
120    pub read_timeout_ms: u64,
121    pub max_response_bytes: usize,
122    pub auth: HttpAuth,
123    pub token_provider: Option<Arc<dyn TokenProvider>>,
124    pub user_agent: Option<String>,
125    pub bridge_endpoint: bool,
126    pub connection_close: bool,
127    pub skip_request_headers: Vec<String>,
128    pub skip_response_headers: Vec<String>,
129    pub follow_redirects: bool,
130    pub max_redirects: usize,
131}
132
133#[derive(Clone, PartialEq)]
134pub enum HttpAuth {
135    None,
136    Basic { username: String, password: String },
137    Bearer { token: String },
138}
139
140impl std::fmt::Debug for HttpAuth {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        match self {
143            HttpAuth::None => f.write_str("None"),
144            HttpAuth::Basic { username, .. } => f
145                .debug_struct("Basic")
146                .field("username", username)
147                .field("password", &"***")
148                .finish(),
149            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
150        }
151    }
152}
153
154/// Camel options that should NOT be forwarded as HTTP query params
155const HTTP_CAMEL_OPTIONS: &[&str] = &[
156    "httpMethod",
157    "throwExceptionOnFailure",
158    "okStatusCodeRange",
159    "followRedirects",
160    "maxRedirects",
161    "connectTimeout",
162    "responseTimeout",
163    "allowInternal",
164    "blockedHosts",
165    "maxBodySize",
166    "readTimeout",
167    "maxResponseBytes",
168    "authMethod",
169    "authUsername",
170    "authPassword",
171    "authBearerToken",
172    "userAgent",
173    "cookieHandling",
174    "bridgeEndpoint",
175    "connectionClose",
176    "skipRequestHeaders",
177    "skipResponseHeaders",
178];
179
180impl UriConfig for HttpEndpointConfig {
181    /// Returns "http" as the primary scheme (also accepts "https")
182    fn scheme() -> &'static str {
183        "http"
184    }
185
186    fn from_uri(uri: &str) -> Result<Self, CamelError> {
187        let parts = parse_uri(uri)?;
188        Self::from_components(parts)
189    }
190
191    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
192        // Validate scheme - accept both http and https
193        if parts.scheme != "http" && parts.scheme != "https" {
194            return Err(CamelError::InvalidUri(format!(
195                "expected scheme 'http' or 'https', got '{}'",
196                parts.scheme
197            )));
198        }
199
200        // Construct base_url from scheme + path
201        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
202        let base_url = format!("{}:{}", parts.scheme, parts.path);
203
204        let http_method = parts.params.get("httpMethod").cloned();
205
206        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
207            Some(v) => parse_bool_param_http(v).map_err(|e| {
208                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
209            })?,
210            None => true,
211        };
212
213        // Parse status code range from "start-end" format (e.g., "200-299")
214        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
215            Some(v) => parse_ok_status_code_range(v)?,
216            None => (200, 299),
217        };
218
219        let response_timeout = match parts.params.get("responseTimeout") {
220            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
221                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
222            })?),
223            None => None,
224        };
225
226        // SSRF protection settings
227        let allow_internal = match parts.params.get("allowInternal") {
228            Some(v) => parse_bool_param_http(v).map_err(|e| {
229                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
230            })?,
231            None => false, // Default: block private IPs
232        };
233
234        // Parse comma-separated blocked hosts
235        let blocked_hosts = parts
236            .params
237            .get("blockedHosts")
238            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
239            .unwrap_or_default();
240
241        let max_body_size = match parts.params.get("maxBodySize") {
242            Some(v) => v.parse::<usize>().map_err(|e| {
243                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
244            })?,
245            None => 10 * 1024 * 1024, // Default: 10MB
246        };
247
248        let read_timeout_ms = match parts.params.get("readTimeout") {
249            Some(v) => v.parse::<u64>().map_err(|e| {
250                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
251            })?,
252            None => 30_000, // Default: 30s
253        };
254
255        let max_response_bytes = match parts.params.get("maxResponseBytes") {
256            Some(v) => v.parse::<usize>().map_err(|e| {
257                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
258            })?,
259            None => 10 * 1024 * 1024, // Default: 10MB
260        };
261
262        let auth = parse_auth_from_params(&parts.params)?;
263
264        let user_agent = parts.params.get("userAgent").cloned();
265
266        if parts.params.contains_key("cookieHandling") {
267            return Err(CamelError::InvalidUri(
268                "cookieHandling is not supported".into(),
269            ));
270        }
271
272        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
273            Some(v) => parse_bool_param_http(v).map_err(|e| {
274                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
275            })?,
276            None => false,
277        };
278
279        let connection_close = match parts.params.get("connectionClose") {
280            Some(v) => parse_bool_param_http(v).map_err(|e| {
281                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
282            })?,
283            None => false,
284        };
285
286        let skip_request_headers = parts
287            .params
288            .get("skipRequestHeaders")
289            .map(|v| {
290                v.split(',')
291                    .map(str::trim)
292                    .filter(|s| !s.is_empty())
293                    .map(|s| s.to_ascii_lowercase())
294                    .collect::<Vec<_>>()
295            })
296            .unwrap_or_default();
297
298        let skip_response_headers = parts
299            .params
300            .get("skipResponseHeaders")
301            .map(|v| {
302                v.split(',')
303                    .map(str::trim)
304                    .filter(|s| !s.is_empty())
305                    .map(|s| s.to_ascii_lowercase())
306                    .collect::<Vec<_>>()
307            })
308            .unwrap_or_default();
309
310        let follow_redirects = match parts.params.get("followRedirects") {
311            Some(v) => parse_bool_param_http(v).map_err(|e| {
312                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
313            })?,
314            None => false,
315        };
316
317        let max_redirects = match parts.params.get("maxRedirects") {
318            Some(v) => v.parse::<usize>().map_err(|e| {
319                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
320            })?,
321            None => 10,
322        };
323
324        // Collect remaining params (not Camel options) as query params
325        let query_params: HashMap<String, String> = parts
326            .params
327            .into_iter()
328            .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
329            .collect();
330
331        Ok(Self {
332            base_url,
333            http_method,
334            throw_exception_on_failure,
335            ok_status_code_range,
336            response_timeout,
337            query_params,
338            allow_internal,
339            blocked_hosts,
340            max_body_size,
341            read_timeout_ms,
342            max_response_bytes,
343            auth,
344            token_provider: None,
345            user_agent,
346            bridge_endpoint,
347            connection_close,
348            skip_request_headers,
349            skip_response_headers,
350            follow_redirects,
351            max_redirects,
352        })
353    }
354}
355
356/// Private container for macro-derived `uri_options()` and `metadata()`.
357///
358/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
359/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
360/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
361/// derivation targets this inner type whose fields are all URI-param-compatible.
362#[derive(Debug, Clone, UriConfig)]
363#[allow(dead_code)]
364#[uri_scheme = "http"]
365#[uri_config(
366    skip_impl,
367    metadata(
368        scheme = "http",
369        description = "HTTP client and server component",
370        producer,
371        consumer,
372        streaming
373    ),
374    crate = "camel_component_api"
375)]
376struct HttpEndpointUriConfig {
377    #[allow(dead_code)]
378    _base_url: String,
379
380    #[uri_param(
381        name = "httpMethod",
382        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
383    )]
384    http_method: Option<String>,
385
386    #[uri_param(
387        name = "throwExceptionOnFailure",
388        default = "true",
389        desc = "Throw on non-2xx status"
390    )]
391    throw_exception_on_failure: bool,
392
393    #[uri_param(
394        name = "okStatusCodeRange",
395        default = "200-299",
396        desc = "Success status code range"
397    )]
398    ok_status_code_range: String,
399
400    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
401    response_timeout: Option<u64>,
402
403    #[uri_param(
404        name = "allowInternal",
405        default = "false",
406        desc = "Allow private/internal network destinations (SSRF)"
407    )]
408    allow_internal: bool,
409
410    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
411    blocked_hosts: Option<String>,
412
413    #[uri_param(
414        name = "maxBodySize",
415        default = "10485760",
416        desc = "Max request/response body bytes"
417    )]
418    max_body_size: u64,
419
420    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
421    read_timeout: Option<u64>,
422
423    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
424    max_response_bytes: Option<u64>,
425
426    #[uri_param(
427        name = "authMethod",
428        kind = "enum:Basic,Bearer",
429        desc = "Authentication method"
430    )]
431    auth_method: Option<String>,
432
433    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
434    auth_username: Option<String>,
435
436    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
437    auth_password: Option<String>,
438
439    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
440    auth_bearer_token: Option<String>,
441
442    #[uri_param(name = "userAgent", desc = "User-Agent header")]
443    user_agent: Option<String>,
444
445    #[uri_param(
446        name = "bridgeEndpoint",
447        default = "false",
448        desc = "Bridge endpoint mode"
449    )]
450    bridge_endpoint: bool,
451
452    #[uri_param(
453        name = "connectionClose",
454        default = "false",
455        desc = "Send Connection: close"
456    )]
457    connection_close: bool,
458
459    #[uri_param(
460        name = "skipRequestHeaders",
461        desc = "Comma-separated request headers to skip"
462    )]
463    skip_request_headers: Option<String>,
464
465    #[uri_param(
466        name = "skipResponseHeaders",
467        desc = "Comma-separated response headers to skip"
468    )]
469    skip_response_headers: Option<String>,
470
471    #[uri_param(
472        name = "followRedirects",
473        default = "false",
474        desc = "Follow HTTP redirects"
475    )]
476    follow_redirects: bool,
477
478    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
479    max_redirects: u64,
480}
481
482impl HttpEndpointConfig {
483    /// Component metadata for the http/https scheme, derived from the
484    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
485    pub fn metadata() -> ComponentMetadata {
486        HttpEndpointUriConfig::metadata()
487    }
488
489    /// URI option definitions, derived from `#[uri_param]` fields.
490    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
491        HttpEndpointUriConfig::uri_options()
492    }
493}
494
495fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
496    let Some(method) = params.get("authMethod") else {
497        return Ok(HttpAuth::None);
498    };
499
500    if method.eq_ignore_ascii_case("none") {
501        return Ok(HttpAuth::None);
502    }
503
504    if method.eq_ignore_ascii_case("basic") {
505        let username = params.get("authUsername").cloned().ok_or_else(|| {
506            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
507        })?;
508        let password = params.get("authPassword").cloned().ok_or_else(|| {
509            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
510        })?;
511        return Ok(HttpAuth::Basic { username, password });
512    }
513
514    if method.eq_ignore_ascii_case("bearer") {
515        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
516            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
517        })?;
518        return Ok(HttpAuth::Bearer { token });
519    }
520
521    Err(CamelError::InvalidUri(format!(
522        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
523    )))
524}
525
526fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
527    match value.to_ascii_lowercase().as_str() {
528        "true" | "1" | "yes" => Ok(true),
529        "false" | "0" | "no" => Ok(false),
530        _ => Err(CamelError::InvalidUri(format!(
531            "invalid boolean value: '{value}'"
532        ))),
533    }
534}
535
536impl HttpEndpointConfig {
537    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
538        let parts = parse_uri(uri)?;
539        let mut endpoint = Self::from_components(parts.clone())?;
540        if endpoint.response_timeout.is_none() {
541            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
542        }
543        if !parts.params.contains_key("allowInternal") {
544            endpoint.allow_internal = config.allow_internal;
545        }
546        if !parts.params.contains_key("blockedHosts") {
547            endpoint.blocked_hosts = config.blocked_hosts.clone();
548        }
549        if !parts.params.contains_key("maxBodySize") {
550            endpoint.max_body_size = config.max_body_size;
551        }
552        if !parts.params.contains_key("readTimeout") {
553            endpoint.read_timeout_ms = config.read_timeout_ms;
554        }
555        if !parts.params.contains_key("maxResponseBytes") {
556            endpoint.max_response_bytes = config.max_response_bytes;
557        }
558        if !parts.params.contains_key("okStatusCodeRange")
559            && let Some(range) = &config.ok_status_code_range
560        {
561            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
562        }
563        if !parts.params.contains_key("followRedirects") {
564            endpoint.follow_redirects = config.follow_redirects;
565        }
566        if !parts.params.contains_key("maxRedirects") {
567            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
568        }
569
570        Ok(endpoint)
571    }
572}
573
574// ---------------------------------------------------------------------------
575// HttpServerConfig
576// ---------------------------------------------------------------------------
577
578/// Configuration for an HTTP server (consumer) endpoint.
579#[derive(Debug, Clone)]
580pub struct HttpServerConfig {
581    /// URI scheme ("http" or "https") parsed from the endpoint URI.
582    pub scheme: String,
583    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
584    pub host: String,
585    /// TCP port to listen on.
586    pub port: u16,
587    /// URL path this consumer handles, e.g. "/orders".
588    pub path: String,
589    /// Maximum request body size in bytes.
590    pub max_request_body: usize,
591    /// Maximum response body size for materializing streams in bytes.
592    pub max_response_body: usize,
593    /// Maximum number of in-flight requests handled concurrently by this server.
594    pub max_inflight_requests: usize,
595    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
596    /// the consumer registers as a method-aware REST endpoint and the
597    /// path is treated as a template (e.g. `/users/{id}` is matched
598    /// against any `/users/<value>`). When `None`, the consumer
599    /// registers in the legacy path-only `api_routes` registry.
600    /// Extracted from the `httpMethod=` URI param at config build time.
601    pub method: Option<String>,
602    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
603    /// `None` for plain HTTP servers.
604    pub tls_config: Option<crate::config::ServerTlsConfig>,
605}
606
607impl UriConfig for HttpServerConfig {
608    /// Returns "http" as the primary scheme (also accepts "https")
609    fn scheme() -> &'static str {
610        "http"
611    }
612
613    fn from_uri(uri: &str) -> Result<Self, CamelError> {
614        let parts = parse_uri(uri)?;
615        Self::from_components(parts)
616    }
617
618    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
619        // Validate scheme - accept both http and https
620        if parts.scheme != "http" && parts.scheme != "https" {
621            return Err(CamelError::InvalidUri(format!(
622                "expected scheme 'http' or 'https', got '{}'",
623                parts.scheme
624            )));
625        }
626
627        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
628        // Strip leading "//"
629        let authority_and_path = parts.path.trim_start_matches('/');
630
631        // Split on the first "/" to separate "host:port" from "/path"
632        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
633            (&authority_and_path[..idx], &authority_and_path[idx..])
634        } else {
635            (authority_and_path, "/")
636        };
637
638        let path = if path_suffix.is_empty() {
639            "/"
640        } else {
641            path_suffix
642        }
643        .to_string();
644
645        // Parse host:port from authority
646        let (host, port) = if let Some(colon) = authority.rfind(':') {
647            let port_str = &authority[colon + 1..];
648            match port_str.parse::<u16>() {
649                Ok(p) => (authority[..colon].to_string(), p),
650                Err(_) => {
651                    return Err(CamelError::InvalidUri(format!(
652                        "invalid port '{}' in authority",
653                        port_str
654                    )));
655                }
656            }
657        } else {
658            // Default port based on scheme: 443 for https, 80 for http
659            let default_port = if parts.scheme == "https" { 443 } else { 80 };
660            (authority.to_string(), default_port)
661        };
662
663        let max_request_body = parts
664            .params
665            .get("maxRequestBody")
666            .and_then(|v| v.parse::<usize>().ok())
667            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
668
669        let max_response_body = parts
670            .params
671            .get("maxResponseBody")
672            .and_then(|v| v.parse::<usize>().ok())
673            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
674
675        let max_inflight_requests = parts
676            .params
677            .get("maxInflightRequests")
678            .and_then(|v| v.parse::<usize>().ok())
679            .unwrap_or(1024);
680
681        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
682        // uppercase method the dispatcher compares against (axum's
683        // `req.method().to_string()` yields "GET"). Without this, a
684        // lower-case `httpMethod` would never match and silently 404.
685        // Review I5.
686        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
687
688        Ok(Self {
689            scheme: parts.scheme,
690            host,
691            port,
692            path,
693            max_request_body,
694            max_response_body,
695            max_inflight_requests,
696            method,
697            tls_config: {
698                let cert = parts.params.get("tlsCert").cloned();
699                let key = parts.params.get("tlsKey").cloned();
700                match (cert, key) {
701                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
702                        cert_path: c,
703                        key_path: k,
704                    }),
705                    (None, None) => None,
706                    _ => None, // partial — enforced in create_consumer, not here
707                }
708            },
709        })
710    }
711}
712
713impl HttpServerConfig {
714    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
715        let parts = parse_uri(uri)?;
716        let mut server = Self::from_components(parts.clone())?;
717        if !parts.params.contains_key("maxRequestBody") {
718            server.max_request_body = config.max_request_body;
719        }
720        if !parts.params.contains_key("maxResponseBody") {
721            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
722            server.max_response_body = config.max_body_size;
723        }
724        Ok(server)
725    }
726}
727
728// ---------------------------------------------------------------------------
729// RequestEnvelope / HttpReply
730// ---------------------------------------------------------------------------
731
732/// Body of the HTTP response: already-materialized bytes or a lazy stream.
733///
734/// **Internal plumbing** — subject to change without notice.
735pub enum HttpReplyBody {
736    Bytes(bytes::Bytes),
737    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
738}
739
740/// An inbound HTTP request sent from the Axum dispatch handler to an
741/// `HttpConsumer` receive loop.
742///
743/// **Internal plumbing** — subject to change without notice.
744pub struct RequestEnvelope {
745    pub method: String,
746    pub path: String,
747    pub query: String,
748    pub headers: http::HeaderMap,
749    pub body: StreamBody,
750    /// Path parameters extracted from a REST template match, e.g.
751    /// `id=42` for a request to `/users/42` matched against
752    /// `/users/{id}`. Empty for non-REST requests or for literal
753    /// template matches. The consumer turns these into
754    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
755    pub path_params: std::collections::HashMap<String, String>,
756    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
757}
758
759/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
760///
761/// **Internal plumbing** — subject to change without notice.
762pub struct HttpReply {
763    pub status: u16,
764    pub headers: Vec<(String, String)>,
765    pub body: HttpReplyBody,
766}
767
768// ---------------------------------------------------------------------------
769// HttpRouteRegistry / ServerRegistry
770// ---------------------------------------------------------------------------
771
772type ServerKey = (String, u16);
773
774/// Handle to a running Axum server on one interface/port.
775struct ServerHandle {
776    registry: HttpRouteRegistry,
777    max_request_body: usize,
778    max_response_body: usize,
779    max_inflight_requests: usize,
780    is_tls: bool,
781    tls_cert_path: Option<String>,
782    tls_key_path: Option<String>,
783    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
784    /// dead-server eviction signal in `get_or_spawn`.
785    monitor_task: tokio::task::JoinHandle<()>,
786    // Retained so the reload handler (Task 7) can call reload_from_config()
787    // to hot-swap certs without restarting the server.
788    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
789    tls_source: Option<ServerTlsSource>,
790}
791
792/// Process-global registry mapping (host, port) → running Axum server handle.
793pub struct ServerRegistry {
794    inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
795}
796
797impl ServerRegistry {
798    /// Returns the global singleton.
799    pub fn global() -> &'static Self {
800        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
801        INSTANCE.get_or_init(|| ServerRegistry {
802            inner: Mutex::new(HashMap::new()),
803        })
804    }
805
806    /// Returns route registry for `port`, spawning new Axum server if
807    /// none is running on that port yet.
808    #[allow(clippy::too_many_arguments)]
809    pub async fn get_or_spawn(
810        &'static self,
811        host: &str,
812        port: u16,
813        max_request_body: usize,
814        max_response_body: usize,
815        max_inflight_requests: usize,
816        runtime: Arc<dyn RuntimeObservability>,
817        route_id: String,
818        tls_config: Option<crate::config::ServerTlsConfig>,
819    ) -> Result<HttpRouteRegistry, CamelError> {
820        let host_owned = host.to_string();
821
822        let cell = {
823            let mut guard = self.inner.lock().map_err(|_| {
824                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
825            })?;
826            let key = (host.to_string(), port);
827            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
828            // The monitor task awaits the server task, so monitor_task.is_finished()
829            // is a reliable proxy for the server being gone (either crashed or aborted).
830            if let Some(existing) = guard.get(&key)
831                && let Some(handle) = existing.get()
832                && handle.monitor_task.is_finished()
833            {
834                // Deregister TLS reload handler so a respawned HTTPS server
835                // doesn't reload stale cert config from the crashed handler.
836                if handle.is_tls {
837                    let scheme = if handle.is_tls { "https" } else { "http" };
838                    camel_component_api::tls_source::TlsReloadRegistry::global()
839                        .unregister(scheme, host, port);
840                }
841                guard.remove(&key);
842            }
843            guard
844                .entry(key)
845                .or_insert_with(|| Arc::new(OnceCell::new()))
846                .clone()
847        };
848
849        if let Some(existing) = cell.get()
850            && existing.max_request_body != max_request_body
851        {
852            return Err(CamelError::EndpointCreationFailed(format!(
853                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
854                existing.max_request_body, max_request_body
855            )));
856        }
857
858        if let Some(existing) = cell.get()
859            && existing.max_response_body != max_response_body
860        {
861            return Err(CamelError::EndpointCreationFailed(format!(
862                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
863                existing.max_response_body, max_response_body
864            )));
865        }
866
867        if let Some(existing) = cell.get()
868            && existing.max_inflight_requests != max_inflight_requests
869        {
870            return Err(CamelError::EndpointCreationFailed(format!(
871                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
872                existing.max_inflight_requests, max_inflight_requests
873            )));
874        }
875
876        // TLS mode mismatch: plain vs TLS
877        if let Some(existing) = cell.get()
878            && existing.is_tls != tls_config.is_some()
879        {
880            return Err(CamelError::EndpointCreationFailed(format!(
881                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
882                existing.is_tls,
883                tls_config.is_some()
884            )));
885        }
886
887        // TLS cert/key mismatch: different cert on same TLS port
888        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
889            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
890                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
891        {
892            return Err(CamelError::EndpointCreationFailed(format!(
893                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
894            )));
895        }
896
897        let handle = cell
898            .get_or_try_init(|| {
899                let rt = Arc::clone(&runtime);
900                let rid = route_id.clone();
901                async move {
902                    let addr = format!("{host_owned}:{port}");
903                    let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
904                        CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
905                    })?;
906                    let registry = HttpRouteRegistry::new();
907                    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
908                    // Constructed once in the TLS branch so they can be retained
909                    // on ServerHandle for the reload handler (Task 7).
910                    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
911                    let tls_source: Option<ServerTlsSource>;
912                    let server_task = if let Some(ref tls) = tls_config {
913                        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
914                        let source = ServerTlsSource {
915                            cert_path: std::path::PathBuf::from(&tls.cert_path),
916                            key_path: std::path::PathBuf::from(&tls.key_path),
917                            client_ca_path: None,
918                        };
919                        // Build the RustlsConfig once — clone() is cheap (Arc
920                        // internally) and shares the ArcSwap the reload handler
921                        // will mutate via reload_from_config().
922                        let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(
923                            std::sync::Arc::new(rustls_config),
924                        );
925                        tls_rustls_cfg = Some(rustls_cfg.clone());
926                        tls_source = Some(source);
927                        // Convert tokio listener to std for axum-server
928                        let std_listener = listener.into_std().map_err(|e| {
929                            CamelError::EndpointCreationFailed(format!(
930                                "TLS listener conversion: {e}"
931                            ))
932                        })?;
933                        tokio::spawn(run_axum_server_tls(
934                            std_listener,
935                            rustls_cfg,
936                            registry.clone(),
937                            max_request_body,
938                            max_response_body,
939                            Arc::clone(&inflight),
940                            Arc::clone(&rt),
941                            rid.clone(),
942                        ))
943                    } else {
944                        tls_rustls_cfg = None;
945                        tls_source = None;
946                        tokio::spawn(run_axum_server(
947                            listener,
948                            registry.clone(),
949                            max_request_body,
950                            max_response_body,
951                            Arc::clone(&inflight),
952                            Arc::clone(&rt),
953                            rid.clone(),
954                        ))
955                    };
956                    let addr_for_monitor = format!("{host_owned}:{port}");
957                    let monitor_task = tokio::spawn(monitor_axum_task(
958                        server_task,
959                        addr_for_monitor,
960                        Arc::clone(&rt),
961                        rid,
962                    ));
963                    let handle = ServerHandle {
964                        registry,
965                        max_request_body,
966                        max_response_body,
967                        max_inflight_requests,
968                        is_tls: tls_config.is_some(),
969                        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
970                        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
971                        monitor_task,
972                        tls_config: tls_rustls_cfg,
973                        tls_source,
974                    };
975                    // Register reload handler (exactly-once: inside OnceCell init closure).
976                    // Note: HTTP servers are process-lifetime (no release/eviction path),
977                    // so handlers are never unregistered. If eviction is added later,
978                    // add TlsReloadRegistry::global().unregister() there.
979                    if let (Some(tls_cfg), Some(source)) =
980                        (handle.tls_config.as_ref(), handle.tls_source.as_ref())
981                    {
982                        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
983                            tls_cfg.clone(),
984                            source.clone(),
985                            host_owned.clone(),
986                            port,
987                        ));
988                        camel_component_api::tls_source::TlsReloadRegistry::global()
989                            .register(handler);
990                    }
991                    Ok::<ServerHandle, CamelError>(handle)
992                }
993            })
994            .await?;
995
996        Ok(handle.registry.clone())
997    }
998
999    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
1000    /// the server stays in the registry for potential restart. Path
1001    /// deregistration happens separately in the consumer's cleanup.
1002    pub async fn unregister(&self, host: &str, port: u16) {
1003        debug!(
1004            host = host,
1005            port = port,
1006            "consumer unregistered from HTTP server"
1007        );
1008    }
1009
1010    /// Reset the global registry — **test-only**.
1011    ///
1012    /// Clears all registered server handles so that tests can start from a clean
1013    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1014    /// process-global singleton in production and resetting it would break
1015    /// running servers.
1016    #[cfg(test)]
1017    pub fn reset() {
1018        let instance = Self::global();
1019        let mut guard = instance
1020            .inner
1021            .lock()
1022            .expect("ServerRegistry lock poisoned during test reset");
1023        guard.clear();
1024    }
1025}
1026
1027// ---------------------------------------------------------------------------
1028// Axum server
1029// ---------------------------------------------------------------------------
1030
1031use axum::{
1032    Router,
1033    body::Body as AxumBody,
1034    extract::{Request, State},
1035    http::{Response, StatusCode},
1036    response::IntoResponse,
1037};
1038
1039#[derive(Clone)]
1040pub(crate) struct AppState {
1041    registry: HttpRouteRegistry,
1042    max_request_body: usize,
1043    max_response_body: usize,
1044    inflight: Arc<tokio::sync::Semaphore>,
1045}
1046
1047async fn run_axum_server(
1048    listener: tokio::net::TcpListener,
1049    registry: HttpRouteRegistry,
1050    max_request_body: usize,
1051    max_response_body: usize,
1052    inflight: Arc<tokio::sync::Semaphore>,
1053    runtime: Arc<dyn RuntimeObservability>,
1054    route_id: String,
1055) {
1056    let state = AppState {
1057        registry,
1058        max_request_body,
1059        max_response_body,
1060        inflight,
1061    };
1062    let app = Router::new().fallback(dispatch_handler).with_state(state);
1063
1064    axum::serve(listener, app).await.unwrap_or_else(|e| {
1065        runtime
1066            .metrics()
1067            .increment_errors(&route_id, "e:http:accept");
1068        // log-policy: outside-contract
1069        tracing::error!(error = %e, "Axum server error");
1070    });
1071}
1072
1073#[allow(clippy::too_many_arguments)]
1074async fn run_axum_server_tls(
1075    listener: std::net::TcpListener,
1076    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1077    registry: HttpRouteRegistry,
1078    max_request_body: usize,
1079    max_response_body: usize,
1080    inflight: Arc<tokio::sync::Semaphore>,
1081    runtime: Arc<dyn RuntimeObservability>,
1082    route_id: String,
1083) {
1084    let state = AppState {
1085        registry,
1086        max_request_body,
1087        max_response_body,
1088        inflight,
1089    };
1090    let app = Router::new().fallback(dispatch_handler).with_state(state);
1091
1092    // RustlsConfig is now constructed once in get_or_spawn and retained on
1093    // ServerHandle so the reload handler can call reload_from_config() on it.
1094
1095    axum_server::from_tcp_rustls(listener, tls_cfg)
1096        .serve(app.into_make_service())
1097        .await
1098        .unwrap_or_else(|e| {
1099            runtime
1100                .metrics()
1101                .increment_errors(&route_id, "e:http:accept-tls");
1102            // log-policy: outside-contract
1103            tracing::error!(error = %e, "Axum TLS server error");
1104        });
1105}
1106
1107/// Monitors an Axum server task and emits a structured error event if it
1108/// exits unexpectedly.
1109///
1110/// # Limitations
1111/// The HTTP server is shared across all routes on a port. Full per-route
1112/// CrashNotification propagation is deferred — this provides observable
1113/// structured logging as a first guard.
1114async fn monitor_axum_task(
1115    handle: tokio::task::JoinHandle<()>,
1116    addr: String,
1117    runtime: Arc<dyn RuntimeObservability>,
1118    route_id: String,
1119) {
1120    match handle.await {
1121        Ok(()) => {
1122            // Clean exit (process shutdown or normal stop)
1123        }
1124        Err(join_err) => {
1125            runtime
1126                .metrics()
1127                .increment_errors(&route_id, "e:http:server-task-exited");
1128            // log-policy: outside-contract
1129            tracing::error!(
1130                addr = %addr,
1131                error = %join_err,
1132                "Axum server task exited unexpectedly — all routes on this port are now dead"
1133            );
1134        }
1135    }
1136}
1137
1138/// Load a rustls ServerConfig from PEM cert/key files.
1139/// Adapted from camel-ws lib.rs load_tls_config.
1140fn load_tls_config(
1141    cert_path: &str,
1142    key_path: &str,
1143) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1144    use std::fs::File;
1145    use std::io::BufReader;
1146
1147    let cert_file = File::open(cert_path)
1148        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1149    let key_file = File::open(key_path)
1150        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1151
1152    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1153        .collect::<Result<Vec<_>, _>>()
1154        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1155
1156    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1157        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1158        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1159
1160    tokio_rustls::rustls::ServerConfig::builder()
1161        .with_no_client_auth()
1162        .with_single_cert(certs, key)
1163        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1164}
1165
1166async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1167    let path = req.uri().path().to_owned();
1168    let method = req.method().to_string();
1169
1170    // Dispatch precedence (spec §7.2 / ADR-0009):
1171    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1172    //   2. Templated API path match (REST, method-aware, by specificity)
1173    //   3. Static mount longest-prefix
1174    //   4. SPA fallback
1175    //
1176    // Legacy exact runs first: it is a cheap HashMap get, and the two
1177    // registries are mutually exclusive per route — a legacy route carries
1178    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1179    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1180    // exact hit can never shadow a REST route that should have matched,
1181    // and running exact-first honours the documented precedence (the prior
1182    // REST-first order let a templated `GET /api/{resource}` steal a
1183    // request meant for an exact `GET /api/users`). Intra-REST method
1184    // disambiguation is handled inside `match_endpoint`, not by this
1185    // ordering. Review C2.
1186    let api_sender = {
1187        let inner = state.registry.inner.read().await;
1188        inner.api_routes.get(&path).cloned()
1189    }; // lock released BEFORE any IO
1190
1191    let (rest_sender, path_params) = if api_sender.is_some() {
1192        // Exact legacy match won — skip the templated scan entirely.
1193        (None, Default::default())
1194    } else {
1195        let inner = state.registry.inner.read().await;
1196        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1197            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1198            rest_match::MatchOutcome::Ambiguous => {
1199                // Ambiguous registration should have been rejected at
1200                // lowering time (rest.rs). Reaching here means two
1201                // equal-specificity templates matched one request —
1202                // surface a loud error rather than a silent 404. Review C3.
1203                // log-policy: handler-owned
1204                tracing::warn!(
1205                    method = %method,
1206                    path = %path,
1207                    "ambiguous REST template match — returning 500"
1208                );
1209                return Response::builder()
1210                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1211                    .body(AxumBody::from("Internal Server Error"))
1212                    .expect("infallible"); // allow-unwrap
1213            }
1214            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1215        }
1216    }; // lock released BEFORE any IO
1217
1218    let sender = api_sender.or(rest_sender);
1219
1220    if let Some(sender) = sender {
1221        let query = req.uri().query().unwrap_or("").to_string();
1222        let headers = req.headers().clone();
1223
1224        // Check Content-Length against limit BEFORE opening the stream
1225        let content_length: Option<u64> = headers
1226            .get(http::header::CONTENT_LENGTH)
1227            .and_then(|v| v.to_str().ok())
1228            .and_then(|s| s.parse().ok());
1229
1230        if let Some(len) = content_length
1231            && len > state.max_request_body as u64
1232        {
1233            return Response::builder()
1234                .status(StatusCode::PAYLOAD_TOO_LARGE)
1235                .body(AxumBody::from("Request body exceeds configured limit"))
1236                .expect("infallible"); // allow-unwrap
1237        }
1238
1239        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1240            Ok(permit) => permit,
1241            Err(_) => {
1242                return Response::builder()
1243                    .status(StatusCode::SERVICE_UNAVAILABLE)
1244                    .body(AxumBody::from("Service Unavailable"))
1245                    .expect("infallible"); // allow-unwrap
1246            }
1247        };
1248
1249        // Build StreamBody from Axum body WITHOUT materializing
1250        let content_type = headers
1251            .get(http::header::CONTENT_TYPE)
1252            .and_then(|v| v.to_str().ok())
1253            .map(|s| s.to_string());
1254
1255        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1256        let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
1257        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
1258
1259        let stream_body = StreamBody {
1260            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1261            metadata: StreamMetadata {
1262                size_hint: content_length,
1263                content_type,
1264                origin: None,
1265            },
1266        };
1267
1268        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1269        let envelope = RequestEnvelope {
1270            method,
1271            path,
1272            query,
1273            headers,
1274            body: stream_body,
1275            path_params,
1276            reply_tx,
1277        };
1278
1279        if sender.send(envelope).await.is_err() {
1280            return Response::builder()
1281                .status(StatusCode::SERVICE_UNAVAILABLE)
1282                .body(AxumBody::from("Consumer unavailable"))
1283                .expect("infallible"); // allow-unwrap
1284        }
1285
1286        match reply_rx.await {
1287            Ok(reply) => {
1288                let reply = match reply.body {
1289                    HttpReplyBody::Bytes(b)
1290                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1291                    {
1292                        HttpReply {
1293                            status: 500,
1294                            headers: vec![],
1295                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1296                                "Response body exceeds configured limit",
1297                            )),
1298                        }
1299                    }
1300                    _ => reply,
1301                };
1302
1303                let status =
1304                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1305                let mut builder = Response::builder().status(status);
1306                for (k, v) in &reply.headers {
1307                    builder = builder.header(k.as_str(), v.as_str());
1308                }
1309                match reply.body {
1310                    HttpReplyBody::Bytes(b) => {
1311                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1312                            Response::builder()
1313                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1314                                .body(AxumBody::from("Invalid response headers from consumer"))
1315                                .expect("infallible") // allow-unwrap
1316                        })
1317                    }
1318                    HttpReplyBody::Stream(stream) => builder
1319                        .body(AxumBody::from_stream(stream))
1320                        .unwrap_or_else(|_| {
1321                            Response::builder()
1322                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1323                                .body(AxumBody::from("Invalid response headers from consumer"))
1324                                .expect("infallible") // allow-unwrap
1325                        }),
1326                }
1327            }
1328            Err(_) => Response::builder()
1329                .status(StatusCode::INTERNAL_SERVER_ERROR)
1330                .body(AxumBody::from("Pipeline error"))
1331                .expect("infallible"), // allow-unwrap
1332        }
1333    } else {
1334        // No API route matched — try static mounts
1335        static_dispatch::dispatch_static(&state, req, &path).await
1336    }
1337}
1338
1339fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1340    len > max
1341}
1342
1343fn title_case_header(name: &str) -> String {
1344    name.split('-')
1345        .map(|part| {
1346            let mut chars = part.chars();
1347            match chars.next() {
1348                None => String::new(),
1349                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1350            }
1351        })
1352        .collect::<Vec<_>>()
1353        .join("-")
1354}
1355
1356// ---------------------------------------------------------------------------
1357// HttpConsumer
1358// ---------------------------------------------------------------------------
1359
1360/// Kernel authentication state captured from a route's [`SecurityContext`]
1361/// (`unify-transport-auth`, Task 2.9).
1362///
1363/// Same construction-order lifecycle as gRPC's `GrpcKernelAuth` (Task 2.1):
1364/// the compiled plan and the provider registry arrive via
1365/// `Consumer::set_security_context` before `start()` accepts requests. A
1366/// context lacking either piece keeps `kernel = None` — a plan without
1367/// providers can never mint a principal (fail-closed, never a silently
1368/// unauthenticated route: the controller's strict-mode dispatch check then
1369/// denies carrier-less Exchanges on non-Public plans).
1370pub(crate) struct HttpKernelAuth {
1371    pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1372    pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1373}
1374
1375impl HttpKernelAuth {
1376    /// Capture the kernel state from a route's security context.
1377    ///
1378    /// `None` unless both the compiled plan and the provider registry are
1379    /// present.
1380    pub(crate) fn from_security_context(
1381        ctx: &camel_component_api::SecurityContext,
1382    ) -> Option<Self> {
1383        Some(Self {
1384            plan: ctx.plan.clone()?,
1385            providers: ctx.providers.clone()?,
1386        })
1387    }
1388}
1389
1390/// Capacity for the per-route RequestEnvelope channel.
1391///
1392/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1393/// permit from before `send()` until its reply, so at most N envelopes can be
1394/// outstanding at any time. A buffer of N therefore can never fill before the
1395/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1396/// and the semaphore stays the single, URI-configurable backpressure point.
1397/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1398/// (rc-3y6j: 64 vs default 1024 permits).
1399///
1400/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1401/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1402/// start panic-free (the empty semaphore still 503s every request).
1403fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1404    max_inflight_requests.max(1)
1405}
1406
1407pub struct HttpConsumer {
1408    config: HttpServerConfig,
1409    /// Runtime observability handle for ADR-0012 metrics and health calls.
1410    runtime: Arc<dyn RuntimeObservability>,
1411    /// Kernel authentication state (plan + providers), set via
1412    /// `set_security_context` before `start()` (Task 2.9). `None` for routes
1413    /// without route-level security (Public under the per-bind gate).
1414    kernel: Option<Arc<HttpKernelAuth>>,
1415}
1416
1417impl HttpConsumer {
1418    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1419        Self {
1420            config,
1421            runtime,
1422            kernel: None,
1423        }
1424    }
1425}
1426
1427#[async_trait::async_trait]
1428impl Consumer for HttpConsumer {
1429    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1430        use camel_component_api::{Body, Exchange, Message};
1431
1432        let registry = ServerRegistry::global()
1433            .get_or_spawn(
1434                &self.config.host,
1435                self.config.port,
1436                self.config.max_request_body,
1437                self.config.max_response_body,
1438                self.config.max_inflight_requests,
1439                self.runtime.clone(),
1440                ctx.route_id().to_string(),
1441                self.config.tls_config.clone(),
1442            )
1443            .await?;
1444
1445        // Create channel for this path and register it. Capacity matches the
1446        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1447        // the channel can never become a second backpressure point.
1448        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1449            envelope_channel_capacity(self.config.max_inflight_requests),
1450        );
1451        // When the from-URI carries `httpMethod=...` (REST-lowered
1452        // route), register the consumer as a method-aware REST endpoint
1453        // so the dispatcher can route by (method, path template).
1454        // Otherwise fall back to the legacy path-only api_routes
1455        // registry. The two registries never overlap for the same
1456        // route: each consumer registers in exactly one of them.
1457        if let Some(method) = self.config.method.clone() {
1458            let segments = rest_match::parse_path_template(&self.config.path);
1459            registry
1460                .register_rest_endpoint(method, segments, env_tx)
1461                .await;
1462        } else {
1463            registry
1464                .register_api_route(self.config.path.clone(), env_tx)
1465                .await;
1466        }
1467
1468        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1469        // (inside get_or_spawn above), (2) the axum server task was spawned,
1470        // and (3) this route's path/REST endpoint was registered. At this
1471        // point the listener is genuinely accepting connections and any
1472        // request to this route will be dispatched (not 404'd). The runtime
1473        // uses this signal to publish RouteStarted and to release
1474        // ctx.start() so external benchmarks can emit a reliable
1475        // listener-bound marker.
1476        ctx.mark_ready();
1477
1478        let path = self.config.path.clone();
1479        let registry_for_cleanup = registry.clone();
1480        let cancel_token = ctx.cancel_token();
1481        let kernel = self.kernel.clone();
1482        loop {
1483            tokio::select! {
1484                _ = ctx.cancelled() => {
1485                    break;
1486                }
1487                envelope = env_rx.recv() => {
1488                    let Some(envelope) = envelope else { break; };
1489
1490                    // Build Exchange from HTTP request
1491                    let mut msg = Message::default();
1492
1493                    // Set standard Camel HTTP headers
1494                    msg.set_header("CamelHttpMethod",
1495                        serde_json::Value::String(envelope.method.clone()));
1496                    msg.set_header("CamelHttpPath",
1497                        serde_json::Value::String(envelope.path.clone()));
1498                    msg.set_header("CamelHttpQuery",
1499                        serde_json::Value::String(envelope.query.clone()));
1500
1501                    // Set path-parameter headers from REST template
1502                    // match. Expert guidance E2: the consumer is
1503                    // responsible for translating the dispatcher's
1504                    // matched params into `CamelHttpPath_<param>`
1505                    // headers on the Exchange, matching the convention
1506                    // used by Camel HTTP for templated routes.
1507                    for (param_name, param_value) in &envelope.path_params {
1508                        msg.set_header(
1509                            format!("CamelHttpPath_{param_name}"),
1510                            serde_json::Value::String(param_value.clone()),
1511                        );
1512                    }
1513
1514                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1515                    for (k, v) in &envelope.headers {
1516                        if let Ok(val_str) = v.to_str() {
1517                            msg.set_header(
1518                                title_case_header(k.as_str()),
1519                                serde_json::Value::String(val_str.to_string()),
1520                            );
1521                        }
1522                    }
1523
1524                    // Body: always arrives as Body::Stream (native streaming)
1525                    // Routes can call into_bytes() if they need to materialize
1526                    msg.body = Body::Stream(envelope.body);
1527
1528                    #[allow(unused_mut)]
1529                    let mut exchange = Exchange::new(msg);
1530
1531                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1532                    #[cfg(feature = "otel")]
1533                    {
1534                        let headers: HashMap<String, String> = envelope
1535                            .headers
1536                            .iter()
1537                            .filter_map(|(k, v)| {
1538                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1539                            })
1540                            .collect();
1541                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1542                    }
1543
1544                    let reply_tx = envelope.reply_tx;
1545                    let sender = ctx.sender().clone();
1546                    let path_clone = path.clone();
1547                    let cancel = cancel_token.clone();
1548                    // Task 2.9 boundary-auth inputs: the raw header map and
1549                    // the request URI (path + query) feed kernel credential
1550                    // extraction inside the per-request task.
1551                    let auth_headers = envelope.headers.clone();
1552                    let auth_uri: http::Uri = {
1553                        let full = if envelope.query.is_empty() {
1554                            envelope.path.clone()
1555                        } else {
1556                            format!("{}?{}", envelope.path, envelope.query)
1557                        };
1558                        // A malformed path cannot become a valid `Uri`; the
1559                        // empty default then carries no credentials, so
1560                        // extraction finds nothing and authn fails closed.
1561                        full.parse().unwrap_or_default()
1562                    };
1563                    let kernel = kernel.clone();
1564
1565                    // Spawn a task to handle this request concurrently
1566                    //
1567                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1568                    // true concurrent request processing. This change was introduced as part of the
1569                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1570                    //
1571                    // Rationale:
1572                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1573                    //    the consumer's main loop until the pipeline processing completes
1574                    // 2. This blocking would prevent multiple HTTP requests from being processed
1575                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1576                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1577                    //    defeating the purpose of pipeline-side concurrency
1578                    // 4. By spawning a task per request, we allow the consumer loop to continue
1579                    //    accepting new requests while existing ones are processed in the pipeline
1580                    //
1581                    // This approach effectively decouples request acceptance from pipeline processing,
1582                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1583                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1584                    tokio::spawn(async move {
1585                        // Check for cancellation before sending to pipeline.
1586                        // Returns 503 (Service Unavailable) instead of letting the request
1587                        // enter a shutting-down pipeline. This is a behavioral change from
1588                        // the pre-concurrency implementation where cancellation during
1589                        // processing would result in a 500 (Internal Server Error).
1590                        // 503 is more semantically correct: the server is temporarily
1591                        // unable to handle the request due to shutdown.
1592                        if cancel.is_cancelled() {
1593                            let _ = reply_tx.send(HttpReply {
1594                                status: 503,
1595                                headers: vec![],
1596                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1597                            });
1598                            return;
1599                        }
1600
1601                        // ADR-0061 Task 2.9: kernel authentication at the
1602                        // request boundary. A `Public` plan passes through
1603                        // with no extraction; any other mode extracts per
1604                        // the plan's sources, authenticates through the
1605                        // kernel, and installs the typed carrier BEFORE the
1606                        // pipeline runs. A denial renders in the HTTP idiom
1607                        // (401 via `pipeline_error_to_reply`) and the route
1608                        // body never sees the request.
1609                        if let Some(kernel) = kernel.as_ref()
1610                            && !matches!(
1611                                kernel.plan.access_mode,
1612                                camel_api::security_policy::AccessMode::Public
1613                            )
1614                        {
1615                            let principal = match camel_auth::extract_token_multi(
1616                                &auth_headers,
1617                                &auth_uri,
1618                                &kernel.plan.credential_sources,
1619                            ) {
1620                                Some(extracted) => {
1621                                    match camel_auth::kernel_authenticate(
1622                                        &kernel.plan,
1623                                        &kernel.providers,
1624                                        &extracted,
1625                                    )
1626                                    .await
1627                                    {
1628                                        Ok(principal) => principal,
1629                                        Err(e) => {
1630                                            // log-policy: handler-owned
1631                                            tracing::warn!(
1632                                                path = %path_clone,
1633                                                error = %e,
1634                                                "HTTP request authentication failed"
1635                                            );
1636                                            let _ = reply_tx.send(pipeline_error_to_reply(
1637                                                e,
1638                                                &path_clone,
1639                                            ));
1640                                            return;
1641                                        }
1642                                    }
1643                                }
1644                                None => {
1645                                    // log-policy: handler-owned
1646                                    tracing::warn!(
1647                                        path = %path_clone,
1648                                        "HTTP request rejected: no credential found in any source"
1649                                    );
1650                                    let _ = reply_tx.send(pipeline_error_to_reply(
1651                                        CamelError::Unauthenticated(
1652                                            "no credential found in any source".to_string(),
1653                                        ),
1654                                        &path_clone,
1655                                    ));
1656                                    return;
1657                                }
1658                            };
1659                            camel_auth::install_carrier(&mut exchange, &principal);
1660                        }
1661
1662                        // Send through pipeline and await result
1663                        let (tx, rx) = tokio::sync::oneshot::channel();
1664                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
1665                            exchange,
1666                            reply_tx: Some(tx),
1667                        };
1668
1669                        let result = match sender.send(envelope).await {
1670                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1671                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1672                        }
1673                        .and_then(|r| r);
1674
1675                        let reply = match result {
1676                            Ok(out) => {
1677                                let status = out
1678                                    .input
1679                                    .header("CamelHttpResponseCode")
1680                                    .and_then(|v| {
1681                                        let raw = v.as_u64()
1682                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1683                                        let code = raw as u16;
1684                                        (100..1000).contains(&code).then_some(code)
1685                                    })
1686                                    .unwrap_or(200);
1687
1688                                let user_content_type = out
1689                                    .input
1690                                    .header("Content-Type")
1691                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
1692
1693                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1694                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1695                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1696                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1697                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1698                                        v.to_string().into_bytes(),
1699                                    )), Some("application/json".to_string())),
1700                                    Body::Stream(s) => {
1701                                        let ct = s.metadata.content_type.clone();
1702                                        match s.stream.lock().await.take() {
1703                                            Some(stream) => (
1704                                                HttpReplyBody::Stream(stream),
1705                                                ct,
1706                                            ),
1707                                            None => {
1708                                                // log-policy: system-broken
1709                                                tracing::error!(
1710                                                    "Body::Stream already consumed before HTTP reply — returning 500"
1711                                                );
1712                                                let error_reply = HttpReply {
1713                                                    status: 500,
1714                                                    headers: vec![],
1715                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1716                                                };
1717                                                if reply_tx.send(error_reply).is_err() {
1718                                                    debug!("reply_tx dropped before error reply could be sent");
1719                                                }
1720                                                return;
1721                                            }
1722                                        }
1723                                    }
1724                                    // Empty and future variants produce an empty reply body.
1725                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1726                                };
1727
1728                                let resp_headers = select_response_headers(
1729                                    &out.input.headers,
1730                                    user_content_type,
1731                                    inferred_content_type,
1732                                );
1733
1734                                HttpReply {
1735                                    status,
1736                                    headers: resp_headers,
1737                                    body: reply_body,
1738                                }
1739                            }
1740                            Err(e) => {
1741                                pipeline_error_to_reply(e, &path_clone)
1742                            }
1743                        };
1744
1745                        // Reply to Axum handler (ignore error if client disconnected)
1746                        let _ = reply_tx.send(reply);
1747                    });
1748                }
1749            }
1750        }
1751
1752        // Deregister this consumer. Mirror the registration choice:
1753        // REST-registered consumers remove their (method, path) endpoint
1754        // WITHOUT touching sibling verbs on the same template (review C1);
1755        // legacy consumers clean up api_routes.
1756        if let Some(method) = &self.config.method {
1757            registry_for_cleanup
1758                .unregister_rest_endpoint(method, &path)
1759                .await;
1760        } else {
1761            registry_for_cleanup.unregister_api_route(&path).await;
1762        }
1763
1764        // D-L10: decrement the shared server's refcount. When the last
1765        // consumer on this (host, port) leaves, the server + monitor tasks
1766        // are aborted and the registry entry is removed.
1767        ServerRegistry::global()
1768            .unregister(&self.config.host, self.config.port)
1769            .await;
1770
1771        Ok(())
1772    }
1773
1774    async fn stop(&mut self) -> Result<(), CamelError> {
1775        Ok(())
1776    }
1777
1778    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1779        camel_component_api::ConcurrencyModel::Concurrent { max: None }
1780    }
1781
1782    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
1783    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
1784    // Opting into Explicit startup makes ctx.start() await the bind+register
1785    // completion so listeners fail fast on bind errors (previously a silent
1786    // background log) and external markers can reliably detect listener-bound
1787    // state.
1788    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
1789        camel_component_api::ConsumerStartupMode::Explicit
1790    }
1791
1792    // Task 2.9: capture the kernel state (compiled plan + provider registry)
1793    // wired by the route controller before start(). See `HttpKernelAuth`.
1794    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
1795        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
1796    }
1797}
1798
1799// ---------------------------------------------------------------------------
1800// HttpComponent / HttpsComponent
1801// ---------------------------------------------------------------------------
1802
1803pub struct HttpComponent {
1804    config: HttpConfig,
1805    pinned_cache: std::sync::Arc<PinnedClientCache>,
1806    client: reqwest::Client,
1807}
1808
1809#[cfg(test)]
1810thread_local! {
1811    static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1812}
1813
1814pub(crate) fn build_client(
1815    config: &HttpConfig,
1816    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1817) -> reqwest::Client {
1818    #[cfg(test)]
1819    BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
1820
1821    let mut builder = reqwest::Client::builder()
1822        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
1823        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1824        .pool_max_idle_per_host(config.pool_max_idle_per_host)
1825        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1826
1827    // Redirects are always handled manually in the producer's send path
1828    // so that each hop can be SSRF-validated. reqwest's built-in redirect
1829    // policy is sync and cannot perform async DNS resolution or SSRF checks.
1830    builder = builder.redirect(reqwest::redirect::Policy::none());
1831
1832    if let Some((host, addrs)) = resolve_override {
1833        builder = builder.resolve_to_addrs(host, addrs);
1834    }
1835
1836    if let Some(tls) = &config.tls
1837        && tls.enabled
1838    {
1839        if tls.insecure || !tls.verify_peer {
1840            // log-policy: handler-owned
1841            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1842            builder = builder.danger_accept_invalid_certs(true);
1843        }
1844
1845        if let Some(ca_path) = &tls.ca_cert_path
1846            && let Ok(ca_bytes) = std::fs::read(ca_path)
1847        {
1848            let cert = reqwest::Certificate::from_pem(&ca_bytes)
1849                .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1850            if let Ok(ca_cert) = cert {
1851                builder = builder.add_root_certificate(ca_cert);
1852            }
1853        }
1854
1855        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1856            && let (Ok(cert_bytes), Ok(key_bytes)) =
1857                (std::fs::read(cert_path), std::fs::read(key_path))
1858        {
1859            let mut identity_pem = cert_bytes;
1860            identity_pem.extend_from_slice(&key_bytes);
1861            if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1862                builder = builder.identity(identity);
1863            }
1864        }
1865    }
1866
1867    builder
1868        .build()
1869        .expect("reqwest::Client::build() with valid config should not fail") // allow-unwrap
1870}
1871
1872#[cfg(test)]
1873pub(crate) fn build_client_call_count() -> u64 {
1874    BUILD_CLIENT_CALLS.with(|c| c.get())
1875}
1876
1877impl HttpComponent {
1878    pub fn new() -> Self {
1879        let config = HttpConfig::default();
1880        Self {
1881            client: build_client(&config, None),
1882            config,
1883            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
1884                PINNED_CLIENT_TTL,
1885                PINNED_CLIENT_MAX_ENTRIES,
1886            )),
1887        }
1888    }
1889
1890    pub fn with_config(config: HttpConfig) -> Self {
1891        Self {
1892            client: build_client(&config, None),
1893            config,
1894            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
1895                PINNED_CLIENT_TTL,
1896                PINNED_CLIENT_MAX_ENTRIES,
1897            )),
1898        }
1899    }
1900
1901    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1902        match config {
1903            Some(cfg) => Self::with_config(cfg),
1904            None => Self::new(),
1905        }
1906    }
1907}
1908
1909impl Default for HttpComponent {
1910    fn default() -> Self {
1911        Self::new()
1912    }
1913}
1914
1915impl Component for HttpComponent {
1916    fn scheme(&self) -> &str {
1917        "http"
1918    }
1919
1920    fn metadata(&self) -> ComponentMetadata {
1921        HttpEndpointConfig::metadata()
1922    }
1923
1924    fn create_endpoint(
1925        &self,
1926        uri: &str,
1927        ctx: &dyn camel_component_api::ComponentContext,
1928    ) -> Result<Box<dyn Endpoint>, CamelError> {
1929        self.config.validate()?;
1930        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1931        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1932        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1933            server_config.host.clone(),
1934            server_config.port,
1935        )));
1936        self.pinned_cache
1937            .wire(HttpComponentKind::Http, ctx.metrics());
1938        Ok(Box::new(HttpEndpoint {
1939            uri: uri.to_string(),
1940            config,
1941            server_config,
1942            client: self.client.clone(),
1943            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
1944            http_config: self.config.clone(),
1945        }))
1946    }
1947}
1948
1949pub struct HttpsComponent {
1950    config: HttpConfig,
1951    pinned_cache: std::sync::Arc<PinnedClientCache>,
1952    client: reqwest::Client,
1953}
1954
1955impl HttpsComponent {
1956    pub fn new() -> Self {
1957        let config = HttpConfig::default();
1958        Self {
1959            client: build_client(&config, None),
1960            config,
1961            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
1962                PINNED_CLIENT_TTL,
1963                PINNED_CLIENT_MAX_ENTRIES,
1964            )),
1965        }
1966    }
1967
1968    pub fn with_config(config: HttpConfig) -> Self {
1969        Self {
1970            client: build_client(&config, None),
1971            config,
1972            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
1973                PINNED_CLIENT_TTL,
1974                PINNED_CLIENT_MAX_ENTRIES,
1975            )),
1976        }
1977    }
1978
1979    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1980        match config {
1981            Some(cfg) => Self::with_config(cfg),
1982            None => Self::new(),
1983        }
1984    }
1985}
1986
1987impl Default for HttpsComponent {
1988    fn default() -> Self {
1989        Self::new()
1990    }
1991}
1992
1993impl Component for HttpsComponent {
1994    fn scheme(&self) -> &str {
1995        "https"
1996    }
1997
1998    fn metadata(&self) -> ComponentMetadata {
1999        // HTTPS shares the same URI option surface and capabilities as HTTP.
2000        // Only the scheme and description differ.
2001        let mut meta = HttpEndpointConfig::metadata();
2002        meta.scheme = "https".to_string();
2003        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2004        meta
2005    }
2006
2007    fn create_endpoint(
2008        &self,
2009        uri: &str,
2010        ctx: &dyn camel_component_api::ComponentContext,
2011    ) -> Result<Box<dyn Endpoint>, CamelError> {
2012        self.config.validate()?;
2013        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2014        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2015        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2016            server_config.host.clone(),
2017            server_config.port,
2018        )));
2019        self.pinned_cache
2020            .wire(HttpComponentKind::Https, ctx.metrics());
2021        Ok(Box::new(HttpEndpoint {
2022            uri: uri.to_string(),
2023            config,
2024            server_config,
2025            client: self.client.clone(),
2026            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2027            http_config: self.config.clone(),
2028        }))
2029    }
2030}
2031
2032// ---------------------------------------------------------------------------
2033// HttpEndpoint
2034// ---------------------------------------------------------------------------
2035
2036struct HttpEndpoint {
2037    uri: String,
2038    config: HttpEndpointConfig,
2039    server_config: HttpServerConfig,
2040    client: reqwest::Client,
2041    pinned_cache: std::sync::Arc<PinnedClientCache>,
2042    http_config: HttpConfig,
2043}
2044
2045impl Endpoint for HttpEndpoint {
2046    fn uri(&self) -> &str {
2047        &self.uri
2048    }
2049
2050    fn create_consumer(
2051        &self,
2052        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2053    ) -> Result<Box<dyn Consumer>, CamelError> {
2054        // Scheme/config consistency check (spec §5) — uses parsed scheme
2055        // from HttpServerConfig, not a fragile port-443 heuristic.
2056        let scheme_is_https = self.server_config.scheme == "https";
2057        let has_tls = self.server_config.tls_config.is_some();
2058
2059        if scheme_is_https && !has_tls {
2060            return Err(CamelError::EndpointCreationFailed(
2061                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2062            ));
2063        }
2064        if !scheme_is_https && has_tls {
2065            return Err(CamelError::EndpointCreationFailed(
2066                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2067            ));
2068        }
2069        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2070    }
2071
2072    fn create_producer(
2073        &self,
2074        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2075        _ctx: &ProducerContext,
2076    ) -> Result<BoxProcessor, CamelError> {
2077        let producer = HttpProducer {
2078            config: Arc::new(self.config.clone()),
2079            client: self.client.clone(),
2080            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2081            http_config: Arc::new(self.http_config.clone()),
2082            runtime: rt,
2083        };
2084        if let Some(ref provider) = self.config.token_provider {
2085            let layer = BearerTokenLayer::new(Arc::clone(provider));
2086            Ok(BoxProcessor::new(layer.layer(producer)))
2087        } else {
2088            Ok(BoxProcessor::new(producer))
2089        }
2090    }
2091}
2092
2093// ---------------------------------------------------------------------------
2094// HttpProducer
2095// ---------------------------------------------------------------------------
2096
2097#[derive(Clone)]
2098struct HttpProducer {
2099    config: Arc<HttpEndpointConfig>,
2100    client: reqwest::Client,
2101    pinned_cache: std::sync::Arc<PinnedClientCache>,
2102    http_config: Arc<HttpConfig>,
2103    /// Runtime observability handle powering the component-ops facade at
2104    /// the request boundary (`("http","request")`, dashboard-observability
2105    /// Task 4.3). The retained `e:http:accept*` labels are consumer-side
2106    /// (server accept loop) — different boundary, no collision with
2107    /// `e:http:request`.
2108    runtime: Arc<dyn RuntimeObservability>,
2109}
2110
2111impl HttpProducer {
2112    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2113        if let Some(ref method) = config.http_method {
2114            return method.to_uppercase();
2115        }
2116        if let Some(method) = exchange
2117            .input
2118            .header("CamelHttpMethod")
2119            .and_then(|v| v.as_str())
2120        {
2121            return method.to_uppercase();
2122        }
2123        if !exchange.input.body.is_empty() {
2124            return "POST".to_string();
2125        }
2126        "GET".to_string()
2127    }
2128
2129    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2130        // bridgeEndpoint=true: emit the endpoint base URL verbatim and ignore
2131        // ALL exchange URL headers (CamelHttpUri, CamelHttpPath,
2132        // CamelHttpQuery) per Apache Camel bridging semantics. Only
2133        // configured query_params are applied. This check MUST come before the
2134        // CamelHttpUri override so bridging wins over that header.
2135        if config.bridge_endpoint {
2136            let url = config.base_url.clone();
2137            if config.query_params.is_empty() {
2138                return url;
2139            }
2140            let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); // allow-unwrap
2141            for (k, v) in &config.query_params {
2142                parsed.query_pairs_mut().append_pair(k, v);
2143            }
2144            return parsed.to_string();
2145        }
2146
2147        if let Some(uri) = exchange
2148            .input
2149            .header("CamelHttpUri")
2150            .and_then(|v| v.as_str())
2151        {
2152            let mut url = uri.to_string();
2153            if let Some(path) = exchange
2154                .input
2155                .header("CamelHttpPath")
2156                .and_then(|v| v.as_str())
2157            {
2158                if !url.ends_with('/') && !path.starts_with('/') {
2159                    url.push('/');
2160                }
2161                url.push_str(path);
2162            }
2163            if let Some(query) = exchange
2164                .input
2165                .header("CamelHttpQuery")
2166                .and_then(|v| v.as_str())
2167            {
2168                url.push('?');
2169                url.push_str(query);
2170            }
2171            return url;
2172        }
2173
2174        let mut url = config.base_url.clone();
2175
2176        if let Some(path) = exchange
2177            .input
2178            .header("CamelHttpPath")
2179            .and_then(|v| v.as_str())
2180        {
2181            if !url.ends_with('/') && !path.starts_with('/') {
2182                url.push('/');
2183            }
2184            url.push_str(path);
2185        }
2186
2187        if let Some(query) = exchange
2188            .input
2189            .header("CamelHttpQuery")
2190            .and_then(|v| v.as_str())
2191        {
2192            url.push('?');
2193            url.push_str(query);
2194        } else if !config.query_params.is_empty() {
2195            let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); // allow-unwrap
2196            for (k, v) in &config.query_params {
2197                parsed.query_pairs_mut().append_pair(k, v);
2198            }
2199            url = parsed.to_string();
2200        }
2201
2202        url
2203    }
2204
2205    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2206        status >= range.0 && status <= range.1
2207    }
2208
2209    /// Whether the HTTP method is entity-enclosing (may carry a request
2210    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
2211    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
2212    /// §9.3.1/§9.3.2).
2213    fn is_entity_enclosing(method: &str) -> bool {
2214        matches!(method, "POST" | "PUT" | "PATCH")
2215    }
2216}
2217
2218impl Service<Exchange> for HttpProducer {
2219    type Response = Exchange;
2220    type Error = CamelError;
2221    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2222
2223    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2224        Poll::Ready(Ok(()))
2225    }
2226
2227    fn call(&mut self, exchange: Exchange) -> Self::Future {
2228        let config = self.config.clone();
2229        let shared_client = self.client.clone();
2230        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2231        let http_config = self.http_config.clone();
2232        let component_metrics = self.runtime.component_metrics();
2233
2234        Box::pin(async move {
2235            let mut exchange = exchange;
2236            let outcome = async {
2237                let method_str = HttpProducer::resolve_method(&exchange, &config);
2238                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
2239                // and PATCH may carry a request body. Any other resolved method
2240                // drops the exchange body before the request is built (Apache
2241                // Camel `HttpMethods` parity).
2242                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2243                let url = HttpProducer::resolve_url(&exchange, &config);
2244
2245                // SECURITY: Validate URL for SSRF
2246                ssrf::validate_url_for_ssrf(&url, &config)?;
2247
2248                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
2249                // (L-H2). When the URL uses a domain name and SSRF protection is active,
2250                // reuse the endpoint's cached DNS-pinned client for that validated
2251                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
2252                // repeated requests keep one connection pool without re-resolving DNS.
2253                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
2254                // URLs use the endpoint's unpinned shared client.
2255                let resolved =
2256                    ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2257                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2258                    pinned_cache
2259                        .get_or_build(host.as_str(), addrs, || {
2260                            build_client(&http_config, Some((host.as_str(), addrs)))
2261                        })
2262                        .await
2263                } else {
2264                    shared_client.clone()
2265                };
2266
2267                debug!(
2268                    correlation_id = %exchange.correlation_id(),
2269                    method = %method_str,
2270                    url = %url,
2271                    "HTTP request"
2272                );
2273
2274                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2275                    CamelError::ProcessorError(format!(
2276                        "Invalid HTTP method '{}': {}",
2277                        method_str, e
2278                    ))
2279                })?;
2280
2281                // Collect headers for potential redirect replay
2282                let mut collected_headers: Vec<(
2283                    reqwest::header::HeaderName,
2284                    reqwest::header::HeaderValue,
2285                )> = Vec::new();
2286
2287                if let Some(user_agent) = &config.user_agent
2288                    && !config.bridge_endpoint
2289                    && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2290                {
2291                    collected_headers.push((reqwest::header::USER_AGENT, val));
2292                }
2293
2294                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
2295                #[cfg(feature = "otel")]
2296                let should_inject_otel = !config.bridge_endpoint;
2297                #[cfg(feature = "otel")]
2298                if should_inject_otel {
2299                    let mut otel_headers = HashMap::new();
2300                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2301                    for (k, v) in otel_headers {
2302                        if let (Ok(name), Ok(val)) = (
2303                            reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2304                            reqwest::header::HeaderValue::from_str(&v),
2305                        ) {
2306                            collected_headers.push((name, val));
2307                        }
2308                    }
2309                }
2310
2311                let conn_tokens = header_policy::connection_tokens(
2312                    exchange
2313                        .input
2314                        .headers
2315                        .iter()
2316                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2317                        .filter_map(|(_, v)| v.as_str()),
2318                );
2319
2320                for (key, value) in &exchange.input.headers {
2321                    if !key.starts_with("Camel")
2322                        && !config
2323                            .skip_request_headers
2324                            .iter()
2325                            .any(|h| h.eq_ignore_ascii_case(key))
2326                        && !header_policy::excluded_outbound(key, &conn_tokens)
2327                        && let Some(val_str) = value.as_str()
2328                        && let (Ok(name), Ok(val)) = (
2329                            reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2330                            reqwest::header::HeaderValue::from_str(val_str),
2331                        )
2332                    {
2333                        collected_headers.push((name, val));
2334                    }
2335                }
2336
2337                // Auth headers
2338                if !config.bridge_endpoint {
2339                    match &config.auth {
2340                        HttpAuth::None => {}
2341                        HttpAuth::Basic { username, password } => {
2342                            use base64::Engine;
2343                            // allow-secret: credentials combined for base64 Basic auth header
2344                            let credentials = format!("{username}:{password}");
2345                            let encoded =
2346                                base64::engine::general_purpose::STANDARD.encode(credentials);
2347                            if let Ok(val) =
2348                                reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2349                            {
2350                                collected_headers.push((reqwest::header::AUTHORIZATION, val));
2351                            }
2352                        }
2353                        HttpAuth::Bearer { token } => {
2354                            // allow-secret: Bearer token in Authorization header
2355                            let bearer = format!("Bearer {token}");
2356                            if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2357                                collected_headers.push((reqwest::header::AUTHORIZATION, val));
2358                            }
2359                        }
2360                    }
2361
2362                    if config.connection_close
2363                        && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2364                    {
2365                        collected_headers.push((reqwest::header::CONNECTION, val));
2366                    }
2367                }
2368
2369                // Materialize body
2370                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2371                let materialized_body: Option<Vec<u8>> = if is_stream_body {
2372                    if suppress_body {
2373                        // A stream body dropped under a non-entity-enclosing
2374                        // method always warns (its emptiness is unknowable) and
2375                        // stays consumed (mem::take). The stream attach arm below
2376                        // still runs its outer flag check, but the inner `if let
2377                        // Body::Stream` re-match fails on the now-Empty body, so
2378                        // no stream is attached and no AlreadyConsumed error can
2379                        // fire.
2380                        std::mem::take(&mut exchange.input.body);
2381                        // log-policy: handler-owned
2382                        tracing::warn!(
2383                            correlation_id = %exchange.correlation_id(),
2384                            method = %method_str,
2385                            "dropping request body for non-entity-enclosing HTTP method"
2386                        );
2387                    }
2388                    None // Streams can't be replayed on redirect
2389                } else {
2390                    let body = std::mem::take(&mut exchange.input.body);
2391                    let bytes = body.into_bytes(config.max_body_size).await?;
2392                    if bytes.is_empty() {
2393                        // Empty body: nothing to send and nothing to warn about.
2394                        None
2395                    } else if suppress_body {
2396                        // log-policy: handler-owned
2397                        tracing::warn!(
2398                            correlation_id = %exchange.correlation_id(),
2399                            method = %method_str,
2400                            "dropping request body for non-entity-enclosing HTTP method"
2401                        );
2402                        None
2403                    } else {
2404                        Some(bytes.to_vec())
2405                    }
2406                };
2407
2408                let response = if config.follow_redirects && !is_stream_body {
2409                    // Use manual redirect loop with per-hop SSRF validation.
2410                    // `client` is the pinned-or-shared binding for the initial
2411                    // request (a hostname initial request keeps its DNS-pinned
2412                    // client); `shared_client` is the unpinned endpoint client
2413                    // reused by IP-literal redirect hops.
2414                    ssrf::send_with_ssrf_safe_redirects(
2415                        &client,
2416                        &shared_client,
2417                        &pinned_cache,
2418                        &http_config,
2419                        &config,
2420                        method,
2421                        &url,
2422                        collected_headers,
2423                        materialized_body,
2424                        config.max_redirects,
2425                        config.response_timeout,
2426                    )
2427                    .await?
2428                } else {
2429                    // Direct send (no redirect following, or streaming body)
2430                    let mut request = client.request(method, &url);
2431
2432                    if let Some(timeout) = config.response_timeout {
2433                        request = request.timeout(timeout);
2434                    }
2435
2436                    for (name, value) in &collected_headers {
2437                        request = request.header(name, value);
2438                    }
2439
2440                    if is_stream_body {
2441                        if let Body::Stream(ref s) = exchange.input.body {
2442                            let mut stream_lock = s.stream.lock().await;
2443                            if let Some(stream) = stream_lock.take() {
2444                                request = request.body(reqwest::Body::wrap_stream(stream));
2445                            } else {
2446                                return Err(CamelError::AlreadyConsumed);
2447                            }
2448                        }
2449                    } else if let Some(ref body_bytes) = materialized_body {
2450                        request = request.body(body_bytes.clone());
2451                    }
2452
2453                    request.send().await.map_err(|e| {
2454                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
2455                    })?
2456                };
2457
2458                let status_code = response.status().as_u16();
2459                let status_text = response
2460                    .status()
2461                    .canonical_reason()
2462                    .unwrap_or("Unknown")
2463                    .to_string();
2464
2465                for (key, value) in response.headers() {
2466                    if config
2467                        .skip_response_headers
2468                        .iter()
2469                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2470                    {
2471                        continue;
2472                    }
2473                    if let Ok(val_str) = value.to_str() {
2474                        exchange.input.set_header(
2475                            title_case_header(key.as_str()),
2476                            serde_json::Value::String(val_str.to_string()),
2477                        );
2478                    }
2479                }
2480
2481                exchange.input.set_header(
2482                    "CamelHttpResponseCode",
2483                    serde_json::Value::Number(status_code.into()),
2484                );
2485                exchange.input.set_header(
2486                    "CamelHttpResponseText",
2487                    serde_json::Value::String(status_text.clone()),
2488                );
2489
2490                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
2491                let read_timeout = Duration::from_millis(config.read_timeout_ms);
2492                let response_body = tokio::time::timeout(read_timeout, async {
2493                    // Check Content-Length header before allocating
2494                    if let Some(content_len) = response.content_length()
2495                        && content_len > config.max_response_bytes as u64
2496                    {
2497                        return Err(CamelError::ProcessorError(format!(
2498                            "Response body too large: {} bytes exceeds limit of {} bytes",
2499                            content_len, config.max_response_bytes
2500                        )));
2501                    }
2502                    // Use bytes_stream() for lazy streaming with size guard
2503                    use futures::TryStreamExt;
2504                    let mut stream = response.bytes_stream();
2505                    let mut total: usize = 0;
2506                    let mut collected = Vec::new();
2507                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
2508                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2509                    })? {
2510                        total += chunk.len();
2511                        if total > config.max_response_bytes {
2512                            return Err(CamelError::ProcessorError(format!(
2513                                "Response body too large: {} bytes exceeds limit of {} bytes",
2514                                total, config.max_response_bytes
2515                            )));
2516                        }
2517                        collected.push(chunk);
2518                    }
2519                    let mut result = bytes::BytesMut::with_capacity(total);
2520                    for chunk in collected {
2521                        result.extend_from_slice(&chunk);
2522                    }
2523                    Ok::<bytes::Bytes, CamelError>(result.freeze())
2524                })
2525                .await
2526                .map_err(|_| {
2527                    CamelError::ProcessorError(format!(
2528                        "Read timeout after {}ms",
2529                        config.read_timeout_ms
2530                    ))
2531                })??;
2532
2533                if config.throw_exception_on_failure
2534                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2535                {
2536                    return Err(CamelError::HttpOperationFailed {
2537                        method: method_str,
2538                        url,
2539                        status_code,
2540                        status_text,
2541                        response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2542                    });
2543                }
2544
2545                if !response_body.is_empty() {
2546                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2547                }
2548
2549                debug!(
2550                    correlation_id = %exchange.correlation_id(),
2551                    status = status_code,
2552                    url = %url,
2553                    "HTTP response"
2554                );
2555                Ok(exchange)
2556            }
2557            .await;
2558            // ("http","request") facade (dashboard-observability 4.3): the
2559            // request boundary is the full client round-trip — SSRF checks,
2560            // send, response read, and (with throwExceptionOnFailure) the
2561            // status gate. http runs no retry_async and the producer
2562            // previously emitted nothing, so no label collides with
2563            // e:http:request.
2564            component_metrics.observe("http", "request", outcome.is_err());
2565            outcome
2566        })
2567    }
2568}
2569
2570/// Serializes tests that mutate or depend on the global `ServerRegistry`.
2571///
2572/// `ServerRegistry::global()` is a process-wide singleton that persists
2573/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
2574/// with another test that has a live server on a fixed port (e.g. 9991),
2575/// the registry entry is removed while the OS socket is still bound, so
2576/// the next `get_or_spawn` call on that port fails with "Address already
2577/// in use". Holding this mutex for the full body of each affected test
2578/// prevents the race without requiring `--test-threads=1`.
2579#[cfg(test)]
2580pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2581
2582/// Map a pipeline error to an HTTP reply.
2583///
2584/// Extracted from the inline `match` in `dispatch_handler` for unit
2585/// testability (rc-1dk4). `TypeConversionFailed` (e.g. malformed JSON
2586/// body) maps to `400 Bad Request` with a structured JSON error body;
2587/// `Unauthenticated`/`Unauthorized` keep their existing `401`/`403`
2588/// mappings; all other errors map to `500 Internal Server Error`.
2589fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2590    match e {
2591        CamelError::Unauthenticated(msg) => {
2592            tracing::warn!(error = %msg, path = %path, "Authentication failed");
2593            HttpReply {
2594                status: 401,
2595                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2596                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2597            }
2598        }
2599        CamelError::Unauthorized(msg) => {
2600            tracing::warn!(error = %msg, path = %path, "Authorization failed");
2601            HttpReply {
2602                status: 403,
2603                headers: vec![],
2604                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2605            }
2606        }
2607        CamelError::TypeConversionFailed(msg) => {
2608            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2609            let body = serde_json::to_string(&serde_json::json!({
2610                "error": "bad_request",
2611                "message": msg,
2612            }))
2613            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
2614            HttpReply {
2615                status: 400,
2616                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2617                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2618            }
2619        }
2620        CamelError::ValidationError(msg) => {
2621            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2622            let body = serde_json::to_string(&serde_json::json!({
2623                "error": "validation_error",
2624                "message": msg,
2625            }))
2626            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
2627            HttpReply {
2628                status: 400,
2629                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2630                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2631            }
2632        }
2633        CamelError::ConsumerStopping => {
2634            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2635            HttpReply {
2636                status: 503,
2637                headers: vec![],
2638                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2639            }
2640        }
2641        e => {
2642            // log-policy: handler-owned
2643            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2644            HttpReply {
2645                status: 500,
2646                headers: vec![],
2647                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2648            }
2649        }
2650    }
2651}
2652
2653/// Select the HTTP response headers emitted by the consumer reply finaliser
2654/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
2655/// `dispatch_handler` for unit testability.
2656///
2657/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
2658/// server-owned headers, plus `content-length`/`content-type` (re-derived),
2659/// and any header named by a `Connection` token. Appends a single
2660/// `Content-Type` from `user_content_type` falling back to
2661/// `inferred_content_type` when either is present.
2662fn select_response_headers(
2663    headers: &HashMap<String, serde_json::Value>,
2664    user_content_type: Option<String>,
2665    inferred_content_type: Option<String>,
2666) -> Vec<(String, String)> {
2667    let conn_tokens = header_policy::connection_tokens(
2668        headers
2669            .iter()
2670            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2671            .filter_map(|(_, v)| v.as_str()),
2672    );
2673    let mut selected: Vec<(String, String)> = headers
2674        .iter()
2675        .filter(|(k, _)| !k.starts_with("Camel"))
2676        .filter(|(k, _)| !header_policy::excluded_response(k, &conn_tokens))
2677        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2678        .collect();
2679    if let Some(ct) = user_content_type.or(inferred_content_type) {
2680        selected.push(("Content-Type".to_string(), ct));
2681    }
2682    selected
2683}
2684
2685#[cfg(test)]
2686mod tests {
2687    use camel_component_api::test_support::NoopRuntimeObservability;
2688
2689    // Producer/consumer tests drive the component-ops facade on every
2690    // call (dashboard-observability 4.3), so even non-observability tests
2691    // must supply a collector-returning runtime — Noop everywhere.
2692    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2693        std::sync::Arc::new(NoopRuntimeObservability)
2694    }
2695    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2696        std::sync::Arc::new(NoopRuntimeObservability)
2697    }
2698    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2699        std::sync::Arc::new(NoopRuntimeObservability)
2700    }
2701
2702    use super::*;
2703    use crate::rest_match::PathSegment;
2704    use camel_component_api::{Message, NoOpComponentContext};
2705    use std::sync::Arc;
2706    use std::time::Duration;
2707
2708    fn test_producer_ctx() -> ProducerContext {
2709        ProducerContext::new()
2710    }
2711
2712    #[test]
2713    fn test_http_config_defaults() {
2714        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2715        assert_eq!(config.base_url, "http://localhost:8080/api");
2716        assert!(config.http_method.is_none());
2717        assert!(config.throw_exception_on_failure);
2718        assert_eq!(config.ok_status_code_range, (200, 299));
2719        assert!(config.response_timeout.is_none());
2720        assert!(matches!(config.auth, HttpAuth::None));
2721        assert!(!config.bridge_endpoint);
2722        assert!(!config.connection_close);
2723    }
2724
2725    #[test]
2726    fn test_http_config_scheme() {
2727        // UriConfig trait method returns "http" as primary scheme
2728        assert_eq!(HttpEndpointConfig::scheme(), "http");
2729    }
2730
2731    #[test]
2732    fn test_http_config_from_components() {
2733        // Test from_components directly (trait method)
2734        let components = camel_component_api::UriComponents {
2735            scheme: "https".to_string(),
2736            path: "//api.example.com/v1".to_string(),
2737            params: std::collections::HashMap::from([(
2738                "httpMethod".to_string(),
2739                "POST".to_string(),
2740            )]),
2741        };
2742        let config = HttpEndpointConfig::from_components(components).unwrap();
2743        assert_eq!(config.base_url, "https://api.example.com/v1");
2744        assert_eq!(config.http_method, Some("POST".to_string()));
2745    }
2746
2747    #[test]
2748    fn test_http_config_with_options() {
2749        let config = HttpEndpointConfig::from_uri(
2750            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2751        ).unwrap();
2752        assert_eq!(config.base_url, "https://api.example.com/v1");
2753        assert_eq!(config.http_method, Some("PUT".to_string()));
2754        assert!(!config.throw_exception_on_failure);
2755        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2756    }
2757
2758    #[test]
2759    fn test_http_endpoint_config_auth_and_headers_options() {
2760        let config = HttpEndpointConfig::from_uri(
2761            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
2762        )
2763        .unwrap();
2764
2765        assert!(matches!(
2766            config.auth,
2767            HttpAuth::Basic { username, password } if username == "u" && password == "p"
2768        ));
2769        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2770        assert!(config.bridge_endpoint);
2771        assert!(config.connection_close);
2772        assert_eq!(
2773            config.skip_request_headers,
2774            vec!["authorization".to_string(), "x-secret".to_string()]
2775        );
2776        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2777    }
2778
2779    #[test]
2780    fn test_http_endpoint_config_bearer_auth() {
2781        let config = HttpEndpointConfig::from_uri(
2782            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2783        )
2784        .unwrap();
2785        assert!(matches!(
2786            config.auth,
2787            HttpAuth::Bearer { token } if token == "t"
2788        ));
2789    }
2790
2791    #[test]
2792    fn rejects_cookie_handling_inmemory() {
2793        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
2794        match result {
2795            Err(CamelError::InvalidUri(msg)) => {
2796                assert!(
2797                    msg.contains("cookieHandling is not supported"),
2798                    "expected rejection message, got: {msg}"
2799                );
2800            }
2801            other => panic!("expected InvalidUri error, got: {other:?}"),
2802        }
2803    }
2804
2805    #[test]
2806    fn rejects_cookie_handling_disabled() {
2807        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
2808        match result {
2809            Err(CamelError::InvalidUri(msg)) => {
2810                assert!(
2811                    msg.contains("cookieHandling is not supported"),
2812                    "expected rejection message, got: {msg}"
2813                );
2814            }
2815            other => panic!("expected InvalidUri error, got: {other:?}"),
2816        }
2817    }
2818
2819    #[test]
2820    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2821        let config = HttpConfig::default()
2822            .with_response_timeout_ms(999)
2823            .with_allow_internal(true)
2824            .with_blocked_hosts(vec!["evil.com".to_string()])
2825            .with_max_body_size(12345);
2826        let endpoint =
2827            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2828        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2829        assert!(endpoint.allow_internal);
2830        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2831        assert_eq!(endpoint.max_body_size, 12345);
2832    }
2833
2834    #[test]
2835    fn test_from_uri_with_defaults_uri_overrides_config() {
2836        let config = HttpConfig::default()
2837            .with_response_timeout_ms(999)
2838            .with_allow_internal(true)
2839            .with_blocked_hosts(vec!["evil.com".to_string()])
2840            .with_max_body_size(12345);
2841        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2842            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
2843            &config,
2844        )
2845        .unwrap();
2846        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2847        assert!(!endpoint.allow_internal);
2848        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2849        assert_eq!(endpoint.max_body_size, 99);
2850    }
2851
2852    #[test]
2853    fn test_http_config_ok_status_range() {
2854        let config =
2855            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2856        assert_eq!(config.ok_status_code_range, (200, 204));
2857    }
2858
2859    #[test]
2860    fn test_http_config_wrong_scheme() {
2861        let result = HttpEndpointConfig::from_uri("file:/tmp");
2862        assert!(result.is_err());
2863    }
2864
2865    #[test]
2866    fn test_http_component_scheme() {
2867        let component = HttpComponent::new();
2868        assert_eq!(component.scheme(), "http");
2869    }
2870
2871    #[test]
2872    fn test_https_component_scheme() {
2873        let component = HttpsComponent::new();
2874        assert_eq!(component.scheme(), "https");
2875    }
2876
2877    #[test]
2878    fn test_http_endpoint_creates_consumer() {
2879        let component = HttpComponent::new();
2880        let ctx = NoOpComponentContext;
2881        let endpoint = component
2882            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2883            .unwrap();
2884        assert!(endpoint.create_consumer(rt()).is_ok());
2885    }
2886
2887    #[test]
2888    fn test_https_endpoint_creates_consumer_errors_without_tls() {
2889        let component = HttpsComponent::new();
2890        let ctx = NoOpComponentContext;
2891        let endpoint = component
2892            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2893            .unwrap();
2894        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
2895        assert!(endpoint.create_consumer(rt()).is_err());
2896    }
2897
2898    #[test]
2899    fn test_http_endpoint_creates_producer() {
2900        let ctx = test_producer_ctx();
2901        let component = HttpComponent::new();
2902        let endpoint_ctx = NoOpComponentContext;
2903        let endpoint = component
2904            .create_endpoint("http://localhost/api", &endpoint_ctx)
2905            .unwrap();
2906        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2907    }
2908
2909    // -----------------------------------------------------------------------
2910    // Producer tests
2911    // -----------------------------------------------------------------------
2912
2913    #[tokio::test]
2914    async fn test_producer_with_token_provider() {
2915        use camel_auth::oauth2::TokenProvider;
2916        use tower::ServiceExt;
2917
2918        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2919            Arc::new(std::sync::Mutex::new(None));
2920        let captured_clone = Arc::clone(&captured_auth);
2921
2922        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2923        let port = listener.local_addr().unwrap().port();
2924
2925        let _handle = tokio::spawn(async move {
2926            use tokio::io::{AsyncReadExt, AsyncWriteExt};
2927            if let Ok((mut stream, _)) = listener.accept().await {
2928                let mut buf = vec![0u8; 8192];
2929                let n = stream.read(&mut buf).await.unwrap_or(0);
2930                let request = String::from_utf8_lossy(&buf[..n]).to_string();
2931                let auth = request
2932                    .lines()
2933                    .find(|l| l.to_lowercase().starts_with("authorization:"))
2934                    .map(|l| {
2935                        l.split(':')
2936                            .nth(1)
2937                            .map(|s| s.trim().to_string())
2938                            .unwrap_or_default()
2939                    });
2940                *captured_clone.lock().unwrap() = auth;
2941                let body = r#"{"echo":"ok"}"#;
2942                let resp = format!(
2943                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2944                    body.len(),
2945                    body
2946                );
2947                let _ = stream.write_all(resp.as_bytes()).await;
2948            }
2949        });
2950
2951        #[derive(Debug)]
2952        struct StaticProvider;
2953        #[async_trait::async_trait]
2954        impl TokenProvider for StaticProvider {
2955            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2956                Ok("injected-token".into())
2957            }
2958        }
2959
2960        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
2961        let ctx = test_producer_ctx();
2962        let component = HttpComponent::new();
2963        let endpoint_ctx = NoOpComponentContext;
2964        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2965        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2966
2967        let exchange = Exchange::new(Message::new("hello"));
2968
2969        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2970        let mut layered = layer.layer(producer);
2971        let result = layered.ready().await.unwrap().call(exchange).await;
2972        assert!(result.is_ok(), "producer call failed: {:?}", result);
2973
2974        tokio::time::sleep(Duration::from_millis(100)).await;
2975        let auth = captured_auth.lock().unwrap().take();
2976        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2977    }
2978
2979    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2980        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2981        let addr = listener.local_addr().unwrap();
2982        let url = format!("http://127.0.0.1:{}", addr.port());
2983
2984        let handle = tokio::spawn(async move {
2985            loop {
2986                if let Ok((mut stream, _)) = listener.accept().await {
2987                    tokio::spawn(async move {
2988                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2989                        let mut buf = vec![0u8; 4096];
2990                        let n = stream.read(&mut buf).await.unwrap_or(0);
2991                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
2992
2993                        let method = request.split_whitespace().next().unwrap_or("GET");
2994
2995                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2996                        let response = format!(
2997                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2998                            body.len(),
2999                            body
3000                        );
3001                        let _ = stream.write_all(response.as_bytes()).await;
3002                    });
3003                }
3004            }
3005        });
3006
3007        (url, handle)
3008    }
3009
3010    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
3011        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3012        let addr = listener.local_addr().unwrap();
3013        let url = format!("http://127.0.0.1:{}", addr.port());
3014
3015        let handle = tokio::spawn(async move {
3016            loop {
3017                if let Ok((mut stream, _)) = listener.accept().await {
3018                    let status = status;
3019                    tokio::spawn(async move {
3020                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
3021                        let mut buf = vec![0u8; 4096];
3022                        let _ = stream.read(&mut buf).await;
3023
3024                        let status_text = match status {
3025                            404 => "Not Found",
3026                            500 => "Internal Server Error",
3027                            _ => "Error",
3028                        };
3029                        let body = "error body";
3030                        let response = format!(
3031                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
3032                            status,
3033                            status_text,
3034                            body.len(),
3035                            body
3036                        );
3037                        let _ = stream.write_all(response.as_bytes()).await;
3038                    });
3039                }
3040            }
3041        });
3042
3043        (url, handle)
3044    }
3045
3046    async fn start_request_capturing_server() -> (
3047        String,
3048        Arc<std::sync::Mutex<Option<String>>>,
3049        tokio::task::JoinHandle<()>,
3050    ) {
3051        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3052        let port = listener.local_addr().unwrap().port();
3053        let url = format!("http://127.0.0.1:{port}");
3054        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
3055        let captured_clone = Arc::clone(&captured);
3056        let handle = tokio::spawn(async move {
3057            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3058            if let Ok((mut stream, _)) = listener.accept().await {
3059                let mut buf = vec![0u8; 16384];
3060                let n = stream.read(&mut buf).await.unwrap_or(0);
3061                let request = String::from_utf8_lossy(&buf[..n]).to_string();
3062                if request.contains("\r\n\r\n") {
3063                    *captured_clone.lock().unwrap() = Some(request);
3064                }
3065                let body = r#"{"echo":"ok"}"#;
3066                let resp = format!(
3067                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3068                    body.len(),
3069                    body
3070                );
3071                let _ = stream.write_all(resp.as_bytes()).await;
3072            }
3073        });
3074        (url, captured, handle)
3075    }
3076
3077    #[tokio::test]
3078    async fn test_http_producer_get_request() {
3079        use tower::ServiceExt;
3080
3081        let (url, _handle) = start_test_server().await;
3082        let ctx = test_producer_ctx();
3083
3084        let component = HttpComponent::new();
3085        let endpoint_ctx = NoOpComponentContext;
3086        let endpoint = component
3087            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3088            .unwrap();
3089        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3090
3091        let exchange = Exchange::new(Message::default());
3092        let result = producer.oneshot(exchange).await.unwrap();
3093
3094        let status = result
3095            .input
3096            .header("CamelHttpResponseCode")
3097            .and_then(|v| v.as_u64())
3098            .unwrap();
3099        assert_eq!(status, 200);
3100
3101        assert!(!result.input.body.is_empty());
3102    }
3103
3104    #[tokio::test]
3105    async fn producer_excludes_host_and_framing() {
3106        use tower::ServiceExt;
3107
3108        let (url, captured, _handle) = start_request_capturing_server().await;
3109        let ctx = test_producer_ctx();
3110        let component = HttpComponent::new();
3111        let endpoint_ctx = NoOpComponentContext;
3112        let endpoint = component
3113            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3114            .unwrap();
3115        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3116
3117        let mut exchange = Exchange::new(Message::default());
3118        exchange.input.set_header("Host", "localhost");
3119        exchange.input.set_header("Content-Length", "42");
3120        exchange.input.set_header("Connection", "keep-alive");
3121        exchange.input.set_header("Upgrade", "h2c");
3122
3123        let result = producer.oneshot(exchange).await;
3124        assert!(result.is_ok(), "producer call failed: {:?}", result);
3125
3126        tokio::time::sleep(Duration::from_millis(100)).await;
3127        let request = captured
3128            .lock()
3129            .unwrap()
3130            .take()
3131            .expect("no outbound request captured");
3132        let lower = request.to_ascii_lowercase();
3133        assert!(
3134            !lower.contains("\r\nhost: localhost"),
3135            "forwarded Host: localhost must be stripped\n{request}"
3136        );
3137        assert!(
3138            !lower.contains("content-length: 42"),
3139            "exchange Content-Length must not be copied\n{request}"
3140        );
3141        assert!(
3142            !lower.lines().any(|l| l.starts_with("connection:")),
3143            "Connection header must not be forwarded\n{request}"
3144        );
3145        assert!(
3146            !lower.lines().any(|l| l.starts_with("upgrade:")),
3147            "Upgrade header must not be forwarded\n{request}"
3148        );
3149        let host_header = lower
3150            .lines()
3151            .find(|l| l.starts_with("host:"))
3152            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
3153            .expect("outbound Host header must be set by reqwest");
3154        assert!(
3155            host_header.starts_with("127.0.0.1:"),
3156            "outbound Host '{host_header}' must match the capture-server address"
3157        );
3158    }
3159
3160    #[tokio::test]
3161    async fn producer_forwards_request_only_headers() {
3162        use tower::ServiceExt;
3163
3164        let (url, captured, _handle) = start_request_capturing_server().await;
3165        let ctx = test_producer_ctx();
3166        let component = HttpComponent::new();
3167        let endpoint_ctx = NoOpComponentContext;
3168        let endpoint = component
3169            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3170            .unwrap();
3171        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3172
3173        let mut exchange = Exchange::new(Message::default());
3174        exchange.input.set_header("Accept", "application/json");
3175        exchange.input.set_header("User-Agent", "myclient/1.0");
3176
3177        let result = producer.oneshot(exchange).await;
3178        assert!(result.is_ok(), "producer call failed: {:?}", result);
3179
3180        tokio::time::sleep(Duration::from_millis(100)).await;
3181        let request = captured
3182            .lock()
3183            .unwrap()
3184            .take()
3185            .expect("no outbound request captured");
3186        let lower = request.to_ascii_lowercase();
3187        assert!(
3188            lower.contains("accept: application/json"),
3189            "request-only Accept header must be forwarded\n{request}"
3190        );
3191        assert!(
3192            lower.contains("user-agent: myclient/1.0"),
3193            "request-only User-Agent header must be forwarded\n{request}"
3194        );
3195    }
3196
3197    #[tokio::test]
3198    async fn producer_honours_skip_request_headers() {
3199        use tower::ServiceExt;
3200
3201        let (url, captured, _handle) = start_request_capturing_server().await;
3202        let ctx = test_producer_ctx();
3203        let component = HttpComponent::new();
3204        let endpoint_ctx = NoOpComponentContext;
3205        let endpoint = component
3206            .create_endpoint(
3207                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
3208                &endpoint_ctx,
3209            )
3210            .unwrap();
3211        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3212
3213        let mut exchange = Exchange::new(Message::default());
3214        exchange.input.set_header("Authorization", "Bearer x");
3215
3216        let result = producer.oneshot(exchange).await;
3217        assert!(result.is_ok(), "producer call failed: {:?}", result);
3218
3219        tokio::time::sleep(Duration::from_millis(100)).await;
3220        let request = captured
3221            .lock()
3222            .unwrap()
3223            .take()
3224            .expect("no outbound request captured");
3225        assert!(
3226            !request.to_ascii_lowercase().contains("authorization"),
3227            "Authorization must be stripped by skipRequestHeaders\n{request}"
3228        );
3229    }
3230
3231    #[tokio::test]
3232    async fn test_http_producer_post_with_body() {
3233        use tower::ServiceExt;
3234
3235        let (url, _handle) = start_test_server().await;
3236        let ctx = test_producer_ctx();
3237
3238        let component = HttpComponent::new();
3239        let endpoint_ctx = NoOpComponentContext;
3240        let endpoint = component
3241            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
3242            .unwrap();
3243        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3244
3245        let exchange = Exchange::new(Message::new("request body"));
3246        let result = producer.oneshot(exchange).await.unwrap();
3247
3248        let status = result
3249            .input
3250            .header("CamelHttpResponseCode")
3251            .and_then(|v| v.as_u64())
3252            .unwrap();
3253        assert_eq!(status, 200);
3254    }
3255
3256    #[tokio::test]
3257    async fn test_http_producer_method_from_header() {
3258        use tower::ServiceExt;
3259
3260        let (url, _handle) = start_test_server().await;
3261        let ctx = test_producer_ctx();
3262
3263        let component = HttpComponent::new();
3264        let endpoint_ctx = NoOpComponentContext;
3265        let endpoint = component
3266            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3267            .unwrap();
3268        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3269
3270        let mut exchange = Exchange::new(Message::default());
3271        exchange.input.set_header(
3272            "CamelHttpMethod",
3273            serde_json::Value::String("DELETE".to_string()),
3274        );
3275
3276        let result = producer.oneshot(exchange).await.unwrap();
3277        let status = result
3278            .input
3279            .header("CamelHttpResponseCode")
3280            .and_then(|v| v.as_u64())
3281            .unwrap();
3282        assert_eq!(status, 200);
3283    }
3284
3285    #[tokio::test]
3286    async fn test_http_producer_forced_method() {
3287        use tower::ServiceExt;
3288
3289        let (url, _handle) = start_test_server().await;
3290        let ctx = test_producer_ctx();
3291
3292        let component = HttpComponent::new();
3293        let endpoint_ctx = NoOpComponentContext;
3294        let endpoint = component
3295            .create_endpoint(
3296                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
3297                &endpoint_ctx,
3298            )
3299            .unwrap();
3300        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3301
3302        let exchange = Exchange::new(Message::default());
3303        let result = producer.oneshot(exchange).await.unwrap();
3304
3305        let status = result
3306            .input
3307            .header("CamelHttpResponseCode")
3308            .and_then(|v| v.as_u64())
3309            .unwrap();
3310        assert_eq!(status, 200);
3311    }
3312
3313    #[tokio::test]
3314    async fn test_http_producer_throw_exception_on_failure() {
3315        use tower::ServiceExt;
3316
3317        let (url, _handle) = start_status_server(404).await;
3318        let ctx = test_producer_ctx();
3319
3320        let component = HttpComponent::new();
3321        let endpoint_ctx = NoOpComponentContext;
3322        let endpoint = component
3323            .create_endpoint(
3324                &format!("{url}/not-found?allowInternal=true"),
3325                &endpoint_ctx,
3326            )
3327            .unwrap();
3328        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3329
3330        let exchange = Exchange::new(Message::default());
3331        let result = producer.oneshot(exchange).await;
3332        assert!(result.is_err());
3333
3334        match result.unwrap_err() {
3335            CamelError::HttpOperationFailed { status_code, .. } => {
3336                assert_eq!(status_code, 404);
3337            }
3338            e => panic!("Expected HttpOperationFailed, got: {e}"),
3339        }
3340    }
3341
3342    #[tokio::test]
3343    async fn test_http_producer_no_throw_on_failure() {
3344        use tower::ServiceExt;
3345
3346        let (url, _handle) = start_status_server(500).await;
3347        let ctx = test_producer_ctx();
3348
3349        let component = HttpComponent::new();
3350        let endpoint_ctx = NoOpComponentContext;
3351        let endpoint = component
3352            .create_endpoint(
3353                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
3354                &endpoint_ctx,
3355            )
3356            .unwrap();
3357        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3358
3359        let exchange = Exchange::new(Message::default());
3360        let result = producer.oneshot(exchange).await.unwrap();
3361
3362        let status = result
3363            .input
3364            .header("CamelHttpResponseCode")
3365            .and_then(|v| v.as_u64())
3366            .unwrap();
3367        assert_eq!(status, 500);
3368    }
3369
3370    #[tokio::test]
3371    async fn test_http_producer_uri_override() {
3372        use tower::ServiceExt;
3373
3374        let (url, _handle) = start_test_server().await;
3375        let ctx = test_producer_ctx();
3376
3377        let component = HttpComponent::new();
3378        let endpoint_ctx = NoOpComponentContext;
3379        let endpoint = component
3380            .create_endpoint(
3381                "http://localhost:1/does-not-exist?allowInternal=true",
3382                &endpoint_ctx,
3383            )
3384            .unwrap();
3385        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3386
3387        let mut exchange = Exchange::new(Message::default());
3388        exchange.input.set_header(
3389            "CamelHttpUri",
3390            serde_json::Value::String(format!("{url}/api")),
3391        );
3392
3393        let result = producer.oneshot(exchange).await.unwrap();
3394        let status = result
3395            .input
3396            .header("CamelHttpResponseCode")
3397            .and_then(|v| v.as_u64())
3398            .unwrap();
3399        assert_eq!(status, 200);
3400    }
3401
3402    #[tokio::test]
3403    async fn test_http_producer_response_headers_mapped() {
3404        use tower::ServiceExt;
3405
3406        let (url, _handle) = start_test_server().await;
3407        let ctx = test_producer_ctx();
3408
3409        let component = HttpComponent::new();
3410        let endpoint_ctx = NoOpComponentContext;
3411        let endpoint = component
3412            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3413            .unwrap();
3414        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3415
3416        let exchange = Exchange::new(Message::default());
3417        let result = producer.oneshot(exchange).await.unwrap();
3418
3419        assert!(
3420            result.input.header("Content-Type").is_some(),
3421            "Response should have Content-Type header"
3422        );
3423        assert!(result.input.header("CamelHttpResponseText").is_some());
3424    }
3425
3426    // -----------------------------------------------------------------------
3427    // Bug fix tests: Client configuration per-endpoint
3428    // -----------------------------------------------------------------------
3429
3430    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
3431        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3432        let addr = listener.local_addr().unwrap();
3433        let url = format!("http://127.0.0.1:{}", addr.port());
3434
3435        let handle = tokio::spawn(async move {
3436            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3437            loop {
3438                if let Ok((mut stream, _)) = listener.accept().await {
3439                    tokio::spawn(async move {
3440                        let mut buf = vec![0u8; 4096];
3441                        let n = stream.read(&mut buf).await.unwrap_or(0);
3442                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
3443
3444                        // Check if this is a request to /final
3445                        if request.contains("GET /final") {
3446                            let body = r#"{"status":"final"}"#;
3447                            let response = format!(
3448                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3449                                body.len(),
3450                                body
3451                            );
3452                            let _ = stream.write_all(response.as_bytes()).await;
3453                        } else {
3454                            // Redirect to /final
3455                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
3456                            let _ = stream.write_all(response.as_bytes()).await;
3457                        }
3458                    });
3459                }
3460            }
3461        });
3462
3463        (url, handle)
3464    }
3465
3466    struct CapturedRequest {
3467        method: String,
3468        path: String,
3469        body: Vec<u8>,
3470        content_length: Option<String>,
3471        transfer_encoding: Option<String>,
3472    }
3473
3474    /// Parse a request head plus its Content-Length-driven body from a freshly
3475    /// accepted connection. Returns `None` if the client closes before sending
3476    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
3477    /// keep-alive connections and never sends FIN) and does NOT rely on a
3478    /// single fixed-size read (a segmented small body would flake).
3479    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
3480        use tokio::io::AsyncReadExt;
3481
3482        // Read the request head (up to and including the terminating CRLF CRLF).
3483        let mut buf: Vec<u8> = Vec::new();
3484        let mut chunk = [0u8; 4096];
3485        let head_end: usize;
3486        loop {
3487            let n = stream.read(&mut chunk).await.unwrap_or(0);
3488            if n == 0 {
3489                return None;
3490            }
3491            buf.extend_from_slice(&chunk[..n]);
3492            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
3493                head_end = pos + 4;
3494                break;
3495            }
3496        }
3497
3498        // Parse the request head.
3499        let head = String::from_utf8_lossy(&buf[..head_end]);
3500        let mut lines = head.split("\r\n");
3501        let request_line = lines.next().unwrap_or("");
3502        let mut parts = request_line.split_whitespace();
3503        let method = parts.next().unwrap_or("").to_string();
3504        let path = parts.next().unwrap_or("").to_string();
3505
3506        let mut content_length: Option<String> = None;
3507        let mut transfer_encoding: Option<String> = None;
3508        for line in lines {
3509            if let Some((name, value)) = line.split_once(':') {
3510                let name = name.trim().to_ascii_lowercase();
3511                let value = value.trim().to_string();
3512                if name == "content-length" {
3513                    content_length = Some(value);
3514                } else if name == "transfer-encoding" {
3515                    transfer_encoding = Some(value);
3516                }
3517            }
3518        }
3519
3520        // Content-Length-driven exact read. A missing header means a 0-length body.
3521        let body_len: usize = content_length
3522            .as_deref()
3523            .and_then(|v| v.parse::<usize>().ok())
3524            .unwrap_or(0);
3525
3526        let mut body: Vec<u8> = buf[head_end..].to_vec();
3527        while body.len() < body_len {
3528            let n = stream.read(&mut chunk).await.unwrap_or(0);
3529            if n == 0 {
3530                break;
3531            }
3532            body.extend_from_slice(&chunk[..n]);
3533        }
3534        body.truncate(body_len);
3535
3536        Some(CapturedRequest {
3537            method,
3538            path,
3539            body,
3540            content_length,
3541            transfer_encoding,
3542        })
3543    }
3544
3545    /// A raw-TCP capture server. Each connection parses the request head, then
3546    /// performs a Content-Length-driven exact read of the body (see
3547    /// [`capture_request`]). Each connection is dropped after the response so
3548    /// every hop opens a fresh connection.
3549    async fn start_capture_server() -> (
3550        String,
3551        tokio::task::JoinHandle<()>,
3552        Arc<Mutex<Vec<CapturedRequest>>>,
3553    ) {
3554        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3555        let addr = listener.local_addr().unwrap();
3556        let url = format!("http://127.0.0.1:{}", addr.port());
3557
3558        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3559        let captured_for_return = Arc::clone(&captured);
3560
3561        let handle = tokio::spawn(async move {
3562            use tokio::io::AsyncWriteExt;
3563            loop {
3564                if let Ok((mut stream, _)) = listener.accept().await {
3565                    let captured = Arc::clone(&captured);
3566                    tokio::spawn(async move {
3567                        let Some(req) = capture_request(&mut stream).await else {
3568                            return;
3569                        };
3570                        captured.lock().unwrap().push(req);
3571
3572                        // 200 OK with Content-Length: 0 and no body, then drop
3573                        // the stream so the client opens a fresh connection.
3574                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
3575                        let _ = stream.write_all(response.as_bytes()).await;
3576                    });
3577                }
3578            }
3579        });
3580
3581        (url, handle, captured_for_return)
3582    }
3583
3584    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
3585    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
3586    /// whose `/final` path answers `200 OK` with an empty body. Every hop
3587    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
3588    /// the connection after responding so each hop is a fresh connection.
3589    async fn start_redirect_capture_server() -> (
3590        String,
3591        tokio::task::JoinHandle<()>,
3592        Arc<Mutex<Vec<CapturedRequest>>>,
3593    ) {
3594        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3595        let addr = listener.local_addr().unwrap();
3596        let url = format!("http://127.0.0.1:{}", addr.port());
3597
3598        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3599        let captured_for_return = Arc::clone(&captured);
3600
3601        let handle = tokio::spawn(async move {
3602            use tokio::io::AsyncWriteExt;
3603            loop {
3604                if let Ok((mut stream, _)) = listener.accept().await {
3605                    let captured = Arc::clone(&captured);
3606                    tokio::spawn(async move {
3607                        let Some(req) = capture_request(&mut stream).await else {
3608                            return;
3609                        };
3610                        let path = req.path.clone();
3611                        captured.lock().unwrap().push(req);
3612
3613                        let (status_line, location) = match path.as_str() {
3614                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
3615                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
3616                            "/final" => ("HTTP/1.1 200 OK", None),
3617                            _ => ("HTTP/1.1 404 Not Found", None),
3618                        };
3619
3620                        let response = match location {
3621                            Some(loc) => format!(
3622                                "{status_line}\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n"
3623                            ),
3624                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
3625                        };
3626                        let _ = stream.write_all(response.as_bytes()).await;
3627                    });
3628                }
3629            }
3630        });
3631
3632        (url, handle, captured_for_return)
3633    }
3634
3635    #[tokio::test]
3636    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
3637        use tower::ServiceExt;
3638
3639        let (url, _handle, captured) = start_capture_server().await;
3640        let ctx = test_producer_ctx();
3641
3642        let component = HttpComponent::with_config(HttpConfig::default());
3643        let endpoint_ctx = NoOpComponentContext;
3644        let endpoint = component
3645            .create_endpoint(
3646                &format!("{url}?httpMethod=GET&allowInternal=true"),
3647                &endpoint_ctx,
3648            )
3649            .unwrap();
3650        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3651
3652        let mut exchange = Exchange::new(Message::default());
3653        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3654
3655        let result = producer.oneshot(exchange).await.unwrap();
3656
3657        let status = result
3658            .input
3659            .header("CamelHttpResponseCode")
3660            .and_then(|v| v.as_u64())
3661            .unwrap();
3662        assert_eq!(status, 200);
3663
3664        let captured = captured.lock().unwrap();
3665        assert_eq!(captured.len(), 1, "expected exactly one captured request");
3666        let req = &captured[0];
3667        assert_eq!(req.method, "GET");
3668        // `httpMethod`/`allowInternal` are URI options, not request-target
3669        // query params, so the origin-form target is just "/".
3670        assert_eq!(req.path, "/");
3671        assert!(req.body.is_empty(), "GET must not carry a body");
3672        assert!(
3673            req.content_length.is_none(),
3674            "suppressed request must not carry Content-Length"
3675        );
3676        assert!(
3677            req.transfer_encoding.is_none(),
3678            "suppressed request must not carry Transfer-Encoding"
3679        );
3680
3681        // The exchange body is consumed by the producer (std::mem::take).
3682        assert!(
3683            result.input.body.is_empty(),
3684            "exchange body must be consumed"
3685        );
3686    }
3687
3688    #[tokio::test]
3689    async fn test_head_with_body_suppressed_via_header() {
3690        use tower::ServiceExt;
3691
3692        let (url, _handle, captured) = start_capture_server().await;
3693        let ctx = test_producer_ctx();
3694
3695        let component = HttpComponent::with_config(HttpConfig::default());
3696        let endpoint_ctx = NoOpComponentContext;
3697        let endpoint = component
3698            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3699            .unwrap();
3700        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3701
3702        let mut exchange = Exchange::new(Message::default());
3703        exchange.input.set_header(
3704            "CamelHttpMethod",
3705            serde_json::Value::String("HEAD".to_string()),
3706        );
3707        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3708
3709        let result = producer.oneshot(exchange).await.unwrap();
3710        let status = result
3711            .input
3712            .header("CamelHttpResponseCode")
3713            .and_then(|v| v.as_u64())
3714            .unwrap();
3715        assert_eq!(status, 200);
3716
3717        let captured = captured.lock().unwrap();
3718        assert_eq!(captured.len(), 1);
3719        let req = &captured[0];
3720        assert_eq!(req.method, "HEAD");
3721        assert!(req.body.is_empty(), "HEAD must not carry a body");
3722    }
3723
3724    #[tokio::test]
3725    async fn test_delete_options_trace_with_body_suppressed() {
3726        use tower::ServiceExt;
3727
3728        let (url, _handle, captured) = start_capture_server().await;
3729        let ctx = test_producer_ctx();
3730        let component = HttpComponent::with_config(HttpConfig::default());
3731        let endpoint_ctx = NoOpComponentContext;
3732
3733        for method in ["DELETE", "OPTIONS", "TRACE"] {
3734            let endpoint = component
3735                .create_endpoint(
3736                    &format!("{url}?httpMethod={method}&allowInternal=true"),
3737                    &endpoint_ctx,
3738                )
3739                .unwrap();
3740            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3741
3742            let mut exchange = Exchange::new(Message::default());
3743            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3744
3745            let result = producer.oneshot(exchange).await.unwrap();
3746            let status = result
3747                .input
3748                .header("CamelHttpResponseCode")
3749                .and_then(|v| v.as_u64())
3750                .unwrap();
3751            assert_eq!(status, 200, "method {method} should succeed");
3752        }
3753
3754        let captured = captured.lock().unwrap();
3755        assert_eq!(captured.len(), 3, "expected three captured requests");
3756        for method in ["DELETE", "OPTIONS", "TRACE"] {
3757            let req = captured
3758                .iter()
3759                .find(|r| r.method == method)
3760                .unwrap_or_else(|| panic!("missing captured request for {method}"));
3761            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
3762        }
3763    }
3764
3765    #[tokio::test]
3766    async fn test_post_put_patch_with_body_still_sent() {
3767        use tower::ServiceExt;
3768
3769        let (url, _handle, captured) = start_capture_server().await;
3770        let ctx = test_producer_ctx();
3771        let component = HttpComponent::with_config(HttpConfig::default());
3772        let endpoint_ctx = NoOpComponentContext;
3773
3774        for method in ["POST", "PUT", "PATCH"] {
3775            let endpoint = component
3776                .create_endpoint(
3777                    &format!("{url}?httpMethod={method}&allowInternal=true"),
3778                    &endpoint_ctx,
3779                )
3780                .unwrap();
3781            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3782
3783            let payload = format!("body-for-{method}");
3784            let mut exchange = Exchange::new(Message::default());
3785            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
3786
3787            let result = producer.oneshot(exchange).await.unwrap();
3788            let status = result
3789                .input
3790                .header("CamelHttpResponseCode")
3791                .and_then(|v| v.as_u64())
3792                .unwrap();
3793            assert_eq!(status, 200, "method {method} should succeed");
3794        }
3795
3796        let captured = captured.lock().unwrap();
3797        assert_eq!(captured.len(), 3, "expected three captured requests");
3798        for method in ["POST", "PUT", "PATCH"] {
3799            let req = captured
3800                .iter()
3801                .find(|r| r.method == method)
3802                .unwrap_or_else(|| panic!("missing captured request for {method}"));
3803            let expected = format!("body-for-{method}");
3804            assert!(!req.body.is_empty(), "{method} must still carry its body");
3805            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
3806        }
3807    }
3808
3809    /// A GET with a stream body must not attach the stream: the entity-enclosing
3810    /// gate drops the stream (mem::take) before the request is built, leaving
3811    /// the exchange body Empty instead of a partially-consumed Body::Stream.
3812    #[tokio::test]
3813    async fn test_stream_body_under_get_not_attached() {
3814        use tower::ServiceExt;
3815
3816        let (url, _handle, captured) = start_capture_server().await;
3817        let ctx = test_producer_ctx();
3818
3819        let component = HttpComponent::with_config(HttpConfig::default());
3820        let endpoint_ctx = NoOpComponentContext;
3821        let endpoint = component
3822            .create_endpoint(
3823                &format!("{url}?httpMethod=GET&allowInternal=true"),
3824                &endpoint_ctx,
3825            )
3826            .unwrap();
3827        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3828
3829        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
3830            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
3831        let stream = Box::pin(futures::stream::iter(chunks));
3832        let mut exchange = Exchange::new(Message::default());
3833        exchange.input.body = Body::Stream(StreamBody {
3834            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
3835            metadata: StreamMetadata::default(),
3836        });
3837
3838        let result = producer.oneshot(exchange).await.unwrap();
3839
3840        let status = result
3841            .input
3842            .header("CamelHttpResponseCode")
3843            .and_then(|v| v.as_u64())
3844            .unwrap();
3845        assert_eq!(status, 200);
3846
3847        let captured = captured.lock().unwrap();
3848        assert_eq!(captured.len(), 1, "expected exactly one captured request");
3849        assert!(
3850            captured[0].body.is_empty(),
3851            "GET must not carry a stream body"
3852        );
3853        assert!(
3854            captured[0].transfer_encoding.is_none(),
3855            "suppressed request must not carry Transfer-Encoding"
3856        );
3857        assert!(
3858            captured[0].content_length.is_none(),
3859            "suppressed request must not carry Content-Length"
3860        );
3861        assert!(
3862            result.input.body.is_empty(),
3863            "exchange body must be consumed to Empty, not left as a stream"
3864        );
3865    }
3866
3867    /// A suppressed body must never be replayed across 307/308 redirect hops:
3868    /// the gate empties `materialized_body` before the redirect loop runs, so
3869    /// neither the first hop nor the final hop carries the body.
3870    #[tokio::test]
3871    async fn test_redirect_hops_never_replay_suppressed_body() {
3872        use tower::ServiceExt;
3873
3874        let (url, _handle, captured) = start_redirect_capture_server().await;
3875        let ctx = test_producer_ctx();
3876
3877        let component =
3878            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3879        let endpoint_ctx = NoOpComponentContext;
3880
3881        for path in ["/hop307", "/hop308"] {
3882            let endpoint = component
3883                .create_endpoint(
3884                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
3885                    &endpoint_ctx,
3886                )
3887                .unwrap();
3888            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3889
3890            let mut exchange = Exchange::new(Message::default());
3891            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3892
3893            let result = producer.oneshot(exchange).await.unwrap();
3894            let status = result
3895                .input
3896                .header("CamelHttpResponseCode")
3897                .and_then(|v| v.as_u64())
3898                .unwrap();
3899            assert_eq!(
3900                status, 200,
3901                "redirect chain for {path} should end at /final"
3902            );
3903        }
3904
3905        // Two chains (307 and 308), each with two hops (redirect + final).
3906        let captured = captured.lock().unwrap();
3907        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
3908        for req in captured.iter() {
3909            assert!(
3910                req.body.is_empty(),
3911                "hop {} {} must not carry a body",
3912                req.method,
3913                req.path
3914            );
3915        }
3916    }
3917
3918    /// The warn! emitted on a suppressed body renders three distinguishable
3919    /// substrings in the log line (tracing-subscriber default field format):
3920    ///   - the message:       "dropping request body ..."
3921    ///   - `method = %method_str`            → `method=GET`
3922    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
3923    /// The closure matches all three so exactly one warn per suppressed
3924    /// request is required (the "HTTP request" debug! also carries
3925    /// `method=GET` and the same `correlation_id=`, but not the message).
3926    #[tracing_test::traced_test]
3927    #[tokio::test]
3928    async fn test_suppressed_body_logs_exactly_one_warn() {
3929        use tower::ServiceExt;
3930
3931        let (url, _handle, _captured) = start_capture_server().await;
3932        let ctx = test_producer_ctx();
3933
3934        let component = HttpComponent::with_config(HttpConfig::default());
3935        let endpoint_ctx = NoOpComponentContext;
3936        let endpoint = component
3937            .create_endpoint(
3938                &format!("{url}?httpMethod=GET&allowInternal=true"),
3939                &endpoint_ctx,
3940            )
3941            .unwrap();
3942        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3943
3944        let mut exchange = Exchange::new(Message::default());
3945        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3946        let correlation_id = exchange.correlation_id().to_string();
3947
3948        let result = producer.oneshot(exchange).await.unwrap();
3949        let status = result
3950            .input
3951            .header("CamelHttpResponseCode")
3952            .and_then(|v| v.as_u64())
3953            .unwrap();
3954        assert_eq!(status, 200);
3955
3956        logs_assert(|lines: &[&str]| {
3957            let hits = lines
3958                .iter()
3959                .filter(|l| {
3960                    l.contains("dropping request body")
3961                        && l.contains("method=GET")
3962                        && l.contains(&format!("correlation_id={correlation_id}"))
3963                })
3964                .count();
3965            match hits {
3966                1 => Ok(()),
3967                n => Err(format!("expected exactly one body-drop warn, found {n}")),
3968            }
3969        });
3970    }
3971
3972    #[tracing_test::traced_test]
3973    #[tokio::test]
3974    async fn test_empty_body_get_emits_no_warn() {
3975        use tower::ServiceExt;
3976
3977        let (url, _handle, _captured) = start_capture_server().await;
3978        let ctx = test_producer_ctx();
3979
3980        let component = HttpComponent::with_config(HttpConfig::default());
3981        let endpoint_ctx = NoOpComponentContext;
3982        let endpoint = component
3983            .create_endpoint(
3984                &format!("{url}?httpMethod=GET&allowInternal=true"),
3985                &endpoint_ctx,
3986            )
3987            .unwrap();
3988        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3989
3990        let exchange = Exchange::new(Message::default());
3991        let result = producer.oneshot(exchange).await.unwrap();
3992        let status = result
3993            .input
3994            .header("CamelHttpResponseCode")
3995            .and_then(|v| v.as_u64())
3996            .unwrap();
3997        assert_eq!(status, 200);
3998
3999        logs_assert(|lines: &[&str]| {
4000            let hits = lines
4001                .iter()
4002                .filter(|l| l.contains("dropping request body"))
4003                .count();
4004            match hits {
4005                0 => Ok(()),
4006                n => Err(format!("expected no body-drop warn, found {n}")),
4007            }
4008        });
4009    }
4010
4011    #[tokio::test]
4012    async fn test_follow_redirects_false_does_not_follow() {
4013        use tower::ServiceExt;
4014
4015        let (url, _handle) = start_redirect_server().await;
4016        let ctx = test_producer_ctx();
4017
4018        let component =
4019            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
4020        let endpoint_ctx = NoOpComponentContext;
4021        let endpoint = component
4022            .create_endpoint(
4023                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
4024                &endpoint_ctx,
4025            )
4026            .unwrap();
4027        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4028
4029        let exchange = Exchange::new(Message::default());
4030        let result = producer.oneshot(exchange).await.unwrap();
4031
4032        // Should get 302, NOT follow redirect to 200
4033        let status = result
4034            .input
4035            .header("CamelHttpResponseCode")
4036            .and_then(|v| v.as_u64())
4037            .unwrap();
4038        assert_eq!(
4039            status, 302,
4040            "Should NOT follow redirect when followRedirects=false"
4041        );
4042    }
4043
4044    #[tokio::test]
4045    async fn test_follow_redirects_true_follows_redirect() {
4046        use tower::ServiceExt;
4047
4048        let (url, _handle) = start_redirect_server().await;
4049        let ctx = test_producer_ctx();
4050
4051        let component =
4052            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4053        let endpoint_ctx = NoOpComponentContext;
4054        let endpoint = component
4055            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4056            .unwrap();
4057        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4058
4059        let exchange = Exchange::new(Message::default());
4060        let result = producer.oneshot(exchange).await.unwrap();
4061
4062        // Should follow redirect and get 200
4063        let status = result
4064            .input
4065            .header("CamelHttpResponseCode")
4066            .and_then(|v| v.as_u64())
4067            .unwrap();
4068        assert_eq!(
4069            status, 200,
4070            "Should follow redirect when followRedirects=true"
4071        );
4072    }
4073
4074    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
4075    /// This verifies the manual redirect loop executes correctly.
4076    #[tokio::test]
4077    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
4078        use tower::ServiceExt;
4079
4080        // Use the existing redirect server which redirects to /final on the same server
4081        let (url, _handle) = start_redirect_server().await;
4082        let ctx = test_producer_ctx();
4083
4084        let component =
4085            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4086        let endpoint_ctx = NoOpComponentContext;
4087        let endpoint = component
4088            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4089            .unwrap();
4090        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4091
4092        let exchange = Exchange::new(Message::default());
4093        let result = producer.oneshot(exchange).await;
4094
4095        // With allowInternal=true, the redirect should succeed
4096        assert!(
4097            result.is_ok(),
4098            "Redirect should succeed with allowInternal=true, got: {:?}",
4099            result
4100        );
4101        let exchange = result.unwrap();
4102        let status = exchange
4103            .input
4104            .header("CamelHttpResponseCode")
4105            .and_then(|v| v.as_u64())
4106            .unwrap();
4107        assert_eq!(status, 200, "Should follow redirect to /final");
4108    }
4109
4110    /// With allowInternal=true, redirects to private IPs should be followed.
4111    #[tokio::test]
4112    async fn test_redirect_to_private_ip_allowed_when_configured() {
4113        use tower::ServiceExt;
4114
4115        // Start a server that redirects to /final on the same server (127.0.0.1)
4116        let (url, _handle) = start_redirect_server().await;
4117        let ctx = test_producer_ctx();
4118
4119        let component =
4120            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4121        let endpoint_ctx = NoOpComponentContext;
4122        let endpoint = component
4123            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4124            .unwrap();
4125        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4126
4127        let exchange = Exchange::new(Message::default());
4128        let result = producer.oneshot(exchange).await.unwrap();
4129
4130        let status = result
4131            .input
4132            .header("CamelHttpResponseCode")
4133            .and_then(|v| v.as_u64())
4134            .unwrap();
4135        assert_eq!(
4136            status, 200,
4137            "Should follow redirect to private IP when allowInternal=true"
4138        );
4139    }
4140
4141    /// Integration test: with allowInternal=false (default), a redirect to a
4142    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
4143    #[tokio::test]
4144    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
4145        use tower::ServiceExt;
4146
4147        // Server that redirects to the AWS metadata endpoint (link-local private IP)
4148        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4149        let addr = listener.local_addr().unwrap();
4150        let url = format!("http://127.0.0.1:{}", addr.port());
4151
4152        let handle = tokio::spawn(async move {
4153            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4154            loop {
4155                if let Ok((mut stream, _)) = listener.accept().await {
4156                    tokio::spawn(async move {
4157                        let mut buf = vec![0u8; 4096];
4158                        let _ = stream.read(&mut buf).await;
4159                        // Always redirect to the metadata endpoint
4160                        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";
4161                        let _ = stream.write_all(response.as_bytes()).await;
4162                    });
4163                }
4164            }
4165        });
4166
4167        let ctx = test_producer_ctx();
4168        let component =
4169            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4170        let endpoint_ctx = NoOpComponentContext;
4171        // allowInternal=false is the default — do NOT set it
4172        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
4173        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4174
4175        let exchange = Exchange::new(Message::default());
4176        let result = producer.oneshot(exchange).await;
4177
4178        // Must be an error — SSRF guard blocks the redirect target
4179        assert!(
4180            result.is_err(),
4181            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
4182        );
4183        let err = result.unwrap_err().to_string();
4184        assert!(
4185            err.contains("blocked IP")
4186                || err.contains("private IP")
4187                || err.contains("SSRF")
4188                || err.contains("not allowed"),
4189            "Error should mention SSRF/IP blocking, got: {err}"
4190        );
4191
4192        handle.abort();
4193    }
4194
4195    /// Integration test: exceeding maxRedirects produces a clear error.
4196    #[tokio::test]
4197    async fn test_too_many_redirects_returns_error() {
4198        use tower::ServiceExt;
4199
4200        // Server that always redirects to itself (infinite loop)
4201        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4202        let addr = listener.local_addr().unwrap();
4203        let url = format!("http://127.0.0.1:{}", addr.port());
4204
4205        let handle = tokio::spawn(async move {
4206            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4207            loop {
4208                if let Ok((mut stream, _)) = listener.accept().await {
4209                    tokio::spawn(async move {
4210                        let mut buf = vec![0u8; 4096];
4211                        let _ = stream.read(&mut buf).await;
4212                        // Always redirect to /loop
4213                        let response =
4214                            "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
4215                        let _ = stream.write_all(response.as_bytes()).await;
4216                    });
4217                }
4218            }
4219        });
4220
4221        let ctx = test_producer_ctx();
4222        let component =
4223            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4224        let endpoint_ctx = NoOpComponentContext;
4225        let endpoint = component
4226            .create_endpoint(
4227                &format!("{url}?allowInternal=true&maxRedirects=2"),
4228                &endpoint_ctx,
4229            )
4230            .unwrap();
4231        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4232
4233        let exchange = Exchange::new(Message::default());
4234        let result = producer.oneshot(exchange).await;
4235
4236        // With the fix, exceeding max redirects returns the redirect response
4237        // as-is instead of erroring. The 302 redirect response is returned
4238        // after followRedirects exhausts the allowed redirect count (2).
4239        // Disable throwExceptionOnFailure to inspect the raw response status.
4240        //
4241        // Old behavior: Err("Too many redirects (max 2)")
4242        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
4243        match result {
4244            Err(e) => {
4245                // If throw_exception_on_failure is on, we get HttpOperationFailed
4246                let msg = e.to_string();
4247                assert!(
4248                    msg.contains("HTTP operation failed") || msg.contains("302"),
4249                    "expected redirect-after-exhaustion error, got: {msg}"
4250                );
4251            }
4252            Ok(ex) => {
4253                let response_code = ex
4254                    .input
4255                    .header("CamelHttpResponseCode")
4256                    .and_then(|v| v.as_u64());
4257                assert_eq!(
4258                    response_code,
4259                    Some(302),
4260                    "expected 302 after exhausting redirects"
4261                );
4262            }
4263        }
4264
4265        handle.abort();
4266    }
4267
4268    #[tokio::test]
4269    async fn test_query_params_forwarded_to_http_request() {
4270        use tower::ServiceExt;
4271
4272        let (url, _handle) = start_test_server().await;
4273        let ctx = test_producer_ctx();
4274
4275        let component = HttpComponent::new();
4276        let endpoint_ctx = NoOpComponentContext;
4277        // apiKey is NOT a Camel option, should be forwarded as query param
4278        let endpoint = component
4279            .create_endpoint(
4280                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
4281                &endpoint_ctx,
4282            )
4283            .unwrap();
4284        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4285
4286        let exchange = Exchange::new(Message::default());
4287        let result = producer.oneshot(exchange).await.unwrap();
4288
4289        // The test server returns the request info in response
4290        // We just verify it succeeds (the query param was sent)
4291        let status = result
4292            .input
4293            .header("CamelHttpResponseCode")
4294            .and_then(|v| v.as_u64())
4295            .unwrap();
4296        assert_eq!(status, 200);
4297    }
4298
4299    #[tokio::test]
4300    async fn test_non_camel_query_params_are_forwarded() {
4301        // This test verifies Bug #3 fix: non-Camel options should be forwarded
4302        // We'll test the config parsing, not the actual HTTP call
4303        let config = HttpEndpointConfig::from_uri(
4304            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
4305        )
4306        .unwrap();
4307
4308        // apiKey and token are NOT Camel options, should be forwarded
4309        assert!(
4310            config.query_params.contains_key("apiKey"),
4311            "apiKey should be preserved"
4312        );
4313        assert!(
4314            config.query_params.contains_key("token"),
4315            "token should be preserved"
4316        );
4317        assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
4318        assert_eq!(config.query_params.get("token").unwrap(), "abc456");
4319
4320        // httpMethod IS a Camel option, should NOT be in query_params
4321        assert!(
4322            !config.query_params.contains_key("httpMethod"),
4323            "httpMethod should not be forwarded"
4324        );
4325    }
4326
4327    #[test]
4328    fn test_query_params_are_url_encoded_when_resolving_url() {
4329        let config =
4330            HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
4331        let exchange = Exchange::new(Message::default());
4332
4333        let url = HttpProducer::resolve_url(&exchange, &config);
4334
4335        assert!(url.contains("q=hello+world"), "url was: {url}");
4336        assert!(url.contains("tag=a%2Bb"), "url was: {url}");
4337    }
4338
4339    // -----------------------------------------------------------------------
4340    // Timeout tests (HTTP-004)
4341    // -----------------------------------------------------------------------
4342
4343    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
4344        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4345        let addr = listener.local_addr().unwrap();
4346        let url = format!("http://127.0.0.1:{}", addr.port());
4347
4348        let handle = tokio::spawn(async move {
4349            loop {
4350                if let Ok((mut stream, _)) = listener.accept().await {
4351                    let delay = delay_ms;
4352                    tokio::spawn(async move {
4353                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4354                        let mut buf = vec![0u8; 4096];
4355                        let _ = stream.read(&mut buf).await;
4356                        // Send headers immediately (no Content-Length → chunked)
4357                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
4358                        let _ = stream.write_all(headers.as_bytes()).await;
4359                        // Delay before sending body chunk
4360                        tokio::time::sleep(Duration::from_millis(delay)).await;
4361                        let body = r#"{"status":"slow"}"#;
4362                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
4363                        let _ = stream.write_all(chunk.as_bytes()).await;
4364                    });
4365                }
4366            }
4367        });
4368
4369        (url, handle)
4370    }
4371
4372    #[tokio::test]
4373    async fn test_http_producer_timeout() {
4374        use tower::ServiceExt;
4375
4376        // Server delays 500ms, client timeout is 100ms → should timeout
4377        let (url, _handle) = start_slow_server(500).await;
4378        let ctx = test_producer_ctx();
4379
4380        let component = HttpComponent::with_config(
4381            HttpConfig::default()
4382                .with_read_timeout_ms(100)
4383                .with_response_timeout_ms(30_000), // generous response timeout
4384        );
4385        let endpoint_ctx = NoOpComponentContext;
4386        let endpoint = component
4387            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
4388            .unwrap();
4389        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4390
4391        let exchange = Exchange::new(Message::default());
4392        let result = producer.oneshot(exchange).await;
4393
4394        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
4395        let err = result.unwrap_err().to_string();
4396        assert!(
4397            err.contains("Read timeout") || err.contains("timeout"),
4398            "Error should mention timeout, got: {}",
4399            err
4400        );
4401    }
4402
4403    #[tokio::test]
4404    async fn test_http_producer_no_timeout_when_fast() {
4405        use tower::ServiceExt;
4406
4407        let (url, _handle) = start_test_server().await;
4408        let ctx = test_producer_ctx();
4409
4410        let component =
4411            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
4412        let endpoint_ctx = NoOpComponentContext;
4413        let endpoint = component
4414            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4415            .unwrap();
4416        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4417
4418        let exchange = Exchange::new(Message::default());
4419        let result = producer.oneshot(exchange).await.unwrap();
4420
4421        let status = result
4422            .input
4423            .header("CamelHttpResponseCode")
4424            .and_then(|v| v.as_u64())
4425            .unwrap();
4426        assert_eq!(status, 200);
4427    }
4428
4429    // -----------------------------------------------------------------------
4430    // SSRF Protection tests
4431    // -----------------------------------------------------------------------
4432
4433    #[tokio::test]
4434    async fn test_http_producer_blocks_metadata_endpoint() {
4435        use tower::ServiceExt;
4436
4437        let ctx = test_producer_ctx();
4438        let component = HttpComponent::new();
4439        let endpoint_ctx = NoOpComponentContext;
4440        let endpoint = component
4441            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
4442            .unwrap();
4443        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4444
4445        let mut exchange = Exchange::new(Message::default());
4446        exchange.input.set_header(
4447            "CamelHttpUri",
4448            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
4449        );
4450
4451        let result = producer.oneshot(exchange).await;
4452        assert!(result.is_err(), "Should block AWS metadata endpoint");
4453
4454        let err = result.unwrap_err();
4455        assert!(
4456            err.to_string().contains("Private IP"),
4457            "Error should mention private IP blocking, got: {}",
4458            err
4459        );
4460    }
4461
4462    #[test]
4463    fn test_ssrf_config_defaults() {
4464        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
4465        assert!(
4466            !config.allow_internal,
4467            "Private IPs should be blocked by default"
4468        );
4469        assert!(
4470            config.blocked_hosts.is_empty(),
4471            "Blocked hosts should be empty by default"
4472        );
4473    }
4474
4475    #[test]
4476    fn test_ssrf_config_allow_internal() {
4477        let config =
4478            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
4479        assert!(
4480            config.allow_internal,
4481            "Private IPs should be allowed when explicitly set"
4482        );
4483    }
4484
4485    #[test]
4486    fn test_ssrf_config_blocked_hosts() {
4487        let config = HttpEndpointConfig::from_uri(
4488            "http://example.com/api?blockedHosts=evil.com,malware.net",
4489        )
4490        .unwrap();
4491        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
4492    }
4493
4494    #[tokio::test]
4495    async fn test_http_producer_blocks_localhost() {
4496        use tower::ServiceExt;
4497
4498        let ctx = test_producer_ctx();
4499        let component = HttpComponent::new();
4500        let endpoint_ctx = NoOpComponentContext;
4501        let endpoint = component
4502            .create_endpoint("http://example.com/api", &endpoint_ctx)
4503            .unwrap();
4504        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4505
4506        let mut exchange = Exchange::new(Message::default());
4507        exchange.input.set_header(
4508            "CamelHttpUri",
4509            serde_json::Value::String("http://localhost:8080/internal".to_string()),
4510        );
4511
4512        let result = producer.oneshot(exchange).await;
4513        assert!(result.is_err(), "Should block localhost");
4514    }
4515
4516    #[tokio::test]
4517    async fn test_http_producer_blocks_loopback_ip() {
4518        use tower::ServiceExt;
4519
4520        let ctx = test_producer_ctx();
4521        let component = HttpComponent::new();
4522        let endpoint_ctx = NoOpComponentContext;
4523        let endpoint = component
4524            .create_endpoint("http://example.com/api", &endpoint_ctx)
4525            .unwrap();
4526        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4527
4528        let mut exchange = Exchange::new(Message::default());
4529        exchange.input.set_header(
4530            "CamelHttpUri",
4531            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
4532        );
4533
4534        let result = producer.oneshot(exchange).await;
4535        assert!(result.is_err(), "Should block loopback IP");
4536    }
4537
4538    #[tokio::test]
4539    async fn test_http_producer_allows_private_ip_when_enabled() {
4540        use tower::ServiceExt;
4541
4542        let ctx = test_producer_ctx();
4543        let component = HttpComponent::new();
4544        let endpoint_ctx = NoOpComponentContext;
4545        // With allowInternal=true, the validation should pass
4546        // (actual connection will fail, but that's expected)
4547        let endpoint = component
4548            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
4549            .unwrap();
4550        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4551
4552        let exchange = Exchange::new(Message::default());
4553
4554        // The request will fail because we can't connect, but it should NOT fail
4555        // due to SSRF protection
4556        let result = producer.oneshot(exchange).await;
4557        // We expect connection error, not SSRF error
4558        if let Err(ref e) = result {
4559            let err_str = e.to_string();
4560            assert!(
4561                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
4562                "Should not be SSRF error, got: {}",
4563                err_str
4564            );
4565        }
4566    }
4567
4568    // -----------------------------------------------------------------------
4569    // HttpServerConfig tests
4570    // -----------------------------------------------------------------------
4571
4572    #[test]
4573    fn test_http_server_config_parse() {
4574        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
4575        assert_eq!(cfg.host, "0.0.0.0");
4576        assert_eq!(cfg.port, 8080);
4577        assert_eq!(cfg.path, "/orders");
4578        assert_eq!(cfg.max_inflight_requests, 1024);
4579    }
4580
4581    #[test]
4582    fn test_http_server_config_scheme() {
4583        // UriConfig trait method returns "http" as primary scheme
4584        assert_eq!(HttpServerConfig::scheme(), "http");
4585    }
4586
4587    #[test]
4588    fn test_http_server_config_from_components() {
4589        // Test from_components directly (trait method)
4590        let components = camel_component_api::UriComponents {
4591            scheme: "https".to_string(),
4592            path: "//0.0.0.0:8443/api".to_string(),
4593            params: std::collections::HashMap::from([
4594                ("maxRequestBody".to_string(), "5242880".to_string()),
4595                ("maxInflightRequests".to_string(), "7".to_string()),
4596            ]),
4597        };
4598        let cfg = HttpServerConfig::from_components(components).unwrap();
4599        assert_eq!(cfg.host, "0.0.0.0");
4600        assert_eq!(cfg.port, 8443);
4601        assert_eq!(cfg.path, "/api");
4602        assert_eq!(cfg.max_request_body, 5242880);
4603        assert_eq!(cfg.max_inflight_requests, 7);
4604    }
4605
4606    #[test]
4607    fn test_http_server_config_default_path() {
4608        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
4609        assert_eq!(cfg.path, "/");
4610    }
4611
4612    #[test]
4613    fn test_http_server_config_wrong_scheme() {
4614        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
4615    }
4616
4617    #[test]
4618    fn test_http_server_config_invalid_port() {
4619        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
4620    }
4621
4622    #[test]
4623    fn test_http_server_config_default_port_by_scheme() {
4624        // HTTP without explicit port should default to 80
4625        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
4626        assert_eq!(cfg_http.port, 80);
4627
4628        // HTTPS without explicit port should default to 443
4629        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
4630        assert_eq!(cfg_https.port, 443);
4631    }
4632
4633    #[test]
4634    fn test_request_envelope_and_reply_are_send() {
4635        fn assert_send<T: Send>() {}
4636        assert_send::<RequestEnvelope>();
4637        assert_send::<HttpReply>();
4638    }
4639
4640    // -----------------------------------------------------------------------
4641    // ServerRegistry tests
4642    // -----------------------------------------------------------------------
4643
4644    #[test]
4645    fn test_server_registry_global_is_singleton() {
4646        let r1 = ServerRegistry::global();
4647        let r2 = ServerRegistry::global();
4648        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
4649    }
4650
4651    #[allow(clippy::await_holding_lock)]
4652    #[tokio::test]
4653    async fn test_concurrent_get_or_spawn_returns_same_registry() {
4654        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4655        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4656        let port = listener.local_addr().unwrap().port();
4657        drop(listener);
4658
4659        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
4660            Arc::new(std::sync::Mutex::new(Vec::new()));
4661
4662        let mut handles = Vec::new();
4663        for _ in 0..4 {
4664            let results = results.clone();
4665            handles.push(tokio::spawn(async move {
4666                let registry = ServerRegistry::global()
4667                    .get_or_spawn(
4668                        "127.0.0.1",
4669                        port,
4670                        2 * 1024 * 1024,
4671                        10 * 1024 * 1024,
4672                        1024,
4673                        test_rt(),
4674                        "test-route".into(),
4675                        None,
4676                    )
4677                    .await
4678                    .unwrap();
4679                results.lock().unwrap().push(registry);
4680            }));
4681        }
4682
4683        for h in handles {
4684            h.await.unwrap();
4685        }
4686
4687        let registries = results.lock().unwrap();
4688        assert_eq!(registries.len(), 4);
4689        for i in 1..registries.len() {
4690            assert!(
4691                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
4692                "all concurrent callers should get same route registry"
4693            );
4694        }
4695    }
4696
4697    #[test]
4698    fn test_server_registry_distinguishes_host_and_port() {
4699        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4700        let rt = tokio::runtime::Runtime::new().expect("runtime");
4701        rt.block_on(async {
4702            let registry = ServerRegistry::global();
4703            // Use two distinct host values with same configured port key.
4704            // Port 0 is acceptable here because the registry key uses the configured
4705            // tuple, not the OS-assigned ephemeral port.
4706            let d1 = registry
4707                .get_or_spawn(
4708                    "127.0.0.1",
4709                    0,
4710                    1024 * 1024,
4711                    10 * 1024 * 1024,
4712                    1024,
4713                    test_rt(),
4714                    "test-route-1".into(),
4715                    None,
4716                )
4717                .await;
4718            let d2 = registry
4719                .get_or_spawn(
4720                    "0.0.0.0",
4721                    0,
4722                    1024 * 1024,
4723                    10 * 1024 * 1024,
4724                    1024,
4725                    test_rt(),
4726                    "test-route-2".into(),
4727                    None,
4728                )
4729                .await;
4730            assert!(d1.is_ok());
4731            assert!(d2.is_ok());
4732            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
4733        });
4734    }
4735
4736    #[allow(clippy::await_holding_lock)]
4737    #[tokio::test]
4738    async fn test_shared_server_max_request_body_policy_is_deterministic() {
4739        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4740        let registry = ServerRegistry::global();
4741        // First registration: maxRequestBody = 1 MB
4742        let d1 = registry
4743            .get_or_spawn(
4744                "127.0.0.1",
4745                9991,
4746                1024 * 1024,
4747                10 * 1024 * 1024,
4748                1024,
4749                test_rt(),
4750                "test-route".into(),
4751                None,
4752            )
4753            .await;
4754        assert!(d1.is_ok());
4755
4756        // Second registration on same (host,port): maxRequestBody = 2 MB
4757        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
4758        let d2 = registry
4759            .get_or_spawn(
4760                "127.0.0.1",
4761                9991,
4762                2 * 1024 * 1024,
4763                10 * 1024 * 1024,
4764                1024,
4765                test_rt(),
4766                "test-route-2".into(),
4767                None,
4768            )
4769            .await;
4770        assert!(d2.is_err());
4771        let err = d2.unwrap_err();
4772        assert!(
4773            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
4774            "Expected incompatible maxRequestBody error, got: {}",
4775            err
4776        );
4777    }
4778
4779    #[test]
4780    fn test_server_registry_reset_clears_entries() {
4781        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4782        let rt = tokio::runtime::Runtime::new().expect("runtime");
4783        rt.block_on(async {
4784            // Register something on a unique port
4785            let d1 = ServerRegistry::global()
4786                .get_or_spawn(
4787                    "127.0.0.1",
4788                    9992,
4789                    1024 * 1024,
4790                    10 * 1024 * 1024,
4791                    1024,
4792                    test_rt(),
4793                    "test-route".into(),
4794                    None,
4795                )
4796                .await;
4797            assert!(d1.is_ok());
4798
4799            // Verify entry exists
4800            let guard = ServerRegistry::global().inner.lock().expect("lock");
4801            assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
4802            drop(guard);
4803
4804            // Reset
4805            ServerRegistry::reset();
4806
4807            // Verify cleared
4808            let guard = ServerRegistry::global().inner.lock().expect("lock");
4809            assert!(
4810                guard.is_empty(),
4811                "registry should be empty after reset, has {} entries",
4812                guard.len()
4813            );
4814        });
4815    }
4816
4817    #[tokio::test]
4818    async fn registry_rejects_tls_on_plain_port() {
4819        ServerRegistry::reset();
4820        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
4821
4822        // First route: plain HTTP
4823        let _r1 = ServerRegistry::global()
4824            .get_or_spawn(
4825                "127.0.0.1",
4826                0,
4827                1024,
4828                1024,
4829                16,
4830                Arc::clone(&rt),
4831                "route-1".into(),
4832                None, // plain
4833            )
4834            .await;
4835
4836        // Second route: TLS on same port → must fail
4837        let result = ServerRegistry::global()
4838            .get_or_spawn(
4839                "127.0.0.1",
4840                0,
4841                1024,
4842                1024,
4843                16,
4844                Arc::clone(&rt),
4845                "route-2".into(),
4846                Some(crate::config::ServerTlsConfig {
4847                    cert_path: "/x.pem".into(),
4848                    key_path: "/y.pem".into(),
4849                }),
4850            )
4851            .await;
4852        assert!(result.is_err(), "must reject TLS on plain port");
4853    }
4854
4855    // -----------------------------------------------------------------------
4856    // D-L10: HTTP monitor_axum_task refcounted shutdown
4857    // -----------------------------------------------------------------------
4858
4859    #[allow(clippy::await_holding_lock)]
4860    #[tokio::test]
4861    async fn test_unregister_last_http_route_keeps_server_alive() {
4862        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4863        ServerRegistry::reset();
4864        let registry = ServerRegistry::global();
4865
4866        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4867        let port = listener.local_addr().unwrap().port();
4868        drop(listener); // Release — ServerRegistry will rebind
4869        let rt = test_rt();
4870
4871        // Register 2 routes on the same (host, port) — OnceCell returns the
4872        // same ServerHandle.
4873        let _r1 = registry
4874            .get_or_spawn(
4875                "127.0.0.1",
4876                port,
4877                1024 * 1024,
4878                10 * 1024 * 1024,
4879                16,
4880                rt.clone(),
4881                "test-route-1".into(),
4882                None,
4883            )
4884            .await
4885            .unwrap();
4886        let _r2 = registry
4887            .get_or_spawn(
4888                "127.0.0.1",
4889                port,
4890                1024 * 1024,
4891                10 * 1024 * 1024,
4892                16,
4893                rt,
4894                "test-route-2".into(),
4895                None,
4896            )
4897            .await
4898            .unwrap();
4899
4900        let key = ("127.0.0.1".to_string(), port);
4901        let cell = {
4902            let guard = registry.inner.lock().expect("lock");
4903            guard.get(&key).expect("entry should exist").clone()
4904        };
4905
4906        // Unregister first route -> monitor still alive (count = 1).
4907        registry.unregister("127.0.0.1", port).await;
4908        {
4909            let handle = cell
4910                .get()
4911                .expect("handle should still exist after first unregister");
4912            assert!(
4913                !handle.monitor_task.is_finished(),
4914                "monitor task should still be alive after first unregister"
4915            );
4916        }
4917
4918        // Unregister second route -> server stays alive (process-lifetime).
4919        registry.unregister("127.0.0.1", port).await;
4920        tokio::time::sleep(Duration::from_millis(20)).await;
4921        {
4922            let handle = cell
4923                .get()
4924                .expect("handle should still exist after last unregister");
4925            assert!(
4926                !handle.monitor_task.is_finished(),
4927                "monitor task should still be alive — server is process-lifetime"
4928            );
4929        }
4930
4931        // Entry stays in registry for potential restart.
4932        {
4933            let guard = registry.inner.lock().expect("lock");
4934            assert!(
4935                guard.get(&key).is_some(),
4936                "entry should remain in registry — server kept alive for restart"
4937            );
4938        }
4939    }
4940
4941    // -----------------------------------------------------------------------
4942    // Axum dispatch handler tests
4943    // -----------------------------------------------------------------------
4944
4945    #[tokio::test]
4946    async fn test_dispatch_handler_returns_404_for_unknown_path() {
4947        let registry = HttpRouteRegistry::new();
4948        // Nothing registered in route registry
4949        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4950        let port = listener.local_addr().unwrap().port();
4951        tokio::spawn(run_axum_server(
4952            listener,
4953            registry,
4954            2 * 1024 * 1024,
4955            10 * 1024 * 1024,
4956            Arc::new(tokio::sync::Semaphore::new(1024)),
4957            test_rt(),
4958            "test-route".into(),
4959        ));
4960
4961        // Wait for server to start
4962        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4963
4964        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
4965            .await
4966            .unwrap();
4967        assert_eq!(resp.status().as_u16(), 404);
4968    }
4969
4970    // -----------------------------------------------------------------------
4971    // HttpConsumer tests
4972    // -----------------------------------------------------------------------
4973
4974    #[tokio::test]
4975    async fn test_http_consumer_start_registers_path() {
4976        use camel_component_api::ConsumerContext;
4977
4978        // Get an OS-assigned free port
4979        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4980        let port = listener.local_addr().unwrap().port();
4981        drop(listener); // Release port — ServerRegistry will rebind it
4982
4983        let consumer_cfg = HttpServerConfig {
4984            scheme: "http".to_string(),
4985            host: "127.0.0.1".to_string(),
4986            port,
4987            path: "/ping".to_string(),
4988            max_request_body: 2 * 1024 * 1024,
4989            max_response_body: 10 * 1024 * 1024,
4990            max_inflight_requests: 1024,
4991            method: None,
4992            tls_config: None,
4993        };
4994        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4995
4996        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4997        let token = tokio_util::sync::CancellationToken::new();
4998        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4999
5000        tokio::spawn(async move {
5001            consumer.start(ctx).await.unwrap();
5002        });
5003
5004        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5005
5006        let client = reqwest::Client::new();
5007        let resp_future = client
5008            .post(format!("http://127.0.0.1:{port}/ping"))
5009            .body("hello world")
5010            .send();
5011
5012        let (http_result, _) = tokio::join!(resp_future, async {
5013            if let Some(mut envelope) = rx.recv().await {
5014                // Set a custom status code
5015                envelope.exchange.input.set_header(
5016                    "CamelHttpResponseCode",
5017                    serde_json::Value::Number(201.into()),
5018                );
5019                if let Some(reply_tx) = envelope.reply_tx {
5020                    let _ = reply_tx.send(Ok(envelope.exchange));
5021                }
5022            }
5023        });
5024
5025        let resp = http_result.unwrap();
5026        assert_eq!(resp.status().as_u16(), 201);
5027
5028        token.cancel();
5029    }
5030
5031    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
5032    /// dispatcher's inflight semaphore so the semaphore stays the single
5033    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
5034    #[test]
5035    fn test_envelope_channel_capacity_follows_max_inflight() {
5036        assert_eq!(envelope_channel_capacity(0), 1);
5037        assert_eq!(envelope_channel_capacity(1), 1);
5038        assert_eq!(envelope_channel_capacity(7), 7);
5039        assert_eq!(envelope_channel_capacity(64), 64);
5040        assert_eq!(envelope_channel_capacity(1024), 1024);
5041    }
5042
5043    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
5044    /// configuration. Consumer start must not panic on it (the channel guard)
5045    /// and every request must get 503 from the empty semaphore.
5046    #[tokio::test]
5047    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
5048        use camel_component_api::ConsumerContext;
5049
5050        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5051        let port = listener.local_addr().unwrap().port();
5052        drop(listener);
5053
5054        let consumer_cfg = HttpServerConfig {
5055            scheme: "http".to_string(),
5056            host: "127.0.0.1".to_string(),
5057            port,
5058            path: "/ping".to_string(),
5059            max_request_body: 2 * 1024 * 1024,
5060            max_response_body: 10 * 1024 * 1024,
5061            max_inflight_requests: 0,
5062            method: None,
5063            tls_config: None,
5064        };
5065        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5066
5067        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5068        let token = tokio_util::sync::CancellationToken::new();
5069        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5070
5071        let start_handle = tokio::spawn(async move {
5072            consumer.start(ctx).await.unwrap();
5073        });
5074
5075        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5076
5077        let client = reqwest::Client::new();
5078        let resp = client
5079            .post(format!("http://127.0.0.1:{port}/ping"))
5080            .body("hello world")
5081            .send()
5082            .await
5083            .unwrap();
5084        assert_eq!(resp.status().as_u16(), 503);
5085
5086        token.cancel();
5087        let _ = start_handle.await;
5088    }
5089
5090    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
5091    /// waits for the listener bind before publishing RouteStarted.
5092    #[test]
5093    fn test_http_consumer_startup_mode_is_explicit() {
5094        use camel_component_api::ConsumerStartupMode;
5095        let consumer_cfg = HttpServerConfig {
5096            scheme: "http".to_string(),
5097            host: "127.0.0.1".to_string(),
5098            port: 0,
5099            path: "/x".to_string(),
5100            max_request_body: 2 * 1024 * 1024,
5101            max_response_body: 10 * 1024 * 1024,
5102            max_inflight_requests: 1024,
5103            method: None,
5104            tls_config: None,
5105        };
5106        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
5107        assert_eq!(
5108            consumer.startup_mode(),
5109            ConsumerStartupMode::Explicit,
5110            "HttpConsumer must opt into Explicit startup"
5111        );
5112    }
5113
5114    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
5115    /// + route registration. The StartupSignal resolves Ok only when that
5116    /// happens. Verified here by injecting our own signal pair into the
5117    /// ConsumerContext and asserting the receiver resolves within a bounded
5118    /// window even before any HTTP request is made.
5119    #[allow(clippy::await_holding_lock)]
5120    #[tokio::test]
5121    async fn test_http_consumer_emits_mark_ready_after_bind() {
5122        use camel_component_api::{ConsumerContext, StartupSignal};
5123
5124        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5125
5126        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5127        let port = listener.local_addr().unwrap().port();
5128        drop(listener);
5129
5130        let consumer_cfg = HttpServerConfig {
5131            scheme: "http".to_string(),
5132            host: "127.0.0.1".to_string(),
5133            port,
5134            path: "/ready-probe".to_string(),
5135            max_request_body: 2 * 1024 * 1024,
5136            max_response_body: 10 * 1024 * 1024,
5137            max_inflight_requests: 1024,
5138            method: None,
5139            tls_config: None,
5140        };
5141        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5142
5143        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5144        let token = tokio_util::sync::CancellationToken::new();
5145        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
5146
5147        // Inject our own startup signal so we can observe mark_ready.
5148        let (signal, startup_rx) = StartupSignal::pair();
5149        let ctx = ctx.with_startup(signal);
5150
5151        // Spawn start() — it MUST call mark_ready once the listener is bound
5152        // and the path is registered.
5153        tokio::spawn(async move {
5154            let _ = consumer.start(ctx).await;
5155        });
5156
5157        // The receiver MUST resolve Ok within a bounded window — proving
5158        // mark_ready was called by start(). A short timeout catches the
5159        // regression where mark_ready is never called (the old behaviour
5160        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
5161        let result =
5162            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
5163                .await
5164                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
5165        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
5166
5167        // Cancellation tears down the spawned start() loop.
5168        token.cancel();
5169    }
5170
5171    #[tokio::test]
5172    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
5173        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5174
5175        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5176        let port = listener.local_addr().unwrap().port();
5177        drop(listener);
5178
5179        let consumer_cfg = HttpServerConfig {
5180            scheme: "http".to_string(),
5181            host: "127.0.0.1".to_string(),
5182            port,
5183            path: "/saturation".to_string(),
5184            max_request_body: 2 * 1024 * 1024,
5185            max_response_body: 10 * 1024 * 1024,
5186            max_inflight_requests: 1,
5187            method: None,
5188            tls_config: None,
5189        };
5190        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5191
5192        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5193        let token = tokio_util::sync::CancellationToken::new();
5194        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5195        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5196        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5197
5198        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
5199        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
5200
5201        tokio::spawn(async move {
5202            let mut first_seen_tx = Some(first_seen_tx);
5203            let mut unblock_first_rx = Some(unblock_first_rx);
5204
5205            while let Some(envelope) = rx.recv().await {
5206                if let Some(tx) = first_seen_tx.take() {
5207                    let _ = tx.send(());
5208                    if let Some(rx_unblock) = unblock_first_rx.take() {
5209                        let _ = rx_unblock.await;
5210                    }
5211                }
5212
5213                if let Some(reply_tx) = envelope.reply_tx {
5214                    let _ = reply_tx.send(Ok(envelope.exchange));
5215                }
5216            }
5217        });
5218
5219        let client = reqwest::Client::new();
5220        let first_req = {
5221            let client = client.clone();
5222            async move {
5223                client
5224                    .get(format!("http://127.0.0.1:{port}/saturation"))
5225                    .send()
5226                    .await
5227                    .unwrap()
5228            }
5229        };
5230
5231        let first_handle = tokio::spawn(first_req);
5232        first_seen_rx.await.unwrap();
5233
5234        let second_resp = client
5235            .get(format!("http://127.0.0.1:{port}/saturation"))
5236            .send()
5237            .await
5238            .unwrap();
5239
5240        assert_eq!(second_resp.status().as_u16(), 503);
5241
5242        let _ = unblock_first_tx.send(());
5243        let first_resp = first_handle.await.unwrap();
5244        assert_eq!(first_resp.status().as_u16(), 200);
5245
5246        token.cancel();
5247    }
5248
5249    #[tokio::test]
5250    #[allow(clippy::await_holding_lock)]
5251    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
5252        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5253
5254        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5255
5256        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5257        let port = listener.local_addr().unwrap().port();
5258        drop(listener);
5259
5260        let consumer_cfg = HttpServerConfig {
5261            scheme: "http".to_string(),
5262            host: "127.0.0.1".to_string(),
5263            port,
5264            path: "/limit-bytes".to_string(),
5265            max_request_body: 2 * 1024 * 1024,
5266            max_response_body: 16,
5267            max_inflight_requests: 1024,
5268            method: None,
5269            tls_config: None,
5270        };
5271        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5272
5273        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5274        let token = tokio_util::sync::CancellationToken::new();
5275        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5276        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5277        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5278
5279        let client = reqwest::Client::new();
5280        let send_fut = client
5281            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
5282            .send();
5283
5284        let (http_result, _) = tokio::join!(send_fut, async {
5285            if let Some(mut envelope) = rx.recv().await {
5286                envelope.exchange.input.body =
5287                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
5288                if let Some(reply_tx) = envelope.reply_tx {
5289                    let _ = reply_tx.send(Ok(envelope.exchange));
5290                }
5291            }
5292        });
5293
5294        let resp = http_result.unwrap();
5295        assert_eq!(resp.status().as_u16(), 500);
5296        let body = resp.text().await.unwrap();
5297        assert_eq!(body, "Response body exceeds configured limit");
5298        token.cancel();
5299    }
5300
5301    #[tokio::test]
5302    #[allow(clippy::await_holding_lock)]
5303    async fn test_http_consumer_enforces_max_response_body_for_json() {
5304        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5305
5306        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5307
5308        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5309        let port = listener.local_addr().unwrap().port();
5310        drop(listener);
5311
5312        let consumer_cfg = HttpServerConfig {
5313            scheme: "http".to_string(),
5314            host: "127.0.0.1".to_string(),
5315            port,
5316            path: "/limit-json".to_string(),
5317            max_request_body: 2 * 1024 * 1024,
5318            max_response_body: 16,
5319            max_inflight_requests: 1024,
5320            method: None,
5321            tls_config: None,
5322        };
5323        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5324
5325        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5326        let token = tokio_util::sync::CancellationToken::new();
5327        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5328        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5329        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5330
5331        let client = reqwest::Client::new();
5332        let send_fut = client
5333            .get(format!("http://127.0.0.1:{port}/limit-json"))
5334            .send();
5335
5336        let (http_result, _) = tokio::join!(send_fut, async {
5337            if let Some(mut envelope) = rx.recv().await {
5338                envelope.exchange.input.body = camel_component_api::Body::Json(
5339                    serde_json::json!({"message":"this response is bigger than sixteen"}),
5340                );
5341                if let Some(reply_tx) = envelope.reply_tx {
5342                    let _ = reply_tx.send(Ok(envelope.exchange));
5343                }
5344            }
5345        });
5346
5347        let resp = http_result.unwrap();
5348        assert_eq!(resp.status().as_u16(), 500);
5349        let body = resp.text().await.unwrap();
5350        assert_eq!(body, "Response body exceeds configured limit");
5351        token.cancel();
5352    }
5353
5354    #[tokio::test]
5355    #[allow(clippy::await_holding_lock)]
5356    async fn test_http_consumer_enforces_max_response_body_for_xml() {
5357        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5358
5359        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5360
5361        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5362        let port = listener.local_addr().unwrap().port();
5363        drop(listener);
5364
5365        let consumer_cfg = HttpServerConfig {
5366            scheme: "http".to_string(),
5367            host: "127.0.0.1".to_string(),
5368            port,
5369            path: "/limit-xml".to_string(),
5370            max_request_body: 2 * 1024 * 1024,
5371            max_response_body: 16,
5372            max_inflight_requests: 1024,
5373            method: None,
5374            tls_config: None,
5375        };
5376        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5377
5378        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5379        let token = tokio_util::sync::CancellationToken::new();
5380        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5381        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5382        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5383
5384        let client = reqwest::Client::new();
5385        let send_fut = client
5386            .get(format!("http://127.0.0.1:{port}/limit-xml"))
5387            .send();
5388
5389        let (http_result, _) = tokio::join!(send_fut, async {
5390            if let Some(mut envelope) = rx.recv().await {
5391                envelope.exchange.input.body = camel_component_api::Body::Xml(
5392                    "<root><value>way-too-large</value></root>".into(),
5393                );
5394                if let Some(reply_tx) = envelope.reply_tx {
5395                    let _ = reply_tx.send(Ok(envelope.exchange));
5396                }
5397            }
5398        });
5399
5400        let resp = http_result.unwrap();
5401        assert_eq!(resp.status().as_u16(), 500);
5402        let body = resp.text().await.unwrap();
5403        assert_eq!(body, "Response body exceeds configured limit");
5404        token.cancel();
5405    }
5406
5407    #[tokio::test]
5408    #[allow(clippy::await_holding_lock)]
5409    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
5410        use camel_component_api::{
5411            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
5412        };
5413        use futures::stream;
5414
5415        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5416
5417        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
5418        let port = listener.local_addr().unwrap().port();
5419        drop(listener);
5420
5421        let consumer_cfg = HttpServerConfig {
5422            scheme: "http".to_string(),
5423            host: "0.0.0.0".to_string(),
5424            port,
5425            path: "/limit-stream".to_string(),
5426            max_request_body: 2 * 1024 * 1024,
5427            max_response_body: 16,
5428            max_inflight_requests: 1024,
5429            method: None,
5430            tls_config: None,
5431        };
5432        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5433
5434        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5435        let token = tokio_util::sync::CancellationToken::new();
5436        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5437        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5438        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5439
5440        let client = reqwest::Client::new();
5441        let send_fut = client
5442            .get(format!("http://127.0.0.1:{port}/limit-stream"))
5443            .send();
5444
5445        let (http_result, _) = tokio::join!(send_fut, async {
5446            if let Some(mut envelope) = rx.recv().await {
5447                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5448                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
5449                let stream = Box::pin(stream::iter(chunks));
5450                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5451                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5452                    metadata: StreamMetadata {
5453                        size_hint: Some(32),
5454                        content_type: Some("application/octet-stream".into()),
5455                        origin: None,
5456                    },
5457                });
5458                if let Some(reply_tx) = envelope.reply_tx {
5459                    let _ = reply_tx.send(Ok(envelope.exchange));
5460                }
5461            }
5462        });
5463
5464        let resp = http_result.unwrap();
5465        assert_eq!(resp.status().as_u16(), 200);
5466        let body = resp.bytes().await.unwrap();
5467        assert_eq!(body.len(), 32);
5468        token.cancel();
5469    }
5470
5471    // -----------------------------------------------------------------------
5472    // Integration tests
5473    // -----------------------------------------------------------------------
5474
5475    #[tokio::test]
5476    #[allow(clippy::await_holding_lock)]
5477    async fn test_integration_single_consumer_round_trip() {
5478        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5479
5480        // Spawns an HTTP consumer on the global ServerRegistry
5481        // (HttpConsumer::start → get_or_spawn). Serialize against the other
5482        // registry tests so parallel runs do not race on shared global state.
5483        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5484
5485        // Get an OS-assigned free port (ephemeral)
5486        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5487        let port = listener.local_addr().unwrap().port();
5488        drop(listener); // Release — ServerRegistry will rebind
5489
5490        let component = HttpComponent::new();
5491        let endpoint_ctx = NoOpComponentContext;
5492        let endpoint = component
5493            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
5494            .unwrap();
5495        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5496
5497        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5498        let token = tokio_util::sync::CancellationToken::new();
5499        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5500
5501        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5502        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5503
5504        let client = reqwest::Client::new();
5505        let send_fut = client
5506            .post(format!("http://127.0.0.1:{port}/echo"))
5507            .header("Content-Type", "text/plain")
5508            .body("ping")
5509            .send();
5510
5511        let (http_result, _) = tokio::join!(send_fut, async {
5512            if let Some(mut envelope) = rx.recv().await {
5513                assert_eq!(
5514                    envelope.exchange.input.header("CamelHttpMethod"),
5515                    Some(&serde_json::Value::String("POST".into()))
5516                );
5517                assert_eq!(
5518                    envelope.exchange.input.header("CamelHttpPath"),
5519                    Some(&serde_json::Value::String("/echo".into()))
5520                );
5521                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
5522                if let Some(reply_tx) = envelope.reply_tx {
5523                    let _ = reply_tx.send(Ok(envelope.exchange));
5524                }
5525            }
5526        });
5527
5528        let resp = http_result.unwrap();
5529        assert_eq!(resp.status().as_u16(), 200);
5530        let body = resp.text().await.unwrap();
5531        assert_eq!(body, "pong");
5532
5533        token.cancel();
5534    }
5535
5536    #[tokio::test]
5537    #[allow(clippy::await_holding_lock)]
5538    async fn test_integration_two_consumers_shared_port() {
5539        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5540
5541        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5542
5543        // Get an OS-assigned free port (ephemeral)
5544        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5545        let port = listener.local_addr().unwrap().port();
5546        drop(listener);
5547
5548        let component = HttpComponent::new();
5549        let endpoint_ctx = NoOpComponentContext;
5550
5551        // Consumer A: /hello
5552        let endpoint_a = component
5553            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
5554            .unwrap();
5555        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
5556
5557        // Consumer B: /world
5558        let endpoint_b = component
5559            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
5560            .unwrap();
5561        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
5562
5563        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5564        let token_a = tokio_util::sync::CancellationToken::new();
5565        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
5566
5567        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5568        let token_b = tokio_util::sync::CancellationToken::new();
5569        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
5570
5571        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
5572        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
5573        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5574
5575        let client = reqwest::Client::new();
5576
5577        // Request to /hello
5578        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
5579        let (resp_hello, _) = tokio::join!(fut_hello, async {
5580            if let Some(mut envelope) = rx_a.recv().await {
5581                envelope.exchange.input.body =
5582                    camel_component_api::Body::Text("hello-response".to_string());
5583                if let Some(reply_tx) = envelope.reply_tx {
5584                    let _ = reply_tx.send(Ok(envelope.exchange));
5585                }
5586            }
5587        });
5588
5589        // Request to /world
5590        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
5591        let (resp_world, _) = tokio::join!(fut_world, async {
5592            if let Some(mut envelope) = rx_b.recv().await {
5593                envelope.exchange.input.body =
5594                    camel_component_api::Body::Text("world-response".to_string());
5595                if let Some(reply_tx) = envelope.reply_tx {
5596                    let _ = reply_tx.send(Ok(envelope.exchange));
5597                }
5598            }
5599        });
5600
5601        let body_a = resp_hello.unwrap().text().await.unwrap();
5602        let body_b = resp_world.unwrap().text().await.unwrap();
5603
5604        assert_eq!(body_a, "hello-response");
5605        assert_eq!(body_b, "world-response");
5606
5607        token_a.cancel();
5608        token_b.cancel();
5609    }
5610
5611    #[tokio::test]
5612    #[allow(clippy::await_holding_lock)]
5613    async fn test_integration_unregistered_path_returns_404() {
5614        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5615
5616        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5617
5618        // Get an OS-assigned free port (ephemeral)
5619        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5620        let port = listener.local_addr().unwrap().port();
5621        drop(listener);
5622
5623        let component = HttpComponent::new();
5624        let endpoint_ctx = NoOpComponentContext;
5625        let endpoint = component
5626            .create_endpoint(
5627                &format!("http://127.0.0.1:{port}/registered"),
5628                &endpoint_ctx,
5629            )
5630            .unwrap();
5631        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5632
5633        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5634        let token = tokio_util::sync::CancellationToken::new();
5635        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5636
5637        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5638
5639        // Wait until the server is actually accepting connections (CI runners can be slow).
5640        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
5641        loop {
5642            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
5643                .await
5644                .is_ok()
5645            {
5646                break;
5647            }
5648            if std::time::Instant::now() >= deadline {
5649                panic!("HTTP server did not start within 5s on port {port}");
5650            }
5651            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5652        }
5653
5654        let client = reqwest::Client::new();
5655        let resp = client
5656            .get(format!("http://127.0.0.1:{port}/not-there"))
5657            .send()
5658            .await
5659            .unwrap();
5660        assert_eq!(resp.status().as_u16(), 404);
5661
5662        token.cancel();
5663    }
5664
5665    #[test]
5666    fn test_http_consumer_declares_concurrent() {
5667        use camel_component_api::ConcurrencyModel;
5668
5669        let config = HttpServerConfig {
5670            scheme: "http".to_string(),
5671            host: "127.0.0.1".to_string(),
5672            port: 19999,
5673            path: "/test".to_string(),
5674            max_request_body: 2 * 1024 * 1024,
5675            max_response_body: 10 * 1024 * 1024,
5676            max_inflight_requests: 1024,
5677            method: None,
5678            tls_config: None,
5679        };
5680        let consumer = HttpConsumer::new(config, test_rt());
5681        assert_eq!(
5682            consumer.concurrency_model(),
5683            ConcurrencyModel::Concurrent { max: None }
5684        );
5685    }
5686
5687    #[test]
5688    fn server_config_parses_tls_cert_and_key() {
5689        let cfg = HttpServerConfig::from_uri(
5690            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
5691        )
5692        .unwrap();
5693        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
5694        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
5695    }
5696
5697    #[test]
5698    fn server_config_no_tls_when_params_absent() {
5699        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
5700        assert!(cfg.tls_config.is_none());
5701    }
5702
5703    // -----------------------------------------------------------------------
5704    // HttpReplyBody streaming tests
5705    // -----------------------------------------------------------------------
5706
5707    #[tokio::test]
5708    async fn test_http_reply_body_stream_variant_exists() {
5709        use bytes::Bytes;
5710        use camel_component_api::CamelError;
5711        use futures::stream;
5712
5713        let chunks: Vec<Result<Bytes, CamelError>> =
5714            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
5715        let stream = Box::pin(stream::iter(chunks));
5716        let reply_body = HttpReplyBody::Stream(stream);
5717        // Si compila y el match funciona, el test pasa
5718        match reply_body {
5719            HttpReplyBody::Stream(_) => {}
5720            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
5721        }
5722    }
5723
5724    // -----------------------------------------------------------------------
5725    // OpenTelemetry propagation tests (only compiled with "otel" feature)
5726    // -----------------------------------------------------------------------
5727
5728    #[cfg(feature = "otel")]
5729    mod otel_tests {
5730        use super::*;
5731        use camel_component_api::Message;
5732        use tower::ServiceExt;
5733
5734        #[tokio::test]
5735        async fn test_producer_injects_traceparent_header() {
5736            let (url, _handle) = start_test_server_with_header_capture().await;
5737            let ctx = test_producer_ctx();
5738
5739            let component = HttpComponent::new();
5740            let endpoint_ctx = NoOpComponentContext;
5741            let endpoint = component
5742                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5743                .unwrap();
5744            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5745
5746            // Create exchange with an OTel context by extracting from a traceparent header
5747            let mut exchange = Exchange::new(Message::default());
5748            let mut headers = std::collections::HashMap::new();
5749            headers.insert(
5750                "traceparent".to_string(),
5751                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
5752            );
5753            camel_otel::extract_into_exchange(&mut exchange, &headers);
5754
5755            let result = producer.oneshot(exchange).await.unwrap();
5756
5757            // Verify request succeeded
5758            let status = result
5759                .input
5760                .header("CamelHttpResponseCode")
5761                .and_then(|v| v.as_u64())
5762                .unwrap();
5763            assert_eq!(status, 200);
5764
5765            // The test server echoes back the received traceparent header
5766            let traceparent = result.input.header("X-Received-Traceparent");
5767            assert!(
5768                traceparent.is_some(),
5769                "traceparent header should have been sent"
5770            );
5771
5772            let traceparent_str = traceparent.unwrap().as_str().unwrap();
5773            // Verify format: version-traceid-spanid-flags
5774            let parts: Vec<&str> = traceparent_str.split('-').collect();
5775            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5776            assert_eq!(parts[0], "00", "version should be 00");
5777            assert_eq!(
5778                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5779                "trace-id should match"
5780            );
5781            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
5782            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
5783        }
5784
5785        #[tokio::test]
5786        async fn test_consumer_extracts_traceparent_header() {
5787            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5788
5789            // Get an OS-assigned free port
5790            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5791            let port = listener.local_addr().unwrap().port();
5792            drop(listener);
5793
5794            let component = HttpComponent::new();
5795            let endpoint_ctx = NoOpComponentContext;
5796            let endpoint = component
5797                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5798                .unwrap();
5799            let mut consumer = endpoint.create_consumer(rt()).unwrap();
5800
5801            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5802            let token = tokio_util::sync::CancellationToken::new();
5803            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5804
5805            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5806            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5807
5808            // Send request with traceparent header
5809            let client = reqwest::Client::new();
5810            let send_fut = client
5811                .post(format!("http://127.0.0.1:{port}/trace"))
5812                .header(
5813                    "traceparent",
5814                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5815                )
5816                .body("test")
5817                .send();
5818
5819            let (http_result, _) = tokio::join!(send_fut, async {
5820                if let Some(envelope) = rx.recv().await {
5821                    // Verify the exchange has a valid OTel context by re-injecting it
5822                    // and checking the traceparent matches
5823                    let mut injected_headers = std::collections::HashMap::new();
5824                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5825
5826                    assert!(
5827                        injected_headers.contains_key("traceparent"),
5828                        "Exchange should have traceparent after extraction"
5829                    );
5830
5831                    let traceparent = injected_headers.get("traceparent").unwrap();
5832                    let parts: Vec<&str> = traceparent.split('-').collect();
5833                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5834                    assert_eq!(
5835                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5836                        "Trace ID should match the original traceparent header"
5837                    );
5838
5839                    if let Some(reply_tx) = envelope.reply_tx {
5840                        let _ = reply_tx.send(Ok(envelope.exchange));
5841                    }
5842                }
5843            });
5844
5845            let resp = http_result.unwrap();
5846            assert_eq!(resp.status().as_u16(), 200);
5847
5848            token.cancel();
5849        }
5850
5851        #[tokio::test]
5852        async fn test_consumer_extracts_mixed_case_traceparent_header() {
5853            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5854
5855            // Get an OS-assigned free port
5856            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5857            let port = listener.local_addr().unwrap().port();
5858            drop(listener);
5859
5860            let component = HttpComponent::new();
5861            let endpoint_ctx = NoOpComponentContext;
5862            let endpoint = component
5863                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5864                .unwrap();
5865            let mut consumer = endpoint.create_consumer(rt()).unwrap();
5866
5867            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5868            let token = tokio_util::sync::CancellationToken::new();
5869            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5870
5871            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5872            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5873
5874            // Send request with MIXED-CASE TraceParent header (not lowercase)
5875            let client = reqwest::Client::new();
5876            let send_fut = client
5877                .post(format!("http://127.0.0.1:{port}/trace"))
5878                .header(
5879                    "TraceParent",
5880                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5881                )
5882                .body("test")
5883                .send();
5884
5885            let (http_result, _) = tokio::join!(send_fut, async {
5886                if let Some(envelope) = rx.recv().await {
5887                    // Verify the exchange has a valid OTel context by re-injecting it
5888                    // and checking the traceparent matches
5889                    let mut injected_headers = HashMap::new();
5890                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5891
5892                    assert!(
5893                        injected_headers.contains_key("traceparent"),
5894                        "Exchange should have traceparent after extraction from mixed-case header"
5895                    );
5896
5897                    let traceparent = injected_headers.get("traceparent").unwrap();
5898                    let parts: Vec<&str> = traceparent.split('-').collect();
5899                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5900                    assert_eq!(
5901                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5902                        "Trace ID should match the original mixed-case TraceParent header"
5903                    );
5904
5905                    if let Some(reply_tx) = envelope.reply_tx {
5906                        let _ = reply_tx.send(Ok(envelope.exchange));
5907                    }
5908                }
5909            });
5910
5911            let resp = http_result.unwrap();
5912            assert_eq!(resp.status().as_u16(), 200);
5913
5914            token.cancel();
5915        }
5916
5917        #[tokio::test]
5918        async fn test_producer_no_trace_context_no_crash() {
5919            let (url, _handle) = start_test_server().await;
5920            let ctx = test_producer_ctx();
5921
5922            let component = HttpComponent::new();
5923            let endpoint_ctx = NoOpComponentContext;
5924            let endpoint = component
5925                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5926                .unwrap();
5927            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5928
5929            // Create exchange with default (empty) otel_context - no trace context
5930            let exchange = Exchange::new(Message::default());
5931
5932            // Should succeed without panic
5933            let result = producer.oneshot(exchange).await.unwrap();
5934
5935            // Verify request succeeded
5936            let status = result
5937                .input
5938                .header("CamelHttpResponseCode")
5939                .and_then(|v| v.as_u64())
5940                .unwrap();
5941            assert_eq!(status, 200);
5942        }
5943
5944        /// Test server that captures and echoes back the traceparent header
5945        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
5946            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5947            let addr = listener.local_addr().unwrap();
5948            let url = format!("http://127.0.0.1:{}", addr.port());
5949
5950            let handle = tokio::spawn(async move {
5951                loop {
5952                    if let Ok((mut stream, _)) = listener.accept().await {
5953                        tokio::spawn(async move {
5954                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5955                            let mut buf = vec![0u8; 8192];
5956                            let n = stream.read(&mut buf).await.unwrap_or(0);
5957                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
5958
5959                            // Extract traceparent header from request
5960                            let traceparent = request
5961                                .lines()
5962                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
5963                                .map(|line| {
5964                                    line.split(':')
5965                                        .nth(1)
5966                                        .map(|s| s.trim().to_string())
5967                                        .unwrap_or_default()
5968                                })
5969                                .unwrap_or_default();
5970
5971                            let body =
5972                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
5973                            let response = format!(
5974                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
5975                                body.len(),
5976                                traceparent,
5977                                body
5978                            );
5979                            let _ = stream.write_all(response.as_bytes()).await;
5980                        });
5981                    }
5982                }
5983            });
5984
5985            (url, handle)
5986        }
5987    }
5988
5989    // -----------------------------------------------------------------------
5990    // Response streaming tests (Eje A - Task 2)
5991    // -----------------------------------------------------------------------
5992
5993    // -----------------------------------------------------------------------
5994    // Request streaming tests (Eje B - Task 3)
5995    // -----------------------------------------------------------------------
5996
5997    #[tokio::test]
5998    async fn test_request_body_arrives_as_stream() {
5999        use camel_component_api::Body;
6000        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6001
6002        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6003        let port = listener.local_addr().unwrap().port();
6004        drop(listener);
6005
6006        let component = HttpComponent::new();
6007        let endpoint_ctx = NoOpComponentContext;
6008        let endpoint = component
6009            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
6010            .unwrap();
6011        let mut consumer = endpoint.create_consumer(rt()).unwrap();
6012
6013        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6014        let token = tokio_util::sync::CancellationToken::new();
6015        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6016
6017        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6018        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6019
6020        let client = reqwest::Client::new();
6021        let send_fut = client
6022            .post(format!("http://127.0.0.1:{port}/upload"))
6023            .body("hello streaming world")
6024            .send();
6025
6026        let (http_result, _) = tokio::join!(send_fut, async {
6027            if let Some(mut envelope) = rx.recv().await {
6028                // Body must be Body::Stream, not Body::Text or Body::Bytes
6029                assert!(
6030                    matches!(envelope.exchange.input.body, Body::Stream(_)),
6031                    "expected Body::Stream, got discriminant {:?}",
6032                    std::mem::discriminant(&envelope.exchange.input.body)
6033                );
6034                // Materialize to verify content
6035                let bytes = envelope
6036                    .exchange
6037                    .input
6038                    .body
6039                    .into_bytes(1024 * 1024)
6040                    .await
6041                    .unwrap();
6042                assert_eq!(&bytes[..], b"hello streaming world");
6043
6044                envelope.exchange.input.body = camel_component_api::Body::Empty;
6045                if let Some(reply_tx) = envelope.reply_tx {
6046                    let _ = reply_tx.send(Ok(envelope.exchange));
6047                }
6048            }
6049        });
6050
6051        let resp = http_result.unwrap();
6052        assert_eq!(resp.status().as_u16(), 200);
6053
6054        token.cancel();
6055    }
6056
6057    // -----------------------------------------------------------------------
6058    // Response streaming tests (Eje A - Task 2)
6059    // -----------------------------------------------------------------------
6060
6061    #[tokio::test]
6062    async fn test_streaming_response_chunked() {
6063        use bytes::Bytes;
6064        use camel_component_api::Body;
6065        use camel_component_api::CamelError;
6066        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6067        use camel_component_api::{StreamBody, StreamMetadata};
6068        use futures::stream;
6069        use std::sync::Arc;
6070        use tokio::sync::Mutex;
6071
6072        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6073        let port = listener.local_addr().unwrap().port();
6074        drop(listener);
6075
6076        let component = HttpComponent::new();
6077        let endpoint_ctx = NoOpComponentContext;
6078        let endpoint = component
6079            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
6080            .unwrap();
6081        let mut consumer = endpoint.create_consumer(rt()).unwrap();
6082
6083        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6084        let token = tokio_util::sync::CancellationToken::new();
6085        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6086
6087        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6088        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6089
6090        let client = reqwest::Client::new();
6091        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
6092
6093        let (http_result, _) = tokio::join!(send_fut, async {
6094            if let Some(mut envelope) = rx.recv().await {
6095                // Respond with Body::Stream
6096                let chunks: Vec<Result<Bytes, CamelError>> =
6097                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
6098                let stream = Box::pin(stream::iter(chunks));
6099                envelope.exchange.input.body = Body::Stream(StreamBody {
6100                    stream: Arc::new(Mutex::new(Some(stream))),
6101                    metadata: StreamMetadata::default(),
6102                });
6103                if let Some(reply_tx) = envelope.reply_tx {
6104                    let _ = reply_tx.send(Ok(envelope.exchange));
6105                }
6106            }
6107        });
6108
6109        let resp = http_result.unwrap();
6110        assert_eq!(resp.status().as_u16(), 200);
6111        let body = resp.text().await.unwrap();
6112        assert_eq!(body, "chunk1chunk2");
6113
6114        token.cancel();
6115    }
6116
6117    // -----------------------------------------------------------------------
6118    // 413 Content-Length limit test (Task 4)
6119    // -----------------------------------------------------------------------
6120
6121    #[tokio::test]
6122    async fn test_413_when_content_length_exceeds_limit() {
6123        use camel_component_api::ConsumerContext;
6124
6125        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6126        let port = listener.local_addr().unwrap().port();
6127        drop(listener);
6128
6129        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
6130        let component = HttpComponent::new();
6131        let endpoint_ctx = NoOpComponentContext;
6132        let endpoint = component
6133            .create_endpoint(
6134                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
6135                &endpoint_ctx,
6136            )
6137            .unwrap();
6138        let mut consumer = endpoint.create_consumer(rt()).unwrap();
6139
6140        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6141        let token = tokio_util::sync::CancellationToken::new();
6142        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6143
6144        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6145        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6146
6147        let client = reqwest::Client::new();
6148        let resp = client
6149            .post(format!("http://127.0.0.1:{port}/upload"))
6150            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
6151            .body("x".repeat(1000))
6152            .send()
6153            .await
6154            .unwrap();
6155
6156        assert_eq!(resp.status().as_u16(), 413);
6157
6158        token.cancel();
6159    }
6160
6161    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
6162    /// The spec says: "If there is no Content-Length, the limit does not apply at the
6163    /// consumer level — the route is responsible."
6164    #[tokio::test]
6165    async fn test_chunked_upload_without_content_length_bypasses_limit() {
6166        use bytes::Bytes;
6167        use camel_component_api::Body;
6168        use camel_component_api::ConsumerContext;
6169        use futures::stream;
6170
6171        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6172        let port = listener.local_addr().unwrap().port();
6173        drop(listener);
6174
6175        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
6176        let component = HttpComponent::new();
6177        let endpoint_ctx = NoOpComponentContext;
6178        let endpoint = component
6179            .create_endpoint(
6180                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
6181                &endpoint_ctx,
6182            )
6183            .unwrap();
6184        let mut consumer = endpoint.create_consumer(rt()).unwrap();
6185
6186        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6187        let token = tokio_util::sync::CancellationToken::new();
6188        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6189
6190        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6191        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6192
6193        let client = reqwest::Client::new();
6194
6195        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
6196        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
6197        // but since there's no Content-Length the 413 check must NOT fire.
6198        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
6199            Ok(Bytes::from("y".repeat(50))),
6200            Ok(Bytes::from("y".repeat(50))),
6201        ];
6202        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
6203        let send_fut = client
6204            .post(format!("http://127.0.0.1:{port}/upload"))
6205            .body(stream_body)
6206            .send();
6207
6208        let consumer_fut = async {
6209            // Use timeout to avoid deadlock if the handler rejects before enqueueing
6210            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
6211                Ok(Some(mut envelope)) => {
6212                    assert!(
6213                        matches!(envelope.exchange.input.body, Body::Stream(_)),
6214                        "expected Body::Stream"
6215                    );
6216                    envelope.exchange.input.body = camel_component_api::Body::Empty;
6217                    if let Some(reply_tx) = envelope.reply_tx {
6218                        let _ = reply_tx.send(Ok(envelope.exchange));
6219                    }
6220                }
6221                Ok(None) => panic!("consumer channel closed unexpectedly"),
6222                Err(_) => {
6223                    // Timeout: the request was rejected before reaching the consumer.
6224                    // The HTTP response will carry the real status code (we check below).
6225                }
6226            }
6227        };
6228
6229        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
6230
6231        let resp = http_result.unwrap();
6232        // Must NOT be 413; chunked uploads without Content-Length bypass the limit.
6233        assert_ne!(
6234            resp.status().as_u16(),
6235            413,
6236            "chunked upload must not be rejected by maxRequestBody"
6237        );
6238        assert_eq!(resp.status().as_u16(), 200);
6239
6240        token.cancel();
6241    }
6242
6243    #[test]
6244    fn test_is_private_ip_ranges() {
6245        use camel_api::is_ssrf_blocked_ip;
6246        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
6247        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
6248        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
6249        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
6250        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
6251        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
6252
6253        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
6254        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
6255        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
6256        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
6257        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
6258        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
6259        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
6260        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
6261
6262        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
6263        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
6264        assert!(!is_ssrf_blocked_ip(
6265            &"2001:4860:4860::8888".parse().unwrap()
6266        )); // allow-unwrap
6267    }
6268
6269    #[test]
6270    fn test_title_case_header() {
6271        assert_eq!(title_case_header("content-type"), "Content-Type");
6272        assert_eq!(title_case_header("authorization"), "Authorization");
6273        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
6274        assert_eq!(title_case_header("host"), "Host");
6275        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
6276        assert_eq!(title_case_header("single"), "Single");
6277        assert_eq!(title_case_header(""), "");
6278    }
6279
6280    #[test]
6281    fn test_resolve_url_combines_path_and_query_sources() {
6282        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
6283        let mut exchange = Exchange::new(Message::default());
6284        exchange.input.set_header(
6285            "CamelHttpPath",
6286            serde_json::Value::String("next".to_string()),
6287        );
6288        let url = HttpProducer::resolve_url(&exchange, &cfg);
6289        assert!(url.starts_with("http://example.com/base/next?"));
6290        assert!(url.contains("foo=bar"));
6291
6292        exchange.input.set_header(
6293            "CamelHttpUri",
6294            serde_json::Value::String("http://other.test/root".to_string()),
6295        );
6296        exchange.input.set_header(
6297            "CamelHttpQuery",
6298            serde_json::Value::String("a=1&b=2".to_string()),
6299        );
6300
6301        let override_url = HttpProducer::resolve_url(&exchange, &cfg);
6302        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
6303    }
6304
6305    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
6306        let mut exchange = Exchange::new(Message::default());
6307        exchange
6308            .input
6309            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
6310        exchange.input.set_header(
6311            "CamelHttpQuery",
6312            serde_json::Value::String(query.to_string()),
6313        );
6314        exchange
6315    }
6316
6317    #[test]
6318    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
6319        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6320        cfg.bridge_endpoint = true;
6321        cfg.query_params
6322            .insert("token".to_string(), "secret".to_string());
6323        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6324        let url = HttpProducer::resolve_url(&exchange, &cfg);
6325        assert_eq!(url, "http://x/?token=secret");
6326        assert!(!url.contains("/foo"));
6327        assert!(!url.contains("dropme"));
6328    }
6329
6330    #[test]
6331    fn resolve_url_bridge_endpoint_false_merges_path() {
6332        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6333        cfg.bridge_endpoint = false;
6334        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6335        let url = HttpProducer::resolve_url(&exchange, &cfg);
6336        assert!(url.contains("/foo"), "url should contain /foo: {url}");
6337        assert!(
6338            url.contains("dropme=1"),
6339            "url should contain dropme=1: {url}"
6340        );
6341    }
6342
6343    #[test]
6344    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
6345        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6346        cfg.bridge_endpoint = true;
6347        let mut exchange = Exchange::new(Message::default());
6348        exchange.input.set_header(
6349            "CamelHttpPath",
6350            serde_json::Value::String("/foo".to_string()),
6351        );
6352        let url = HttpProducer::resolve_url(&exchange, &cfg);
6353        assert_eq!(url, "http://x");
6354        assert!(!url.contains("/foo"));
6355    }
6356
6357    #[test]
6358    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
6359        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6360        cfg.bridge_endpoint = true;
6361        // query_params stays empty ([])
6362        let mut exchange = Exchange::new(Message::default());
6363        exchange.input.set_header(
6364            "CamelHttpUri",
6365            serde_json::Value::String("http://dest/explicit".to_string()),
6366        );
6367        exchange.input.set_header(
6368            "CamelHttpPath",
6369            serde_json::Value::String("/foo".to_string()),
6370        );
6371        exchange.input.set_header(
6372            "CamelHttpQuery",
6373            serde_json::Value::String("x=1".to_string()),
6374        );
6375        let url = HttpProducer::resolve_url(&exchange, &cfg);
6376        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
6377        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
6378        // wins verbatim.
6379        assert_eq!(url, "http://x");
6380    }
6381
6382    #[test]
6383    fn test_http_producer_helpers_status_and_size_boundaries() {
6384        assert!(HttpProducer::is_ok_status(200, (200, 299)));
6385        assert!(HttpProducer::is_ok_status(299, (200, 299)));
6386        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
6387        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
6388
6389        assert!(!exceeds_max_response_body(10, 10));
6390        assert!(exceeds_max_response_body(11, 10));
6391    }
6392
6393    // -----------------------------------------------------------------------
6394    // Content-Type inference tests
6395    // -----------------------------------------------------------------------
6396
6397    async fn setup_consumer_on_free_port(
6398        path: &str,
6399    ) -> (
6400        u16,
6401        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
6402        tokio_util::sync::CancellationToken,
6403    ) {
6404        use camel_component_api::ConsumerContext;
6405
6406        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6407        let port = listener.local_addr().unwrap().port();
6408        drop(listener);
6409
6410        let consumer_cfg = HttpServerConfig {
6411            scheme: "http".to_string(),
6412            host: "127.0.0.1".to_string(),
6413            port,
6414            path: path.to_string(),
6415            max_request_body: 2 * 1024 * 1024,
6416            max_response_body: 10 * 1024 * 1024,
6417            max_inflight_requests: 1024,
6418            method: None,
6419            tls_config: None,
6420        };
6421        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6422
6423        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6424        let token = tokio_util::sync::CancellationToken::new();
6425        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6426
6427        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6428        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6429
6430        (port, rx, token)
6431    }
6432
6433    #[tokio::test]
6434    async fn test_content_type_inferred_for_json_body() {
6435        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
6436
6437        let client = reqwest::Client::new();
6438        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
6439
6440        let (http_result, _) = tokio::join!(send_fut, async {
6441            if let Some(mut envelope) = rx.recv().await {
6442                envelope.exchange.input.body =
6443                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
6444                if let Some(reply_tx) = envelope.reply_tx {
6445                    let _ = reply_tx.send(Ok(envelope.exchange));
6446                }
6447            }
6448        });
6449
6450        let resp = http_result.unwrap();
6451        assert_eq!(resp.status().as_u16(), 200);
6452        let ct = resp
6453            .headers()
6454            .get("content-type")
6455            .expect("Content-Type header should be present");
6456        assert_eq!(ct, "application/json");
6457        let body = resp.text().await.unwrap();
6458        assert_eq!(body, r#"{"message":"hello"}"#);
6459
6460        token.cancel();
6461    }
6462
6463    #[tokio::test]
6464    async fn test_content_type_inferred_for_text_body() {
6465        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
6466
6467        let client = reqwest::Client::new();
6468        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
6469
6470        let (http_result, _) = tokio::join!(send_fut, async {
6471            if let Some(mut envelope) = rx.recv().await {
6472                envelope.exchange.input.body =
6473                    camel_component_api::Body::Text("plain text response".to_string());
6474                if let Some(reply_tx) = envelope.reply_tx {
6475                    let _ = reply_tx.send(Ok(envelope.exchange));
6476                }
6477            }
6478        });
6479
6480        let resp = http_result.unwrap();
6481        assert_eq!(resp.status().as_u16(), 200);
6482        let ct = resp
6483            .headers()
6484            .get("content-type")
6485            .expect("Content-Type header should be present");
6486        assert_eq!(ct, "text/plain; charset=utf-8");
6487        let body = resp.text().await.unwrap();
6488        assert_eq!(body, "plain text response");
6489
6490        token.cancel();
6491    }
6492
6493    #[tokio::test]
6494    async fn test_content_type_inferred_for_xml_body() {
6495        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
6496
6497        let client = reqwest::Client::new();
6498        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
6499
6500        let (http_result, _) = tokio::join!(send_fut, async {
6501            if let Some(mut envelope) = rx.recv().await {
6502                envelope.exchange.input.body =
6503                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
6504                if let Some(reply_tx) = envelope.reply_tx {
6505                    let _ = reply_tx.send(Ok(envelope.exchange));
6506                }
6507            }
6508        });
6509
6510        let resp = http_result.unwrap();
6511        assert_eq!(resp.status().as_u16(), 200);
6512        let ct = resp
6513            .headers()
6514            .get("content-type")
6515            .expect("Content-Type header should be present");
6516        assert_eq!(ct, "application/xml");
6517        let body = resp.text().await.unwrap();
6518        assert_eq!(body, "<root><item>value</item></root>");
6519
6520        token.cancel();
6521    }
6522
6523    #[tokio::test]
6524    async fn test_no_content_type_for_empty_body() {
6525        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
6526
6527        let client = reqwest::Client::new();
6528        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
6529
6530        let (http_result, _) = tokio::join!(send_fut, async {
6531            if let Some(mut envelope) = rx.recv().await {
6532                envelope.exchange.input.body = camel_component_api::Body::Empty;
6533                if let Some(reply_tx) = envelope.reply_tx {
6534                    let _ = reply_tx.send(Ok(envelope.exchange));
6535                }
6536            }
6537        });
6538
6539        let resp = http_result.unwrap();
6540        assert_eq!(resp.status().as_u16(), 200);
6541        assert!(
6542            resp.headers().get("content-type").is_none(),
6543            "Empty body should not set Content-Type"
6544        );
6545
6546        token.cancel();
6547    }
6548
6549    #[tokio::test]
6550    async fn test_no_content_type_for_raw_bytes_body() {
6551        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
6552
6553        let client = reqwest::Client::new();
6554        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
6555
6556        let (http_result, _) = tokio::join!(send_fut, async {
6557            if let Some(mut envelope) = rx.recv().await {
6558                envelope.exchange.input.body =
6559                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
6560                if let Some(reply_tx) = envelope.reply_tx {
6561                    let _ = reply_tx.send(Ok(envelope.exchange));
6562                }
6563            }
6564        });
6565
6566        let resp = http_result.unwrap();
6567        assert_eq!(resp.status().as_u16(), 200);
6568        assert!(
6569            resp.headers().get("content-type").is_none(),
6570            "Raw Bytes body should not set Content-Type"
6571        );
6572
6573        token.cancel();
6574    }
6575
6576    #[tokio::test]
6577    async fn test_content_type_from_stream_metadata() {
6578        use camel_component_api::{StreamBody, StreamMetadata};
6579        use futures::stream;
6580
6581        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
6582
6583        let client = reqwest::Client::new();
6584        let send_fut = client
6585            .get(format!("http://127.0.0.1:{port}/stream-ct"))
6586            .send();
6587
6588        let (http_result, _) = tokio::join!(send_fut, async {
6589            if let Some(mut envelope) = rx.recv().await {
6590                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6591                    vec![Ok(bytes::Bytes::from("audio data"))];
6592                let stream = Box::pin(stream::iter(chunks));
6593                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6594                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6595                    metadata: StreamMetadata {
6596                        size_hint: None,
6597                        content_type: Some("audio/mpeg".to_string()),
6598                        origin: None,
6599                    },
6600                });
6601                if let Some(reply_tx) = envelope.reply_tx {
6602                    let _ = reply_tx.send(Ok(envelope.exchange));
6603                }
6604            }
6605        });
6606
6607        let resp = http_result.unwrap();
6608        assert_eq!(resp.status().as_u16(), 200);
6609        let ct = resp
6610            .headers()
6611            .get("content-type")
6612            .expect("Content-Type header should be present");
6613        assert_eq!(ct, "audio/mpeg");
6614        let body = resp.text().await.unwrap();
6615        assert_eq!(body, "audio data");
6616
6617        token.cancel();
6618    }
6619
6620    #[tokio::test]
6621    async fn test_user_content_type_overrides_inferred() {
6622        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
6623
6624        let client = reqwest::Client::new();
6625        let send_fut = client
6626            .get(format!("http://127.0.0.1:{port}/override-ct"))
6627            .send();
6628
6629        let (http_result, _) = tokio::join!(send_fut, async {
6630            if let Some(mut envelope) = rx.recv().await {
6631                envelope.exchange.input.body =
6632                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
6633                envelope.exchange.input.set_header(
6634                    "Content-Type",
6635                    serde_json::Value::String("text/html".to_string()),
6636                );
6637                if let Some(reply_tx) = envelope.reply_tx {
6638                    let _ = reply_tx.send(Ok(envelope.exchange));
6639                }
6640            }
6641        });
6642
6643        let resp = http_result.unwrap();
6644        assert_eq!(resp.status().as_u16(), 200);
6645        let ct = resp
6646            .headers()
6647            .get("content-type")
6648            .expect("Content-Type header should be present");
6649        assert_eq!(
6650            ct, "text/html",
6651            "User-set Content-Type should take precedence over inferred type"
6652        );
6653
6654        token.cancel();
6655    }
6656
6657    #[tokio::test]
6658    async fn test_user_content_type_with_bytes_body() {
6659        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
6660
6661        let client = reqwest::Client::new();
6662        let send_fut = client
6663            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
6664            .send();
6665
6666        let (http_result, _) = tokio::join!(send_fut, async {
6667            if let Some(mut envelope) = rx.recv().await {
6668                envelope.exchange.input.body =
6669                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
6670                envelope.exchange.input.set_header(
6671                    "Content-Type",
6672                    serde_json::Value::String("application/json".to_string()),
6673                );
6674                if let Some(reply_tx) = envelope.reply_tx {
6675                    let _ = reply_tx.send(Ok(envelope.exchange));
6676                }
6677            }
6678        });
6679
6680        let resp = http_result.unwrap();
6681        assert_eq!(resp.status().as_u16(), 200);
6682        let ct = resp
6683            .headers()
6684            .get("content-type")
6685            .expect("Content-Type header should be present for Bytes body with user header");
6686        assert_eq!(
6687            ct, "application/json",
6688            "User Content-Type should be sent for Bytes body"
6689        );
6690
6691        token.cancel();
6692    }
6693
6694    // -----------------------------------------------------------------------
6695    // Server monitor tests (GRL-005)
6696    // -----------------------------------------------------------------------
6697
6698    #[tokio::test]
6699    async fn monitor_task_silent_on_clean_exit() {
6700        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
6701        // Clean exit should complete without panicking or logging errors
6702        monitor_axum_task(
6703            handle,
6704            "127.0.0.1:0".to_string(),
6705            noop_rt(),
6706            "test-monitor".into(),
6707        )
6708        .await;
6709    }
6710
6711    #[tokio::test]
6712    async fn monitor_task_handles_panicked_task() {
6713        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
6714            panic!("simulated server crash");
6715        });
6716        // Should complete without panicking even though the inner task panicked
6717        monitor_axum_task(
6718            handle,
6719            "127.0.0.1:9999".to_string(),
6720            noop_rt(),
6721            "test-monitor".into(),
6722        )
6723        .await;
6724    }
6725
6726    // -----------------------------------------------------------------------
6727    // Credential redaction tests
6728    // -----------------------------------------------------------------------
6729
6730    #[test]
6731    fn http_auth_basic_debug_redacts_password() {
6732        let auth = HttpAuth::Basic {
6733            username: "admin".to_string(),
6734            password: "hunter2".to_string(),
6735        };
6736        let debug = format!("{:?}", auth);
6737        assert!(
6738            !debug.contains("hunter2"),
6739            "password must be redacted: {debug}"
6740        );
6741        assert!(debug.contains("admin"), "username should appear: {debug}");
6742    }
6743
6744    #[test]
6745    fn http_auth_bearer_debug_redacts_token() {
6746        let auth = HttpAuth::Bearer {
6747            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
6748        };
6749        let debug = format!("{:?}", auth);
6750        assert!(
6751            !debug.contains("eyJhbGci"),
6752            "token must be redacted: {debug}"
6753        );
6754    }
6755
6756    #[test]
6757    fn http_auth_none_debug_shows_variant() {
6758        let debug = format!("{:?}", HttpAuth::None);
6759        assert!(
6760            debug.contains("None"),
6761            "None variant should appear: {debug}"
6762        );
6763    }
6764
6765    #[test]
6766    fn http_endpoint_config_debug_redacts_auth_credentials() {
6767        let config = HttpEndpointConfig::from_uri(
6768            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
6769        )
6770        .unwrap();
6771        let debug = format!("{:?}", config);
6772        assert!(
6773            !debug.contains("secret123"),
6774            "password must be redacted in HttpEndpointConfig debug: {debug}"
6775        );
6776    }
6777
6778    // -----------------------------------------------------------------------
6779    // Static file serving tests (Task 5)
6780    // -----------------------------------------------------------------------
6781
6782    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
6783    use tower_http::services::ServeDir;
6784
6785    fn make_test_registry() -> HttpRouteRegistry {
6786        HttpRouteRegistry::new()
6787    }
6788
6789    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
6790        AppState {
6791            registry,
6792            max_request_body: 2 * 1024 * 1024,
6793            max_response_body: 10 * 1024 * 1024,
6794            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
6795        }
6796    }
6797
6798    #[allow(clippy::await_holding_lock)]
6799    #[tokio::test]
6800    async fn test_static_file_serving_serves_file_contents() {
6801        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6802        ServerRegistry::reset();
6803
6804        // Create temp dir with test files
6805        let temp_dir =
6806            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
6807        std::fs::create_dir_all(&temp_dir).unwrap();
6808        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
6809        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
6810
6811        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6812
6813        let registry = make_test_registry();
6814        let serve_dir = ServeDir::new(&canonical_dir)
6815            .precompressed_gzip()
6816            .precompressed_br()
6817            .append_index_html_on_directories(true);
6818
6819        let mount = StaticMount {
6820            mount_path: "/".to_string(),
6821            mode: MountMode::Static,
6822            dir: canonical_dir.clone(),
6823            cache_control: "public, max-age=3600".to_string(),
6824            error_pages: std::collections::HashMap::new(),
6825            serve_dir,
6826        };
6827        registry.register_static_mount(mount).await.unwrap();
6828
6829        let state = make_test_state(registry);
6830
6831        // Test serving hello.txt
6832        let req = Request::builder()
6833            .uri("/hello.txt")
6834            .body(AxumBody::empty())
6835            .unwrap();
6836        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
6837        assert_eq!(resp.status(), StatusCode::OK);
6838        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6839            .await
6840            .unwrap();
6841        assert_eq!(&body[..], b"Hello, static world!");
6842
6843        // Test serving style.css
6844        let req = Request::builder()
6845            .uri("/style.css")
6846            .body(AxumBody::empty())
6847            .unwrap();
6848        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6849        assert_eq!(resp.status(), StatusCode::OK);
6850        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6851            .await
6852            .unwrap();
6853        assert_eq!(&body[..], b"body { color: red; }");
6854
6855        // Test 404 for non-existent file
6856        let req = Request::builder()
6857            .uri("/missing.txt")
6858            .body(AxumBody::empty())
6859            .unwrap();
6860        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
6861        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6862
6863        // Cleanup
6864        std::fs::remove_dir_all(&temp_dir).ok();
6865    }
6866
6867    #[allow(clippy::await_holding_lock)]
6868    #[tokio::test]
6869    async fn test_spa_fallback_serves_index_for_unknown_paths() {
6870        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6871        ServerRegistry::reset();
6872
6873        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
6874        std::fs::create_dir_all(&temp_dir).unwrap();
6875        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
6876        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
6877
6878        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6879
6880        let registry = make_test_registry();
6881        let serve_dir = ServeDir::new(&canonical_dir)
6882            .precompressed_gzip()
6883            .precompressed_br()
6884            .append_index_html_on_directories(true);
6885
6886        let mount = StaticMount {
6887            mount_path: "/".to_string(),
6888            mode: MountMode::Spa,
6889            dir: canonical_dir.clone(),
6890            cache_control: "public, max-age=0".to_string(),
6891            error_pages: std::collections::HashMap::new(),
6892            serve_dir,
6893        };
6894        // Register as SPA mount
6895        registry.register_static_mount(mount).await.unwrap();
6896
6897        let state = make_test_state(registry);
6898
6899        // SPA fallback: GET /dashboard with Accept: text/html → index.html
6900        let req = Request::builder()
6901            .method("GET")
6902            .uri("/dashboard")
6903            .header("Accept", "text/html")
6904            .body(AxumBody::empty())
6905            .unwrap();
6906        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
6907        assert_eq!(resp.status(), StatusCode::OK);
6908        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6909            .await
6910            .unwrap();
6911        assert_eq!(&body[..], b"<h1>SPA App</h1>");
6912
6913        // Static file still works: GET /app.js
6914        let req = Request::builder()
6915            .method("GET")
6916            .uri("/app.js")
6917            .body(AxumBody::empty())
6918            .unwrap();
6919        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
6920        assert_eq!(resp.status(), StatusCode::OK);
6921        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6922            .await
6923            .unwrap();
6924        assert_eq!(&body[..], b"console.log('app')");
6925
6926        // No SPA fallback for JSON accept → 404
6927        let req = Request::builder()
6928            .method("GET")
6929            .uri("/api/data")
6930            .header("Accept", "application/json")
6931            .body(AxumBody::empty())
6932            .unwrap();
6933        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
6934        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6935
6936        // No SPA fallback for file extensions → 404
6937        let req = Request::builder()
6938            .method("GET")
6939            .uri("/style.css")
6940            .header("Accept", "text/html")
6941            .body(AxumBody::empty())
6942            .unwrap();
6943        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6944        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6945
6946        // Cleanup
6947        std::fs::remove_dir_all(&temp_dir).ok();
6948    }
6949
6950    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
6951    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
6952    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
6953    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
6954    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
6955    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
6956    #[allow(clippy::await_holding_lock)]
6957    async fn run_conditional_get_returns_304(mode: MountMode) {
6958        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6959        ServerRegistry::reset();
6960
6961        let temp_dir = std::env::temp_dir().join(format!(
6962            "http_cond_get_{}_{}",
6963            if mode == MountMode::Spa {
6964                "spa"
6965            } else {
6966                "static"
6967            },
6968            std::process::id()
6969        ));
6970        std::fs::create_dir_all(&temp_dir).unwrap();
6971        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
6972
6973        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6974
6975        let registry = make_test_registry();
6976        let serve_dir = ServeDir::new(&canonical_dir)
6977            .precompressed_gzip()
6978            .precompressed_br()
6979            .append_index_html_on_directories(true);
6980
6981        let mount = StaticMount {
6982            mount_path: "/".to_string(),
6983            mode,
6984            dir: canonical_dir.clone(),
6985            cache_control: "public, max-age=3600".to_string(),
6986            error_pages: std::collections::HashMap::new(),
6987            serve_dir,
6988        };
6989        registry.register_static_mount(mount).await.unwrap();
6990
6991        let state = make_test_state(registry);
6992
6993        // 1st request: normal GET → 200, capture validators.
6994        let req = Request::builder()
6995            .method("GET")
6996            .uri("/index.html")
6997            .body(AxumBody::empty())
6998            .unwrap();
6999        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7000        assert_eq!(
7001            resp.status(),
7002            StatusCode::OK,
7003            "first GET should return 200, got {}",
7004            resp.status()
7005        );
7006        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
7007        assert!(
7008            resp.headers().contains_key(http::header::CACHE_CONTROL),
7009            "200 response missing Cache-Control"
7010        );
7011        let etag = resp
7012            .headers()
7013            .get(http::header::ETAG)
7014            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
7015            .clone();
7016        let last_modified = resp
7017            .headers()
7018            .get(http::header::LAST_MODIFIED)
7019            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
7020            .clone();
7021        // Consume the body so the response is fully drained.
7022        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
7023            .await
7024            .unwrap();
7025
7026        // 2nd request: If-None-Match with the captured ETag → 304.
7027        // Unconditional: ETag presence is required (asserted above) so this
7028        // sub-test cannot silently skip on a ServeDir etag_method change.
7029        let req = Request::builder()
7030            .method("GET")
7031            .uri("/index.html")
7032            .header(http::header::IF_NONE_MATCH, etag.clone())
7033            .body(AxumBody::empty())
7034            .unwrap();
7035        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7036        assert_eq!(
7037            resp.status(),
7038            StatusCode::NOT_MODIFIED,
7039            "If-None-Match with matching ETag should return 304, got {}",
7040            resp.status()
7041        );
7042        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
7043        assert!(
7044            resp.headers().contains_key(http::header::CACHE_CONTROL),
7045            "304 (If-None-Match) missing Cache-Control"
7046        );
7047        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
7048        // response parts rebuild in serve_via_serve_dir preserves them.
7049        assert_eq!(
7050            resp.headers().get(http::header::ETAG),
7051            Some(&etag),
7052            "304 (If-None-Match) must echo the ETag validator"
7053        );
7054        assert_eq!(
7055            resp.headers().get(http::header::LAST_MODIFIED),
7056            Some(&last_modified),
7057            "304 (If-None-Match) must carry Last-Modified"
7058        );
7059
7060        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
7061        let req = Request::builder()
7062            .method("GET")
7063            .uri("/index.html")
7064            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
7065            .body(AxumBody::empty())
7066            .unwrap();
7067        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7068        assert_eq!(
7069            resp.status(),
7070            StatusCode::NOT_MODIFIED,
7071            "If-Modified-Since with matching timestamp should return 304, got {}",
7072            resp.status()
7073        );
7074        assert!(
7075            resp.headers().contains_key(http::header::CACHE_CONTROL),
7076            "304 (If-Modified-Since) missing Cache-Control"
7077        );
7078        assert_eq!(
7079            resp.headers().get(http::header::ETAG),
7080            Some(&etag),
7081            "304 (If-Modified-Since) must carry the ETag validator"
7082        );
7083        assert_eq!(
7084            resp.headers().get(http::header::LAST_MODIFIED),
7085            Some(&last_modified),
7086            "304 (If-Modified-Since) must echo Last-Modified"
7087        );
7088
7089        // Negative control: a PAST If-Modified-Since (before the file's mtime)
7090        // MUST return 200 — proving the 304 path is validator-aware, not a
7091        // blanket "always 304" regression. A future date would correctly yield
7092        // 304 since the file's mtime precedes it; that is RFC-correct 304
7093        // behaviour, not a negative control.
7094        let req = Request::builder()
7095            .method("GET")
7096            .uri("/index.html")
7097            .header(
7098                http::header::IF_MODIFIED_SINCE,
7099                "Wed, 21 Oct 2000 07:28:00 GMT",
7100            )
7101            .body(AxumBody::empty())
7102            .unwrap();
7103        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7104        assert_eq!(
7105            resp.status(),
7106            StatusCode::OK,
7107            "past If-Modified-Since should return 200 (file modified after it), got {}",
7108            resp.status()
7109        );
7110
7111        // Cleanup
7112        std::fs::remove_dir_all(&temp_dir).ok();
7113    }
7114
7115    #[tokio::test]
7116    async fn test_conditional_get_returns_304_static_mode() {
7117        run_conditional_get_returns_304(MountMode::Static).await;
7118    }
7119
7120    #[tokio::test]
7121    async fn test_conditional_get_returns_304_spa_mode() {
7122        run_conditional_get_returns_304(MountMode::Spa).await;
7123    }
7124
7125    #[allow(clippy::await_holding_lock)]
7126    #[tokio::test]
7127    async fn test_error_page_mapping_serves_custom_404() {
7128        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7129        ServerRegistry::reset();
7130
7131        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
7132        let errors_dir = temp_dir.join("errors");
7133        std::fs::create_dir_all(&errors_dir).unwrap();
7134        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
7135        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
7136
7137        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7138        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
7139
7140        let registry = make_test_registry();
7141        let serve_dir = ServeDir::new(&canonical_dir)
7142            .precompressed_gzip()
7143            .precompressed_br()
7144            .append_index_html_on_directories(true);
7145
7146        let mut error_pages = std::collections::HashMap::new();
7147        error_pages.insert(404, canonical_404);
7148
7149        let mount = StaticMount {
7150            mount_path: "/".to_string(),
7151            mode: MountMode::Static,
7152            dir: canonical_dir.clone(),
7153            cache_control: "public, max-age=0".to_string(),
7154            error_pages,
7155            serve_dir,
7156        };
7157        registry.register_static_mount(mount).await.unwrap();
7158
7159        let state = make_test_state(registry);
7160
7161        // Request non-existent file → custom 404 page
7162        let req = Request::builder()
7163            .method("GET")
7164            .uri("/missing.html")
7165            .body(AxumBody::empty())
7166            .unwrap();
7167        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
7168        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7169        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7170            .await
7171            .unwrap();
7172        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
7173
7174        // Existing file still works
7175        let req = Request::builder()
7176            .method("GET")
7177            .uri("/index.html")
7178            .body(AxumBody::empty())
7179            .unwrap();
7180        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7181        assert_eq!(resp.status(), StatusCode::OK);
7182        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7183            .await
7184            .unwrap();
7185        assert_eq!(&body[..], b"<h1>Home</h1>");
7186
7187        // Cleanup
7188        std::fs::remove_dir_all(&temp_dir).ok();
7189    }
7190
7191    #[tokio::test]
7192    async fn http_consumer_returns_body_and_code_on_stop() {
7193        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
7194        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7195        use tower::ServiceExt;
7196
7197        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
7198        let set_body_step = CompiledStep::Process {
7199            kind_hint: camel_api::SpanKindHint::Internal,
7200            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
7201                ex.input.body = Body::Text("nope".into());
7202                Box::pin(async move { Ok(ex) })
7203            }),
7204            body_contract: None,
7205            lifecycle: None,
7206            label: None,
7207        };
7208        let set_status_step = CompiledStep::Process {
7209            kind_hint: camel_api::SpanKindHint::Internal,
7210            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
7211                ex.input.set_header(
7212                    "CamelHttpResponseCode",
7213                    serde_json::Value::Number(409.into()),
7214                );
7215                Box::pin(async move { Ok(ex) })
7216            }),
7217            body_contract: None,
7218            lifecycle: None,
7219            label: None,
7220        };
7221        let pipeline = compose_pipeline_with_handler(
7222            vec![set_body_step, set_status_step, CompiledStep::Stop],
7223            None,
7224            PipelineRuntimeCtx::compile_time(),
7225        );
7226
7227        let ex = Exchange::new(Message::default());
7228        let result = pipeline.oneshot(ex).await;
7229        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
7230        let returned = result.unwrap();
7231        assert_eq!(returned.input.body.as_text(), Some("nope"));
7232        assert_eq!(
7233            returned
7234                .input
7235                .header("CamelHttpResponseCode")
7236                .and_then(|v| v.as_u64()),
7237            Some(409)
7238        );
7239    }
7240
7241    #[tokio::test]
7242    async fn http_consumer_returns_200_when_body_empty_on_stop() {
7243        // After ADR-0024: Stop with no body + no status header produces 200 (same as
7244        // a normal completion with no body). The 204 default is gone — users who
7245        // want 204 set CamelHttpResponseCode=204 explicitly.
7246        //
7247        // This test stays at the pipeline level (consistent with the test above).
7248        // E2E coverage of the full HTTP dispatch path is in
7249        // crates/camel-test/tests/integration_test.rs.
7250        use camel_api::{Exchange, Message};
7251        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7252        use tower::ServiceExt;
7253
7254        let pipeline = compose_pipeline_with_handler(
7255            vec![CompiledStep::Stop],
7256            None,
7257            PipelineRuntimeCtx::compile_time(),
7258        );
7259        let ex = Exchange::new(Message::default());
7260        let result = pipeline.oneshot(ex).await;
7261        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
7262        // Body is default (empty); no CamelHttpResponseCode header was set.
7263        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
7264    }
7265
7266    // -----------------------------------------------------------------------
7267    // Task 5: Method-aware REST dispatch tests
7268    // -----------------------------------------------------------------------
7269
7270    /// Spins up an axum server on a free port with a fresh registry.
7271    /// Returns the port plus the registry so the caller can register
7272    /// REST endpoints directly.
7273    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
7274        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7275        let port = listener.local_addr().unwrap().port();
7276        let registry = HttpRouteRegistry::new();
7277        tokio::spawn(run_axum_server(
7278            listener,
7279            registry.clone(),
7280            2 * 1024 * 1024,
7281            10 * 1024 * 1024,
7282            Arc::new(tokio::sync::Semaphore::new(1024)),
7283            test_rt(),
7284            "test-route".into(),
7285        ));
7286        // Give the server a moment to start accepting.
7287        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7288        (port, registry)
7289    }
7290
7291    /// Helper for REST integration tests: spawns a responder task that
7292    /// reads from `rx`, writes a fixed `(status, body)` back via the
7293    /// envelope's reply channel, and returns once the test request is
7294    /// satisfied.
7295    fn spawn_responder(
7296        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
7297        status: u16,
7298        body: String,
7299    ) -> tokio::task::JoinHandle<()> {
7300        tokio::spawn(async move {
7301            if let Some(envelope) = rx.recv().await {
7302                let _ = envelope.reply_tx.send(HttpReply {
7303                    status,
7304                    headers: vec![],
7305                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
7306                });
7307            }
7308        })
7309    }
7310
7311    #[tokio::test]
7312    async fn method_aware_dispatch_same_path_different_verbs() {
7313        let (port, registry) = spawn_test_server().await;
7314
7315        // Register two REST endpoints on the same path with different
7316        // methods. This is the core scenario REST DSL needs to support:
7317        // GET /users (list) and POST /users (create) must not overwrite
7318        // each other.
7319        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7320        registry
7321            .register_rest_endpoint(
7322                "GET".into(),
7323                vec![PathSegment::Literal("users".into())],
7324                get_tx,
7325            )
7326            .await;
7327
7328        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7329        registry
7330            .register_rest_endpoint(
7331                "POST".into(),
7332                vec![PathSegment::Literal("users".into())],
7333                post_tx,
7334            )
7335            .await;
7336
7337        let get_handle = spawn_responder(get_rx, 200, "list".into());
7338        let post_handle = spawn_responder(post_rx, 201, "create".into());
7339
7340        let client = reqwest::Client::new();
7341
7342        // GET /users → list route
7343        let resp = client
7344            .get(format!("http://127.0.0.1:{port}/users"))
7345            .send()
7346            .await
7347            .unwrap();
7348        assert_eq!(resp.status().as_u16(), 200);
7349        let body = resp.text().await.unwrap();
7350        assert_eq!(body, "list");
7351
7352        // POST /users → create route
7353        let resp = client
7354            .post(format!("http://127.0.0.1:{port}/users"))
7355            .send()
7356            .await
7357            .unwrap();
7358        assert_eq!(resp.status().as_u16(), 201);
7359        let body = resp.text().await.unwrap();
7360        assert_eq!(body, "create");
7361
7362        let _ = tokio::join!(get_handle, post_handle);
7363    }
7364
7365    #[tokio::test]
7366    async fn method_aware_dispatch_templated_path_extracts_params() {
7367        let (port, registry) = spawn_test_server().await;
7368
7369        // Register GET /users/{id} as a templated endpoint. The
7370        // dispatcher should match `/users/42` against the template and
7371        // attach `id=42` to the envelope's path_params.
7372        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7373        registry
7374            .register_rest_endpoint(
7375                "GET".into(),
7376                vec![
7377                    PathSegment::Literal("users".into()),
7378                    PathSegment::Param("id".into()),
7379                ],
7380                tx,
7381            )
7382            .await;
7383
7384        // Spawn a responder that echoes the captured id back in the body
7385        // so the test can verify the param was set.
7386        let handle = tokio::spawn(async move {
7387            if let Some(envelope) = rx.recv().await {
7388                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
7389                let _ = envelope.reply_tx.send(HttpReply {
7390                    status: 200,
7391                    headers: vec![],
7392                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
7393                });
7394            }
7395        });
7396
7397        let client = reqwest::Client::new();
7398        let resp = client
7399            .get(format!("http://127.0.0.1:{port}/users/42"))
7400            .send()
7401            .await
7402            .unwrap();
7403        assert_eq!(resp.status().as_u16(), 200);
7404        let body = resp.text().await.unwrap();
7405        assert_eq!(body, "id=42");
7406
7407        let _ = handle.await;
7408    }
7409
7410    #[tokio::test]
7411    async fn method_aware_dispatch_unmatched_method_falls_through() {
7412        // If no REST endpoint matches the method, dispatch must fall
7413        // through to the legacy api_routes lookup or static mounts. With
7414        // nothing else registered, the request gets 404 from static
7415        // dispatch.
7416        let (port, _registry) = spawn_test_server().await;
7417
7418        // Register only GET /users; a DELETE /users request has no match.
7419        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7420        _registry
7421            .register_rest_endpoint(
7422                "GET".into(),
7423                vec![PathSegment::Literal("users".into())],
7424                get_tx,
7425            )
7426            .await;
7427
7428        // Drain the GET channel in the background so the consumer side
7429        // doesn't block (we don't expect any envelopes here).
7430        let drain = tokio::spawn(async move {
7431            let mut get_rx = get_rx;
7432            while get_rx.recv().await.is_some() {}
7433        });
7434
7435        let client = reqwest::Client::new();
7436        let resp = client
7437            .delete(format!("http://127.0.0.1:{port}/users"))
7438            .send()
7439            .await
7440            .unwrap();
7441        assert_eq!(resp.status().as_u16(), 404);
7442
7443        drop(drain);
7444    }
7445
7446    #[tokio::test]
7447    async fn regression_legacy_exact_api_route_still_works() {
7448        // A `http:` route registered without an `httpMethod=` URI param
7449        // lands in the legacy api_routes registry. The dispatcher must
7450        // still find it via exact path lookup. This guards against
7451        // regressions introduced by the new REST-aware dispatch.
7452        let (port, registry) = spawn_test_server().await;
7453
7454        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7455        registry.register_api_route("/legacy/path".into(), tx).await;
7456
7457        let handle = tokio::spawn(async move {
7458            if let Some(envelope) = rx.recv().await {
7459                let _ = envelope.reply_tx.send(HttpReply {
7460                    status: 200,
7461                    headers: vec![],
7462                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
7463                });
7464            }
7465        });
7466
7467        let client = reqwest::Client::new();
7468        let resp = client
7469            .get(format!("http://127.0.0.1:{port}/legacy/path"))
7470            .send()
7471            .await
7472            .unwrap();
7473        assert_eq!(resp.status().as_u16(), 200);
7474        let body = resp.text().await.unwrap();
7475        assert_eq!(body, "legacy ok");
7476
7477        let _ = handle.await;
7478    }
7479
7480    #[allow(clippy::await_holding_lock)]
7481    #[tokio::test]
7482    async fn regression_static_mount_still_works() {
7483        // Verify that static file serving still works after the
7484        // dispatch refactor. We register a temp-dir mount and request
7485        // a file from it; the static dispatcher should serve it.
7486        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7487        ServerRegistry::reset();
7488
7489        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
7490        std::fs::create_dir_all(&temp_dir).unwrap();
7491        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
7492        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7493
7494        let registry = make_test_registry();
7495        let serve_dir = ServeDir::new(&canonical_dir)
7496            .precompressed_gzip()
7497            .precompressed_br()
7498            .append_index_html_on_directories(true);
7499        let mount = StaticMount {
7500            mount_path: "/".to_string(),
7501            mode: MountMode::Static,
7502            dir: canonical_dir.clone(),
7503            cache_control: "public, max-age=3600".to_string(),
7504            error_pages: std::collections::HashMap::new(),
7505            serve_dir,
7506        };
7507        registry.register_static_mount(mount).await.unwrap();
7508
7509        let state = make_test_state(registry);
7510        let req = Request::builder()
7511            .uri("/regress.txt")
7512            .body(AxumBody::empty())
7513            .unwrap();
7514        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
7515        assert_eq!(resp.status(), StatusCode::OK);
7516        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7517            .await
7518            .unwrap();
7519        assert_eq!(&body[..], b"static works");
7520
7521        std::fs::remove_dir_all(&temp_dir).ok();
7522    }
7523
7524    // -----------------------------------------------------------------------
7525    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
7526    // templated from-URI round-trip. These exercise the real axum dispatch
7527    // path (register → HTTP request → reply) so a regression in any of the
7528    // three critical fixes surfaces as a test failure rather than a silent
7529    // production 404/500.
7530    // -----------------------------------------------------------------------
7531
7532    #[tokio::test]
7533    async fn deregister_one_method_keeps_sibling_verbs() {
7534        // Review C1: stopping the GET /users consumer must NOT tear down the
7535        // live POST /users endpoint. Register both, deregister GET only,
7536        // then verify POST still dispatches.
7537        let (port, registry) = spawn_test_server().await;
7538
7539        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7540        registry
7541            .register_rest_endpoint(
7542                "GET".into(),
7543                vec![PathSegment::Literal("users".into())],
7544                get_tx,
7545            )
7546            .await;
7547
7548        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7549        registry
7550            .register_rest_endpoint(
7551                "POST".into(),
7552                vec![PathSegment::Literal("users".into())],
7553                post_tx,
7554            )
7555            .await;
7556
7557        // Drain GET in the background (no requests expected after deregister).
7558        let drain = tokio::spawn(async move {
7559            let mut get_rx = get_rx;
7560            while get_rx.recv().await.is_some() {}
7561        });
7562
7563        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
7564        registry.unregister_rest_endpoint("GET", "/users").await;
7565        drop(drain);
7566
7567        let post_handle = spawn_responder(post_rx, 201, "create".into());
7568
7569        let client = reqwest::Client::new();
7570        // POST /users must still reach its consumer after GET was removed.
7571        let resp = client
7572            .post(format!("http://127.0.0.1:{port}/users"))
7573            .send()
7574            .await
7575            .unwrap();
7576        assert_eq!(resp.status().as_u16(), 201);
7577        assert_eq!(resp.text().await.unwrap(), "create");
7578
7579        let _ = post_handle.await;
7580    }
7581
7582    #[tokio::test]
7583    async fn dispatch_exact_legacy_beats_rest_template() {
7584        // Review C2: an exact legacy API route (`GET /api/users`, no
7585        // httpMethod) must win over a templated REST route
7586        // (`GET /api/{resource}`) for the request `/api/users`, per spec
7587        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
7588        let (port, registry) = spawn_test_server().await;
7589
7590        // Exact legacy route.
7591        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7592        registry
7593            .register_api_route("/api/users".into(), exact_tx)
7594            .await;
7595        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
7596
7597        // Templated REST route that would ALSO match /api/users.
7598        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7599        registry
7600            .register_rest_endpoint(
7601                "GET".into(),
7602                vec![
7603                    PathSegment::Literal("api".into()),
7604                    PathSegment::Param("resource".into()),
7605                ],
7606                tpl_tx,
7607            )
7608            .await;
7609        // The templated handler must NOT receive the /api/users request. If
7610        // it does, it replies "template-leak" so a future assertion could
7611        // catch it. We do NOT await this task: the exact-match branch wins
7612        // and the templated channel never receives, so awaiting would block
7613        // until the test runtime tears down.
7614        let _tpl_drain = tokio::spawn(async move {
7615            let mut tpl_rx = tpl_rx;
7616            if let Some(env) = tpl_rx.recv().await {
7617                let _ = env.reply_tx.send(HttpReply {
7618                    status: 200,
7619                    headers: vec![],
7620                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
7621                });
7622            }
7623        });
7624
7625        let client = reqwest::Client::new();
7626        let resp = client
7627            .get(format!("http://127.0.0.1:{port}/api/users"))
7628            .send()
7629            .await
7630            .unwrap();
7631        assert_eq!(resp.status().as_u16(), 200);
7632        // Exact-match handler answered — not the templated one.
7633        assert_eq!(resp.text().await.unwrap(), "exact");
7634
7635        let _ = exact_handle.await;
7636    }
7637
7638    #[tokio::test]
7639    async fn ambiguous_rest_templates_return_500_not_silent_404() {
7640        // Review C3: two equal-specificity templates that both match one
7641        // request are an ambiguous registration. At runtime this must
7642        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
7643        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
7644        let (port, registry) = spawn_test_server().await;
7645
7646        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7647        registry
7648            .register_rest_endpoint(
7649                "GET".into(),
7650                vec![
7651                    PathSegment::Literal("users".into()),
7652                    PathSegment::Param("id".into()),
7653                ],
7654                a_tx,
7655            )
7656            .await;
7657
7658        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7659        registry
7660            .register_rest_endpoint(
7661                "GET".into(),
7662                vec![
7663                    PathSegment::Literal("users".into()),
7664                    PathSegment::Param("name".into()),
7665                ],
7666                b_tx,
7667            )
7668            .await;
7669
7670        let client = reqwest::Client::new();
7671        let resp = client
7672            .get(format!("http://127.0.0.1:{port}/users/42"))
7673            .send()
7674            .await
7675            .unwrap();
7676        // Ambiguous → 500 (previously a silent 404).
7677        assert_eq!(resp.status().as_u16(), 500);
7678    }
7679
7680    #[test]
7681    fn from_uri_round_trips_templated_path_with_http_method() {
7682        // Review I4: a REST-lowered from-URI like
7683        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
7684        // through HttpServerConfig::from_uri, preserving the templated path
7685        // and the (uppercased) method. This is the binding the DSL lowering
7686        // emits and the consumer reads; it was previously unasserted.
7687        use crate::UriConfig;
7688        let cfg =
7689            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
7690        assert_eq!(cfg.host, "0.0.0.0");
7691        assert_eq!(cfg.port, 8080);
7692        assert_eq!(cfg.path, "/users/{id}");
7693        assert_eq!(cfg.method.as_deref(), Some("GET"));
7694
7695        // Lower-case httpMethod is uppercased (review I5).
7696        let cfg_lc =
7697            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
7698        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
7699        assert_eq!(cfg_lc.path, "/orders");
7700    }
7701
7702    // -----------------------------------------------------------------------
7703    // rc-1dk4: TypeConversionFailed → 400 Bad Request
7704    // -----------------------------------------------------------------------
7705
7706    #[test]
7707    fn type_conversion_failed_maps_to_400() {
7708        let reply = pipeline_error_to_reply(
7709            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
7710            "/api/users",
7711        );
7712        assert_eq!(reply.status, 400);
7713        // Content-Type must be application/json
7714        let ct = reply
7715            .headers
7716            .iter()
7717            .find(|(k, _)| k == "Content-Type")
7718            .map(|(_, v)| v.as_str());
7719        assert_eq!(ct, Some("application/json"));
7720        // Body must contain structured error JSON
7721        let body = match &reply.body {
7722            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7723            _ => panic!("expected bytes body"),
7724        };
7725        assert!(body.contains("\"error\""));
7726        assert!(body.contains("bad_request"));
7727        assert!(body.contains("invalid JSON at line 1"));
7728    }
7729
7730    #[test]
7731    fn other_error_still_maps_to_500() {
7732        let reply =
7733            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
7734        assert_eq!(reply.status, 500);
7735    }
7736
7737    #[test]
7738    fn unauthenticated_maps_to_401() {
7739        let reply = pipeline_error_to_reply(
7740            CamelError::Unauthenticated("no token".to_string()),
7741            "/api/users",
7742        );
7743        assert_eq!(reply.status, 401);
7744    }
7745
7746    #[test]
7747    fn unauthorized_maps_to_403() {
7748        let reply = pipeline_error_to_reply(
7749            CamelError::Unauthorized("forbidden".to_string()),
7750            "/api/users",
7751        );
7752        assert_eq!(reply.status, 403);
7753    }
7754
7755    #[test]
7756    fn validation_error_maps_to_400() {
7757        let reply = pipeline_error_to_reply(
7758            CamelError::ValidationError("body does not match schema".to_string()),
7759            "/api/users",
7760        );
7761        assert_eq!(reply.status, 400);
7762        let ct = reply
7763            .headers
7764            .iter()
7765            .find(|(k, _)| k == "Content-Type")
7766            .map(|(_, v)| v.as_str());
7767        assert_eq!(ct, Some("application/json"));
7768        let body = match &reply.body {
7769            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7770            _ => panic!("expected bytes body"),
7771        };
7772        assert!(body.contains("\"error\""));
7773        assert!(body.contains("validation_error"));
7774        assert!(body.contains("body does not match schema"));
7775    }
7776
7777    #[test]
7778    fn https_consumer_without_tls_cert_errors() {
7779        let endpoint = HttpEndpoint {
7780            uri: "https://0.0.0.0:8443/api".to_string(),
7781            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7782            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7783            client: reqwest::Client::new(),
7784            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7785                PINNED_CLIENT_TTL,
7786                PINNED_CLIENT_MAX_ENTRIES,
7787            )),
7788            http_config: HttpConfig::default(),
7789        };
7790        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7791        let result = endpoint.create_consumer(rt);
7792        assert!(result.is_err(), "expected error for https without tls cert");
7793        if let Err(e) = result {
7794            let msg = e.to_string();
7795            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
7796        }
7797    }
7798
7799    #[test]
7800    fn http_consumer_with_tls_config_errors() {
7801        let endpoint = HttpEndpoint {
7802            uri: "http://0.0.0.0:8080/api".to_string(),
7803            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
7804            server_config: HttpServerConfig::from_uri(
7805                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
7806            )
7807            .unwrap(),
7808            client: reqwest::Client::new(),
7809            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7810                PINNED_CLIENT_TTL,
7811                PINNED_CLIENT_MAX_ENTRIES,
7812            )),
7813            http_config: HttpConfig::default(),
7814        };
7815        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7816        let result = endpoint.create_consumer(rt);
7817        assert!(result.is_err(), "expected error for http with tls config");
7818        if let Err(e) = result {
7819            let msg = e.to_string();
7820            assert!(msg.contains("https"), "error must mention https: {msg}");
7821        }
7822    }
7823
7824    #[test]
7825    fn https_consumer_with_partial_tls_cert_only_errors() {
7826        // tlsCert without tlsKey → tls_config is None at parse time
7827        // → create_consumer sees https:// + no TLS → must error
7828        let server_config =
7829            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7830        assert!(
7831            server_config.tls_config.is_none(),
7832            "partial tlsCert must not create ServerTlsConfig"
7833        );
7834        let endpoint = HttpEndpoint {
7835            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
7836            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
7837                .unwrap(),
7838            server_config,
7839            client: reqwest::Client::new(),
7840            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7841                PINNED_CLIENT_TTL,
7842                PINNED_CLIENT_MAX_ENTRIES,
7843            )),
7844            http_config: HttpConfig::default(),
7845        };
7846        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7847        let result = endpoint.create_consumer(rt);
7848        assert!(
7849            result.is_err(),
7850            "must error: https:// requires both tlsCert and tlsKey"
7851        );
7852    }
7853
7854    #[test]
7855    fn load_tls_config_parses_valid_pem() {
7856        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
7857        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7858        use camel_component_api::test_support::tls;
7859        let (_, cert_pem, key_pem) = tls::gen_server_cert();
7860        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
7861        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
7862
7863        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
7864        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
7865    }
7866
7867    #[tokio::test(flavor = "multi_thread")]
7868    #[allow(clippy::await_holding_lock)]
7869    async fn consumer_tls_handshake_roundtrip() {
7870        use camel_component_api::test_support::tls;
7871        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7872
7873        // Install rustls crypto provider (aws-lc-rs)
7874        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7875
7876        // Serialize against global ServerRegistry singleton
7877        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7878
7879        // Generate CA + server cert
7880        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7881        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
7882        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
7883        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
7884
7885        // Get ephemeral port
7886        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7887        let port = probe.local_addr().unwrap().port();
7888        drop(probe);
7889
7890        ServerRegistry::reset();
7891
7892        // Create real HttpComponent + endpoint with TLS URI
7893        let component = HttpComponent::new();
7894        let endpoint_ctx = NoOpComponentContext;
7895        let uri = format!(
7896            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7897            cert_path.to_string_lossy(),
7898            key_path.to_string_lossy(),
7899        );
7900        let endpoint = component
7901            .create_endpoint(&uri, &endpoint_ctx)
7902            .expect("create TLS endpoint");
7903        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
7904
7905        // Start consumer — this calls get_or_spawn with tls_config
7906        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7907        let token = tokio_util::sync::CancellationToken::new();
7908        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
7909        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7910
7911        // Give server time to start
7912        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7913
7914        // Client with CA cert — REAL verification (no danger_accept_invalid)
7915        let ca_bytes = std::fs::read(&ca_path).unwrap();
7916        let client = reqwest::Client::builder()
7917            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
7918            .build()
7919            .unwrap();
7920
7921        let send_fut = client
7922            .post(format!("https://localhost:{port}/test"))
7923            .body("ping")
7924            .send();
7925
7926        // Handler: receive envelope, reply 200 with "pong" body
7927        let (http_result, _) = tokio::join!(send_fut, async {
7928            if let Some(mut envelope) = rx.recv().await {
7929                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7930                if let Some(reply_tx) = envelope.reply_tx {
7931                    let _ = reply_tx.send(Ok(envelope.exchange));
7932                }
7933            }
7934        });
7935
7936        let resp = http_result.expect("TLS handshake + request must succeed");
7937
7938        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
7939        let body = resp.text().await.unwrap();
7940        assert_eq!(body, "pong");
7941
7942        token.cancel();
7943    }
7944
7945    #[tokio::test(flavor = "multi_thread")]
7946    #[allow(clippy::await_holding_lock)]
7947    async fn consumer_tls_rejects_client_without_ca() {
7948        use camel_component_api::test_support::tls;
7949        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7950
7951        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7952
7953        // Serialize against global ServerRegistry singleton
7954        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7955
7956        let (_, cert_pem, key_pem) = tls::gen_server_cert();
7957        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
7958        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
7959
7960        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7961        let port = probe.local_addr().unwrap().port();
7962        drop(probe);
7963
7964        ServerRegistry::reset();
7965
7966        // Spawn TLS server via real HttpComponent path
7967        let component = HttpComponent::new();
7968        let endpoint_ctx = NoOpComponentContext;
7969        let uri = format!(
7970            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7971            cert_path.to_string_lossy(),
7972            key_path.to_string_lossy(),
7973        );
7974        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
7975        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7976        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7977        let token = tokio_util::sync::CancellationToken::new();
7978        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
7979        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7980
7981        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7982
7983        // Client WITHOUT CA cert — must fail TLS verification
7984        let client = reqwest::Client::builder().build().unwrap();
7985
7986        let result = client
7987            .get(format!("https://localhost:{port}/test"))
7988            .send()
7989            .await;
7990
7991        assert!(
7992            result.is_err(),
7993            "must reject without CA — proves real verification"
7994        );
7995
7996        token.cancel();
7997    }
7998
7999    #[test]
8000    fn server_config_partial_tls_cert_without_key() {
8001        // Parse URI with only tlsCert (no tlsKey)
8002        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
8003        // Partial params → tls_config must be None
8004        assert!(cfg.tls_config.is_none());
8005    }
8006
8007    #[test]
8008    fn endpoint_uri_options_count_parity() {
8009        // Mirror struct must stay in sync with bespoke from_components parser.
8010        assert_eq!(
8011            HttpEndpointConfig::uri_options().len(),
8012            20,
8013            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
8014        );
8015    }
8016
8017    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
8018        pairs
8019            .iter()
8020            .map(|(k, v)| {
8021                (
8022                    (*k).to_string(),
8023                    serde_json::Value::String((*v).to_string()),
8024                )
8025            })
8026            .collect()
8027    }
8028
8029    #[test]
8030    fn response_emits_cache_control_via_pragma_warning() {
8031        let headers = make_headers(&[
8032            ("Cache-Control", "public, max-age=3600"),
8033            ("Via", "1.1 myproxy"),
8034            ("Pragma", "no-cache"),
8035            ("Warning", "199 misc"),
8036        ]);
8037        let selected = select_response_headers(&headers, None, None);
8038        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8039        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
8040            assert!(
8041                names.contains(&expected),
8042                "{expected} should pass through to the response"
8043            );
8044        }
8045    }
8046
8047    #[test]
8048    fn response_excludes_request_only_and_server_owned() {
8049        let headers = make_headers(&[
8050            ("User-Agent", "x"),
8051            ("Accept", "*/*"),
8052            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
8053        ]);
8054        let selected = select_response_headers(&headers, None, None);
8055        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8056        for excluded in ["User-Agent", "Accept", "Date"] {
8057            assert!(
8058                !names.contains(&excluded),
8059                "{excluded} should NOT appear in the response"
8060            );
8061        }
8062    }
8063
8064    #[test]
8065    fn response_re_derives_content_type() {
8066        let headers = make_headers(&[("Content-Type", "text/plain")]);
8067        let selected = select_response_headers(&headers, Some("application/json".into()), None);
8068        let ct_entries: Vec<&str> = selected
8069            .iter()
8070            .filter(|(k, _)| k == "Content-Type")
8071            .map(|(_, v)| v.as_str())
8072            .collect();
8073        assert_eq!(
8074            ct_entries,
8075            ["application/json"],
8076            "exactly one Content-Type entry, re-derived from user_content_type"
8077        );
8078    }
8079
8080    #[test]
8081    fn response_excludes_camel_headers() {
8082        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
8083        let selected = select_response_headers(&headers, None, None);
8084        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8085        assert!(
8086            !names.contains(&"CamelHttpPath"),
8087            "Camel-namespace headers must be excluded"
8088        );
8089        assert!(
8090            names.contains(&"Cache-Control"),
8091            "Cache-Control must pass through"
8092        );
8093    }
8094
8095    // -----------------------------------------------------------------------
8096    // Bridge proxy end-to-end integration tests (Task 4.1)
8097    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
8098    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
8099    // -----------------------------------------------------------------------
8100
8101    /// Destination server that captures the outbound request line and the
8102    /// `Host:` header the producer actually sent on the wire. Returns
8103    /// `(host_value, request_line)` so a bridge-proxy test can assert that
8104    /// the producer derived `Host` from the destination (not the exchange)
8105    /// and honoured bridging semantics for the path.
8106    async fn start_host_capturing_destination() -> (
8107        String,
8108        Arc<std::sync::Mutex<Option<(String, String)>>>,
8109        tokio::task::JoinHandle<()>,
8110    ) {
8111        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8112        let port = listener.local_addr().unwrap().port();
8113        let url = format!("http://127.0.0.1:{port}");
8114        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
8115            Arc::new(std::sync::Mutex::new(None));
8116        let captured_clone = Arc::clone(&captured);
8117        let handle = tokio::spawn(async move {
8118            use tokio::io::{AsyncReadExt, AsyncWriteExt};
8119            if let Ok((mut stream, _)) = listener.accept().await {
8120                let mut buf = vec![0u8; 16384];
8121                let n = stream.read(&mut buf).await.unwrap_or(0);
8122                let request = String::from_utf8_lossy(&buf[..n]).to_string();
8123                if request.contains("\r\n\r\n") {
8124                    let request_line = request.lines().next().unwrap_or("").to_string();
8125                    let host_value = request
8126                        .lines()
8127                        .find(|l| l.to_lowercase().starts_with("host:"))
8128                        .and_then(|l| l.split_once(':'))
8129                        .map(|(_, v)| v.trim().to_string())
8130                        .unwrap_or_default();
8131                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
8132                }
8133                let body = r#"{"echo":"ok"}"#;
8134                let resp = format!(
8135                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
8136                    body.len(),
8137                    body
8138                );
8139                let _ = stream.write_all(resp.as_bytes()).await;
8140            }
8141        });
8142        (url, captured, handle)
8143    }
8144
8145    /// A bridging producer must derive `Host` from the destination URL and
8146    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
8147    /// semantics. The wire-level proof is the raw `Host:` header and request
8148    /// line captured at the destination TCP socket.
8149    #[tokio::test]
8150    async fn bridge_proxy_outbound_host_matches_destination() {
8151        use tower::ServiceExt;
8152
8153        let (url, captured, _handle) = start_host_capturing_destination().await;
8154        // The Host header reqwest derives for http://127.0.0.1:{port} is the
8155        // authority, scheme-stripped: "127.0.0.1:{port}".
8156        let expected_host = url.strip_prefix("http://").unwrap();
8157
8158        let ctx = test_producer_ctx();
8159        let component = HttpComponent::new();
8160        let endpoint_ctx = NoOpComponentContext;
8161        let endpoint = component
8162            .create_endpoint(
8163                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
8164                &endpoint_ctx,
8165            )
8166            .unwrap();
8167        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8168
8169        // Exchange carries a stale Host and a CamelHttpPath that bridging
8170        // must drop.
8171        let mut exchange = Exchange::new(Message::default());
8172        exchange.input.set_header("Host", "localhost");
8173        exchange.input.set_header("CamelHttpPath", "/foo");
8174
8175        let result = producer.oneshot(exchange).await;
8176        assert!(result.is_ok(), "producer call failed: {:?}", result);
8177
8178        tokio::time::sleep(Duration::from_millis(100)).await;
8179        let (host_value, request_line) = captured
8180            .lock()
8181            .unwrap()
8182            .take()
8183            .expect("destination capture mutex empty — producer did not reach the destination");
8184
8185        assert_ne!(
8186            host_value, "localhost",
8187            "bridge producer must not forward the exchange Host: localhost"
8188        );
8189        assert_eq!(
8190            host_value, expected_host,
8191            "Host must be derived from the destination authority (no scheme)"
8192        );
8193        assert!(
8194            !request_line.contains("/foo"),
8195            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
8196        );
8197    }
8198
8199    /// A response header set by the route (`Cache-Control`) must survive to
8200    /// the wire. The assertion is on the reqwest HTTP response — not an
8201    /// in-process HttpReply struct — so it proves the consumer's reply
8202    /// finaliser emitted the header over the socket.
8203    #[tokio::test]
8204    async fn bridge_proxy_route_set_response_header_survives() {
8205        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8206
8207        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8208        let port = listener.local_addr().unwrap().port();
8209        drop(listener);
8210
8211        let component = HttpComponent::new();
8212        let endpoint_ctx = NoOpComponentContext;
8213        let endpoint = component
8214            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
8215            .unwrap();
8216        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8217
8218        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8219        let token = tokio_util::sync::CancellationToken::new();
8220        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8221
8222        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8223        tokio::time::sleep(Duration::from_millis(50)).await;
8224
8225        let client = reqwest::Client::new();
8226        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
8227
8228        // Route sets Cache-Control on the outbound reply (exchange.input is
8229        // the message the reply finaliser reads — see select_response_headers
8230        // at the dispatch site).
8231        let (http_result, _) = tokio::join!(send_fut, async {
8232            if let Some(mut envelope) = rx.recv().await {
8233                envelope
8234                    .exchange
8235                    .input
8236                    .set_header("Cache-Control", "public, max-age=3600");
8237                if let Some(reply_tx) = envelope.reply_tx {
8238                    let _ = reply_tx.send(Ok(envelope.exchange));
8239                }
8240            }
8241        });
8242
8243        let resp = http_result.unwrap();
8244        assert_eq!(resp.status().as_u16(), 200);
8245
8246        let cache_control = resp.headers().get("cache-control");
8247        assert!(
8248            cache_control.is_some(),
8249            "Cache-Control header must survive to the wire response"
8250        );
8251        assert_eq!(
8252            cache_control.unwrap().to_str().unwrap(),
8253            "public, max-age=3600"
8254        );
8255
8256        token.cancel();
8257    }
8258
8259    // -----------------------------------------------------------------------
8260    // credential-sources task 2.3: credential values stay out of diagnostics
8261    // -----------------------------------------------------------------------
8262    //
8263    // camel-http has no request access log (design.md "Redaction sinks",
8264    // ADR-0051). The only diagnostic sink on the failed-auth path is
8265    // `pipeline_error_to_reply`, which renders the (generic) error message and
8266    // the *configured* route path — never the request URI, query string, or
8267    // extracted credential. These tests pin that redact-by-construction
8268    // contract: a sentinel credential presented in a declared source must not
8269    // appear in the reply body nor in any tracing record emitted while the
8270    // request is handled.
8271    //
8272    // Capture scope: `#[traced_test]` installs a per-crate env filter
8273    // (`camel_component_http=trace`), so records from OTHER targets
8274    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
8275    // redaction contract for those crates is guarded by their own tests.
8276    // Revisit this capture scope if camel-auth ever logs on the auth path.
8277    use camel_api::security_policy::CredentialSource;
8278    use camel_auth::credential_source::extract_token_from_exchange;
8279    use camel_auth::native_auth::NativeCredentialStore;
8280    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
8281
8282    // Sentinel credential values — test fixtures only, not real secrets.
8283    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
8284    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
8285    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
8286
8287    /// Build the exchange the consumer would build for a request envelope:
8288    /// standard Camel HTTP headers plus title-cased forwarded request headers.
8289    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
8290        let mut msg = Message::default();
8291        msg.set_header(
8292            "CamelHttpMethod",
8293            serde_json::Value::String(envelope.method.clone()),
8294        );
8295        msg.set_header(
8296            "CamelHttpPath",
8297            serde_json::Value::String(envelope.path.clone()),
8298        );
8299        msg.set_header(
8300            "CamelHttpQuery",
8301            serde_json::Value::String(envelope.query.clone()),
8302        );
8303        for (k, v) in &envelope.headers {
8304            if let Ok(val_str) = v.to_str() {
8305                msg.set_header(
8306                    title_case_header(k.as_str()),
8307                    serde_json::Value::String(val_str.to_string()),
8308                );
8309            }
8310        }
8311        Exchange::new(msg)
8312    }
8313
8314    /// Register a route whose responder authenticates each request against an
8315    /// empty native store, so every presented credential fails lookup with
8316    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
8317    /// authentication step (extract per `sources` → authenticate → deny) so the
8318    /// credential-extraction redaction contract is exercised on a real
8319    /// authentication failure.
8320    async fn spawn_failing_auth_route(
8321        registry: &HttpRouteRegistry,
8322        path: &str,
8323        sources: Vec<CredentialSource>,
8324    ) {
8325        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
8326            NativeCredentialStore::try_new(vec![]).unwrap(),
8327        ));
8328        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8329        registry.register_api_route(path.to_string(), tx).await;
8330        let path_owned = path.to_string();
8331        tokio::spawn(async move {
8332            while let Some(envelope) = rx.recv().await {
8333                let exchange = envelope_to_exchange(&envelope);
8334                let reply_tx = envelope.reply_tx;
8335                let result: Result<(), CamelError> = async {
8336                    let token = extract_token_from_exchange(&exchange, &sources)
8337                        .map(|extracted| extracted.token)
8338                        .ok_or_else(|| {
8339                            CamelError::Unauthenticated("no credential in any source".into())
8340                        })?;
8341                    authenticator.authenticate_bearer(&token).await?;
8342                    Ok(())
8343                }
8344                .await;
8345                let reply = match result {
8346                    Ok(()) => HttpReply {
8347                        status: 200,
8348                        headers: vec![],
8349                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
8350                    },
8351                    Err(e) => pipeline_error_to_reply(e, &path_owned),
8352                };
8353                let _ = reply_tx.send(reply);
8354            }
8355        });
8356    }
8357
8358    /// Whether any tracing record captured so far (process-wide) contains
8359    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
8360    /// shared buffer, so logs from spawned request-handling tasks are included.
8361    fn captured_logs_contain(needle: &str) -> bool {
8362        let buf = tracing_test::internal::global_buf().lock().unwrap();
8363        String::from_utf8_lossy(&buf).contains(needle)
8364    }
8365
8366    #[tracing_test::traced_test]
8367    #[tokio::test]
8368    async fn error_context_redacts_query_sentinel() {
8369        let (port, registry) = spawn_test_server().await;
8370        spawn_failing_auth_route(
8371            &registry,
8372            "/secure-query",
8373            vec![CredentialSource::QueryParam {
8374                param: "token".to_string(),
8375            }],
8376        )
8377        .await;
8378
8379        let client = reqwest::Client::new();
8380        let resp = client
8381            // allow-secret: `token` is the declared query-source param name, not a credential
8382            .get(format!(
8383                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
8384            ))
8385            .send()
8386            .await
8387            .unwrap();
8388
8389        assert_eq!(resp.status().as_u16(), 401);
8390        let body = resp.text().await.unwrap();
8391        assert_eq!(body, "Unauthorized");
8392        assert!(
8393            !body.contains(SENTINEL_QRY_42),
8394            "reply body must not contain the query credential"
8395        );
8396        assert!(
8397            !captured_logs_contain(SENTINEL_QRY_42),
8398            "no tracing record during request handling may render the query credential"
8399        );
8400        // Permanent positive control: the failed-auth warn! must be captured.
8401        // If the per-crate env filter ever stops matching, this fails loudly
8402        // instead of letting the sentinel assertions pass vacuously.
8403        assert!(
8404            captured_logs_contain("Authentication failed"),
8405            "positive control: the failed-auth warn! must be captured by the test subscriber"
8406        );
8407    }
8408
8409    #[tracing_test::traced_test]
8410    #[tokio::test]
8411    async fn error_context_redacts_cookie_sentinel() {
8412        let (port, registry) = spawn_test_server().await;
8413        spawn_failing_auth_route(
8414            &registry,
8415            "/secure-cookie",
8416            vec![CredentialSource::Cookie {
8417                name: "session".to_string(),
8418            }],
8419        )
8420        .await;
8421
8422        let client = reqwest::Client::new();
8423        let resp = client
8424            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
8425            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
8426            .send()
8427            .await
8428            .unwrap();
8429
8430        assert_eq!(resp.status().as_u16(), 401);
8431        let body = resp.text().await.unwrap();
8432        assert_eq!(body, "Unauthorized");
8433        assert!(
8434            !body.contains(SENTINEL_CKY_7),
8435            "reply body must not contain the cookie credential"
8436        );
8437        assert!(
8438            !captured_logs_contain(SENTINEL_CKY_7),
8439            "no tracing record during request handling may render the cookie credential"
8440        );
8441    }
8442
8443    #[tracing_test::traced_test]
8444    #[tokio::test]
8445    async fn error_reply_no_credential_value() {
8446        let (port, registry) = spawn_test_server().await;
8447        spawn_failing_auth_route(
8448            &registry,
8449            "/secure-bad",
8450            vec![CredentialSource::Cookie {
8451                name: "session".to_string(),
8452            }],
8453        )
8454        .await;
8455
8456        let client = reqwest::Client::new();
8457        let resp = client
8458            .get(format!("http://127.0.0.1:{port}/secure-bad"))
8459            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
8460            .send()
8461            .await
8462            .unwrap();
8463
8464        assert_eq!(resp.status().as_u16(), 401);
8465        let body = resp.text().await.unwrap();
8466        assert_eq!(body, "Unauthorized");
8467        assert!(
8468            !body.contains(SENTINEL_BAD_1),
8469            "reply body must not contain the credential value"
8470        );
8471        assert!(
8472            !captured_logs_contain(SENTINEL_BAD_1),
8473            "error logs must not render the credential value"
8474        );
8475    }
8476
8477    // -----------------------------------------------------------------------
8478    // Pinned-client-cache producer-path behavioral tests
8479    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
8480    // the endpoint cache, hostname requests build one client while the entry
8481    // stays retrievable, IP-literal requests bypass the cache)
8482    // -----------------------------------------------------------------------
8483
8484    /// Local responder that accepts any number of HTTP/1.1 connections on an
8485    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
8486    /// Unlike [`start_host_capturing_destination`], which serves exactly one
8487    /// connection, this loop keeps accepting so cache-reuse tests can drive
8488    /// several requests through one destination. Returns
8489    /// `(base_url, JoinHandle)`.
8490    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
8491        use tokio::io::AsyncWriteExt;
8492
8493        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
8494            .await
8495            .expect("bind ephemeral 127.0.0.1 listener");
8496        let port = listener.local_addr().expect("local addr").port();
8497        let base_url = format!("http://localhost:{port}");
8498        let handle = tokio::spawn(async move {
8499            while let Ok((mut conn, _)) = listener.accept().await {
8500                let _ = conn
8501                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
8502                    .await;
8503                let _ = conn.shutdown().await;
8504            }
8505        });
8506        (base_url, handle)
8507    }
8508
8509    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
8510    /// target a different authority (the 127.0.0.1 literal) on the same
8511    /// listener.
8512    fn responder_port(base_url: &str) -> u16 {
8513        url::Url::parse(base_url)
8514            .expect("responder base URL parses")
8515            .port()
8516            .expect("responder base URL carries an explicit port")
8517    }
8518
8519    /// Build an endpoint literal whose outbound config points at
8520    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
8521    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
8522    /// build counts stay observable across producers.
8523    fn endpoint_with_shared_cache(
8524        base_url: &str,
8525        pinned_cache: &Arc<PinnedClientCache>,
8526    ) -> HttpEndpoint {
8527        let uri = format!("{base_url}?allowInternal=true");
8528        HttpEndpoint {
8529            uri: uri.clone(),
8530            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
8531            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
8532            client: reqwest::Client::new(),
8533            pinned_cache: Arc::clone(pinned_cache),
8534            http_config: HttpConfig::default(),
8535        }
8536    }
8537
8538    #[tokio::test]
8539    async fn producers_share_endpoint_cache() {
8540        use tower::ServiceExt;
8541
8542        let (base_url, _handle) = spawn_multi_accept_200().await;
8543        let pinned_cache = Arc::new(PinnedClientCache::new(
8544            PINNED_CLIENT_TTL,
8545            PINNED_CLIENT_MAX_ENTRIES,
8546        ));
8547
8548        let ctx = test_producer_ctx();
8549        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
8550        let producer_a = endpoint.create_producer(rt(), &ctx);
8551        let producer_b = endpoint.create_producer(rt(), &ctx);
8552
8553        // Each producer sends one exchange whose resolved URL is the
8554        // endpoint's localhost base URL (a domain name → pinned-client path).
8555        for producer in [producer_a, producer_b] {
8556            let producer = producer.expect("create producer");
8557            let exchange = Exchange::new(Message::default());
8558            let reply = producer.oneshot(exchange).await;
8559            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8560        }
8561
8562        assert_eq!(
8563            pinned_cache.build_count(),
8564            1,
8565            "both producers must hit the same shared cache entry; a second \
8566             build means sharing is broken"
8567        );
8568    }
8569
8570    #[tokio::test]
8571    async fn producer_repeated_hostname_requests_build_one_client() {
8572        use tower::ServiceExt;
8573
8574        let (base_url, _handle) = spawn_multi_accept_200().await;
8575        let pinned_cache = Arc::new(PinnedClientCache::new(
8576            PINNED_CLIENT_TTL,
8577            PINNED_CLIENT_MAX_ENTRIES,
8578        ));
8579        let ctx = test_producer_ctx();
8580        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
8581        let producer = endpoint
8582            .create_producer(rt(), &ctx)
8583            .expect("create producer");
8584
8585        // Two sequential hostname requests — the cached pinned client stays
8586        // retrievable between them, so no second build may happen.
8587        for i in 0..2 {
8588            let exchange = Exchange::new(Message::default());
8589            let reply = producer.clone().oneshot(exchange).await;
8590            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
8591        }
8592
8593        assert_eq!(
8594            pinned_cache.build_count(),
8595            1,
8596            "repeated hostname requests must reuse the one pinned client; \
8597             0 builds means the producer bypassed the cache, more than 1 \
8598             means the entry was dropped"
8599        );
8600    }
8601
8602    #[tokio::test]
8603    async fn ip_literal_request_never_enters_cache() {
8604        use tower::ServiceExt;
8605
8606        let (base_url, _handle) = spawn_multi_accept_200().await;
8607        let pinned_cache = Arc::new(PinnedClientCache::new(
8608            PINNED_CLIENT_TTL,
8609            PINNED_CLIENT_MAX_ENTRIES,
8610        ));
8611
8612        let ctx = test_producer_ctx();
8613        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
8614        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
8615        let producer = endpoint
8616            .create_producer(rt(), &ctx)
8617            .expect("create producer");
8618
8619        let exchange = Exchange::new(Message::default());
8620        let reply = producer.oneshot(exchange).await;
8621        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8622
8623        assert_eq!(
8624            pinned_cache.build_count(),
8625            0,
8626            "an IP-literal URL must use the shared unpinned client and \
8627             never enter the pinned cache"
8628        );
8629    }
8630
8631    #[tokio::test]
8632    async fn test_component_endpoints_share_pinned_cache() {
8633        use tower::ServiceExt;
8634
8635        let component = HttpComponent::new();
8636        let (base_url, _handle) = spawn_multi_accept_200().await;
8637        let baseline = component.pinned_cache.build_count();
8638
8639        let ctx = test_producer_ctx();
8640        let endpoint_ctx = NoOpComponentContext;
8641        for uri in [
8642            format!("{base_url}/a?allowInternal=true&k=a"),
8643            format!("{base_url}/b?allowInternal=true&k=b"),
8644        ] {
8645            let endpoint = component
8646                .create_endpoint(&uri, &endpoint_ctx)
8647                .expect("create endpoint");
8648            let producer = endpoint
8649                .create_producer(rt(), &ctx)
8650                .expect("create producer");
8651            let exchange = Exchange::new(Message::default());
8652            let reply = producer.oneshot(exchange).await;
8653            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8654        }
8655
8656        assert_eq!(
8657            component.pinned_cache.build_count() - baseline,
8658            1,
8659            "endpoints created by one component must share its pinned cache; \
8660             0 builds means the endpoints bypassed it, more than 1 means \
8661             per-endpoint caches came back"
8662        );
8663    }
8664
8665    #[tokio::test]
8666    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
8667        use tower::ServiceExt;
8668
8669        let component = HttpComponent::new();
8670        let (base_url, _handle) = spawn_multi_accept_200().await;
8671        let baseline = component.pinned_cache.build_count();
8672
8673        let ctx = test_producer_ctx();
8674        let endpoint_ctx = NoOpComponentContext;
8675        for i in 0..3 {
8676            let endpoint = component
8677                .create_endpoint(
8678                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
8679                    &endpoint_ctx,
8680                )
8681                .expect("create endpoint");
8682            let producer = endpoint
8683                .create_producer(rt(), &ctx)
8684                .expect("create producer");
8685            let exchange = Exchange::new(Message::default());
8686            let reply = producer.oneshot(exchange).await;
8687            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
8688        }
8689
8690        assert_eq!(
8691            component.pinned_cache.build_count() - baseline,
8692            1,
8693            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
8694             must reuse the component's one pinned cache entry; 0 builds \
8695             means the endpoints bypassed it, more than 1 means \
8696             per-endpoint caches came back"
8697        );
8698    }
8699
8700    #[test]
8701    fn test_https_component_owns_distinct_cache() {
8702        let http = HttpComponent::new();
8703        let https = HttpsComponent::new();
8704
8705        assert!(
8706            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
8707            "http and https components must each own their own pinned cache"
8708        );
8709
8710        let endpoint_ctx = NoOpComponentContext;
8711        let _ = http
8712            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
8713            .expect("http endpoint");
8714        let _ = https
8715            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
8716            .expect("https endpoint");
8717
8718        assert_eq!(
8719            http.pinned_cache.build_count(),
8720            0,
8721            "endpoint creation must not build a pinned client"
8722        );
8723        assert_eq!(
8724            https.pinned_cache.build_count(),
8725            0,
8726            "endpoint creation must not build a pinned client"
8727        );
8728    }
8729
8730    #[test]
8731    fn test_component_constructor_builds_one_unpinned_client() {
8732        let baseline = build_client_call_count();
8733
8734        let _http = HttpComponent::new();
8735        assert_eq!(
8736            build_client_call_count() - baseline,
8737            1,
8738            "HttpComponent::new() must build exactly one shared unpinned client"
8739        );
8740
8741        let _https = HttpsComponent::new();
8742        assert_eq!(
8743            build_client_call_count() - baseline,
8744            2,
8745            "HttpsComponent::new() must build exactly one more shared unpinned client"
8746        );
8747    }
8748
8749    #[test]
8750    fn test_component_endpoints_share_unpinned_client() {
8751        let component = HttpComponent::new();
8752        let baseline = build_client_call_count();
8753
8754        let endpoint_ctx = NoOpComponentContext;
8755        for uri in [
8756            "http://localhost:1/a?allowInternal=true",
8757            "http://localhost:1/b?allowInternal=true",
8758        ] {
8759            let _endpoint = component
8760                .create_endpoint(uri, &endpoint_ctx)
8761                .expect("create endpoint");
8762        }
8763
8764        assert_eq!(
8765            build_client_call_count() - baseline,
8766            0,
8767            "create_endpoint must clone the component's shared unpinned client, \
8768             never build a fresh one"
8769        );
8770    }
8771
8772    #[test]
8773    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
8774        let component = HttpComponent::new();
8775        let baseline = build_client_call_count();
8776
8777        let ctx = test_producer_ctx();
8778        let endpoint_ctx = NoOpComponentContext;
8779        for i in 0..3 {
8780            let endpoint = component
8781                .create_endpoint(
8782                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
8783                    &endpoint_ctx,
8784                )
8785                .expect("create endpoint");
8786            let _producer = endpoint
8787                .create_producer(rt(), &ctx)
8788                .expect("create producer");
8789        }
8790
8791        assert_eq!(
8792            build_client_call_count() - baseline,
8793            0,
8794            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
8795             must reuse the component's shared unpinned client and build \
8796             no additional clients"
8797        );
8798    }
8799}