Skip to main content

camel_component_http/
lib.rs

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