Skip to main content

camel_component_http/
lib.rs

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