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