Skip to main content

camel_component_grpc/
config.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::str::FromStr;
4use std::sync::Arc;
5
6use camel_api::error::CamelError;
7use camel_auth::oauth2::TokenProvider;
8use camel_component_api::NetworkRetryPolicy;
9use serde::Deserialize;
10use serde::de::{self, MapAccess, Visitor};
11use tracing::error;
12
13// ── Transport configuration (ADR-0033) ────────────────────────────────────
14
15/// Outbound (client) TLS configuration. The client VERIFIES the server
16/// (ca_cert) and optionally presents its own identity (mTLS).
17#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
18#[non_exhaustive]
19pub struct ClientTlsConfig {
20    #[serde(default)]
21    pub server_name: Option<String>,
22    #[serde(default)]
23    pub ca_cert_path: Option<String>,
24    #[serde(default)]
25    pub client_cert_path: Option<String>,
26    #[serde(default)]
27    pub client_key_path: Option<String>,
28    /// If true, skip server cert verification. Hard-errors today (fail-closed);
29    /// the field is preserved so the gate is explicit, not silent.
30    #[serde(default)]
31    pub insecure_skip_verify: bool,
32}
33
34/// Outbound transport intent. Plaintext MUST be declared explicitly (ADR-0033).
35#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
36pub enum ClientTransport {
37    #[default]
38    Plaintext,
39    Tls(ClientTlsConfig),
40}
41
42/// Inbound (server) TLS configuration. Server-auth by default; when
43/// `client_ca_path` is set, the server verifies client certificates (mTLS).
44#[derive(Debug, Clone, Deserialize, PartialEq)]
45pub struct ServerTlsConfig {
46    pub server_cert_path: String,
47    pub server_key_path: String,
48    /// Optional CA (PEM) to verify CLIENT certificates (mTLS). When set,
49    /// the server rejects clients that don't present a valid cert signed
50    /// by this CA. When absent, server-auth only (backward compatible).
51    #[serde(default)]
52    pub client_ca_path: Option<String>,
53}
54
55#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
56pub enum ServerTransport {
57    #[default]
58    Plaintext,
59    Tls(ServerTlsConfig),
60}
61
62/// Top-level transport intent — mirrors the URI `transport=` parameter.
63#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
64pub enum TransportIntent {
65    #[default]
66    Plaintext,
67    Tls,
68}
69
70// ── Auth configuration (GRPC-007) ─────────────────────────────────────────
71
72/// Authentication configuration for gRPC channels.
73#[derive(Default)]
74#[non_exhaustive]
75pub enum AuthConfig {
76    /// No authentication.
77    #[default]
78    None,
79    /// Bearer token authentication. Adds `Authorization: Bearer <token>` to request metadata.
80    Bearer { token: String },
81    /// Google service account authentication (scaffold only).
82    ///
83    /// /// TODO(GRPC-007): Google service account token refresh not yet implemented
84    GoogleServiceAccount { json_path: String },
85    /// OAuth2 token provider for dynamic token injection.
86    OAuth2 {
87        token_provider: Arc<dyn TokenProvider>,
88    },
89}
90
91impl<'de> Deserialize<'de> for AuthConfig {
92    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
93    where
94        D: de::Deserializer<'de>,
95    {
96        struct AuthConfigVisitor;
97
98        impl<'de> Visitor<'de> for AuthConfigVisitor {
99            type Value = AuthConfig;
100
101            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102                formatter.write_str("an AuthConfig variant")
103            }
104
105            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
106            where
107                E: de::Error,
108            {
109                match v {
110                    "None" => Ok(AuthConfig::None),
111                    "OAuth2" => Err(de::Error::custom(
112                        "OAuth2 variant cannot be deserialized; construct programmatically",
113                    )),
114                    other => Err(de::Error::unknown_variant(
115                        other,
116                        &["None", "Bearer", "GoogleServiceAccount", "OAuth2"],
117                    )),
118                }
119            }
120
121            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
122            where
123                M: MapAccess<'de>,
124            {
125                let variant = map
126                    .next_key::<String>()?
127                    .ok_or_else(|| de::Error::invalid_length(0, &self))?;
128                match variant.as_str() {
129                    "None" => Ok(AuthConfig::None),
130                    "Bearer" => {
131                        let mut token = None;
132                        while let Some(key) = map.next_key::<String>()? {
133                            if key == "token" {
134                                token = Some(map.next_value()?);
135                            } else {
136                                let _: de::IgnoredAny = map.next_value()?;
137                            }
138                        }
139                        let token = token.ok_or_else(|| de::Error::missing_field("token"))?;
140                        Ok(AuthConfig::Bearer { token })
141                    }
142                    "GoogleServiceAccount" => {
143                        let mut json_path = None;
144                        while let Some(key) = map.next_key::<String>()? {
145                            if key == "json_path" {
146                                json_path = Some(map.next_value()?);
147                            } else {
148                                let _: de::IgnoredAny = map.next_value()?;
149                            }
150                        }
151                        let json_path =
152                            json_path.ok_or_else(|| de::Error::missing_field("json_path"))?;
153                        Ok(AuthConfig::GoogleServiceAccount { json_path })
154                    }
155                    "OAuth2" => Err(de::Error::custom(
156                        "OAuth2 variant cannot be deserialized; construct programmatically",
157                    )),
158                    other => Err(de::Error::unknown_variant(
159                        other,
160                        &["None", "Bearer", "GoogleServiceAccount"],
161                    )),
162                }
163            }
164        }
165
166        deserializer.deserialize_any(AuthConfigVisitor)
167    }
168}
169
170impl fmt::Debug for AuthConfig {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        match self {
173            AuthConfig::None => write!(f, "None"), // allow-secret
174            AuthConfig::Bearer { .. } => write!(f, "Bearer {{ token: \"[REDACTED]\" }}"), // allow-secret
175            AuthConfig::GoogleServiceAccount { .. } => {
176                write!(f, "GoogleServiceAccount {{ json_path: \"[REDACTED]\" }}") // allow-secret
177            }
178            AuthConfig::OAuth2 { .. } => write!(f, "OAuth2 {{ token_provider: \"[REDACTED]\" }}"), // allow-secret
179        }
180    }
181}
182
183impl Clone for AuthConfig {
184    fn clone(&self) -> Self {
185        match self {
186            AuthConfig::None => AuthConfig::None,
187            AuthConfig::Bearer { token } => AuthConfig::Bearer {
188                token: token.clone(),
189            },
190            AuthConfig::GoogleServiceAccount { json_path } => AuthConfig::GoogleServiceAccount {
191                json_path: json_path.clone(),
192            },
193            AuthConfig::OAuth2 { token_provider } => AuthConfig::OAuth2 {
194                token_provider: Arc::clone(token_provider),
195            },
196        }
197    }
198}
199
200// ── Interceptor placeholder (GRPC-008) ────────────────────────────────────
201
202/// Placeholder for future gRPC interceptor registration.
203///
204/// This field stores interceptor class/type names as strings. Actual wiring
205/// into the tonic service stack is not yet implemented.
206///
207/// /// TODO(GRPC-008): interceptor registry not yet implemented
208#[derive(Debug, Clone, Default, Deserialize)]
209#[non_exhaustive]
210pub struct InterceptorConfig {
211    #[serde(default)]
212    pub interceptors: Vec<String>,
213}
214
215// ── Consumer / Producer strategies (GRPC-009) ────────────────────────────
216
217/// Strategy for selecting among multiple gRPC consumers.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub enum ConsumerStrategy {
221    #[default]
222    RoundRobin,
223    First,
224    Last,
225}
226
227impl fmt::Display for ConsumerStrategy {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        match self {
230            ConsumerStrategy::RoundRobin => write!(f, "roundRobin"),
231            ConsumerStrategy::First => write!(f, "first"),
232            ConsumerStrategy::Last => write!(f, "last"),
233        }
234    }
235}
236
237impl FromStr for ConsumerStrategy {
238    type Err = CamelError;
239
240    fn from_str(s: &str) -> Result<Self, Self::Err> {
241        match s {
242            "roundRobin" | "round_robin" => Ok(Self::RoundRobin),
243            "first" => Ok(Self::First),
244            "last" => Ok(Self::Last),
245            _ => Err(CamelError::Config(format!("invalid ConsumerStrategy: {s}"))),
246        }
247    }
248}
249
250/// Strategy for gRPC producer invocation mode.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
252#[serde(rename_all = "camelCase")]
253pub enum ProducerStrategy {
254    FireAndForget,
255    #[default]
256    RequestReply,
257}
258
259impl fmt::Display for ProducerStrategy {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        match self {
262            ProducerStrategy::FireAndForget => write!(f, "fireAndForget"),
263            ProducerStrategy::RequestReply => write!(f, "requestReply"),
264        }
265    }
266}
267
268impl FromStr for ProducerStrategy {
269    type Err = CamelError;
270
271    fn from_str(s: &str) -> Result<Self, Self::Err> {
272        match s {
273            "fireAndForget" | "fire_and_forget" => Ok(Self::FireAndForget),
274            "requestReply" | "request_reply" => Ok(Self::RequestReply),
275            _ => Err(CamelError::Config(format!("invalid ProducerStrategy: {s}"))),
276        }
277    }
278}
279
280// ── Main config ───────────────────────────────────────────────────────────
281
282#[derive(Clone, Deserialize)]
283pub struct GrpcConfig {
284    #[serde(rename = "protoFile")]
285    pub proto_file: Option<String>,
286    pub service: Option<String>,
287    pub method: Option<String>,
288    #[serde(default)]
289    pub reflection: bool,
290    #[serde(default)]
291    pub transport_intent: TransportIntent,
292    pub client_transport: ClientTransport,
293    pub server_transport: ServerTransport,
294    #[serde(default = "default_max_msg_len")]
295    pub max_receive_message_length: usize,
296    pub deadline_ms: Option<u64>,
297    pub metadata: Option<String>,
298
299    // H14: default connect timeout and deadline
300    #[serde(default = "default_connect_timeout_ms")]
301    pub connect_timeout_ms: u64,
302    #[serde(default = "default_deadline_ms")]
303    pub default_deadline_ms: u64,
304
305    // GRPC-007: Auth support
306    #[serde(default)]
307    pub auth: AuthConfig,
308
309    // GRPC-008: Interceptor placeholder
310    #[serde(default)]
311    pub interceptors: InterceptorConfig,
312
313    // GRPC-009: Strategy configuration
314    #[serde(default)]
315    pub consumer_strategy: ConsumerStrategy,
316    #[serde(default)]
317    pub producer_strategy: ProducerStrategy,
318
319    /// Transient error retry policy for gRPC producer RPC calls.
320    ///
321    /// Controls how the producer retries `Unavailable`, `DeadlineExceeded`,
322    /// `ResourceExhausted`, `Aborted`, and transport-level errors. Permanent
323    /// codes (`InvalidArgument`, `NotFound`, `PermissionDenied`, etc.) are
324    /// never retried.
325    #[serde(default)]
326    pub retry: NetworkRetryPolicy,
327}
328
329/// Custom Debug that redacts sensitive fields (GRPC-013).
330impl fmt::Debug for GrpcConfig {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        f.debug_struct("GrpcConfig")
333            .field("proto_file", &self.proto_file)
334            .field("service", &self.service)
335            .field("method", &self.method)
336            .field("reflection", &self.reflection)
337            .field("client_transport", &self.client_transport)
338            .field("server_transport", &self.server_transport)
339            .field(
340                "max_receive_message_length",
341                &self.max_receive_message_length,
342            )
343            .field("deadline_ms", &self.deadline_ms)
344            .field("connect_timeout_ms", &self.connect_timeout_ms)
345            .field("default_deadline_ms", &self.default_deadline_ms)
346            .field("metadata", &self.metadata.as_ref().map(|_| "[REDACTED]"))
347            .field("auth", &self.auth)
348            .field("interceptors", &self.interceptors)
349            .field("consumer_strategy", &self.consumer_strategy)
350            .field("producer_strategy", &self.producer_strategy)
351            .field("retry", &self.retry)
352            .finish()
353    }
354}
355
356fn default_max_msg_len() -> usize {
357    4 * 1024 * 1024
358}
359
360fn default_connect_timeout_ms() -> u64 {
361    10_000
362}
363
364fn default_deadline_ms() -> u64 {
365    30_000
366}
367
368/// Server-side configuration for the gRPC transport layer.
369#[derive(Debug, Clone)]
370pub struct GrpcServerConfig {
371    /// Maximum incoming message size in bytes. None means use tonic/hyper default.
372    pub max_receive_message_len: Option<usize>,
373    /// Transport mode for inbound connections (plaintext or TLS).
374    pub transport: ServerTransport,
375}
376
377impl Default for GrpcServerConfig {
378    fn default() -> Self {
379        Self {
380            max_receive_message_len: None,
381            transport: ServerTransport::Plaintext,
382        }
383    }
384}
385
386// ── TLS file I/O helper ───────────────────────────────────────────────────
387
388pub(crate) fn read_tls_file(
389    path: &str,
390    label: &str,
391    runtime: &Arc<dyn camel_component_api::RuntimeObservability>,
392    route_id: &str,
393) -> Result<Vec<u8>, CamelError> {
394    std::fs::read(path).map_err(|e| {
395        runtime.health().force_unhealthy_for_route(
396            route_id,
397            "g:grpc:tls-read",
398            &format!("failed to read {label}: {e}"),
399        );
400        // log-policy: outside-contract
401        error!(error = %e, "grpc TLS file read failed");
402        CamelError::EndpointCreationFailed(format!("failed to read {label}: {e}"))
403    })
404}
405
406// ── URI param parsing helpers ──────────────────────────────────────────────
407
408fn parse_bool_param(val: &str) -> Result<bool, CamelError> {
409    match val.to_ascii_lowercase().as_str() {
410        "true" | "1" | "yes" => Ok(true),
411        "false" | "0" | "no" => Ok(false),
412        _ => Err(CamelError::Config(format!("invalid bool value: {val}"))),
413    }
414}
415
416fn parse_numeric_param<T: std::str::FromStr>(val: &str, field: &str) -> Result<T, CamelError>
417where
418    T::Err: std::fmt::Display,
419{
420    val.parse::<T>()
421        .map_err(|e| CamelError::Config(format!("invalid numeric value for {field}: {val} ({e})")))
422}
423
424/// Parse query pairs into a typed `GrpcConfig`, handling bool and numeric
425/// params natively instead of relying on serde string→bool/number coercion.
426fn parse_grpc_query_params(
427    pairs: impl Iterator<Item = (String, String)>,
428) -> Result<GrpcConfig, CamelError> {
429    let mut map: HashMap<String, String> = HashMap::new();
430    for (k, v) in pairs {
431        map.insert(k, v);
432    }
433
434    let proto_file = map.remove("protoFile");
435    let service = map.remove("service");
436    let method = map.remove("method");
437    let metadata = map.remove("metadata");
438
439    let reflection = map
440        .remove("reflection")
441        .map(|v| parse_bool_param(&v))
442        .transpose()?
443        .unwrap_or(false);
444
445    let transport_val = map.remove("transport").ok_or_else(|| {
446        CamelError::Config(
447            "gRPC URI missing required 'transport' parameter. \
448                 Use transport=plaintext (explicit h2c) or transport=tls \
449                 (TLS). Omitted transport is rejected (ADR-0033)."
450                .to_string(),
451        )
452    })?;
453
454    if map.remove("tls").is_some() {
455        return Err(CamelError::Config(
456            "gRPC URI uses legacy 'tls' parameter — removed. \
457             Use transport=plaintext|tls (ADR-0033)."
458                .to_string(),
459        ));
460    }
461
462    let ca_cert_path = map.remove("caCertPath");
463    let client_cert_path = map.remove("clientCertPath");
464    let client_key_path = map.remove("clientKeyPath");
465    let client_server_name = map.remove("serverName");
466    let server_cert_path = map.remove("serverCertPath");
467    let server_key_path = map.remove("serverKeyPath");
468    let client_ca_path = map.remove("clientCaPath");
469
470    let (transport_intent, client_transport, server_transport) = match transport_val.as_str() {
471        "plaintext" => {
472            if [
473                ca_cert_path.as_ref(),
474                client_cert_path.as_ref(),
475                client_key_path.as_ref(),
476                client_server_name.as_ref(),
477                client_ca_path.as_ref(),
478            ]
479            .iter()
480            .any(|v| v.is_some())
481            {
482                return Err(CamelError::Config(
483                    "gRPC transport=plaintext with client cert params — conflicting intent. \
484                     Remove caCertPath/clientCertPath/clientKeyPath/serverName/clientCaPath."
485                        .to_string(),
486                ));
487            }
488            if [server_cert_path.as_ref(), server_key_path.as_ref()]
489                .iter()
490                .any(|v| v.is_some())
491            {
492                return Err(CamelError::Config(
493                    "gRPC transport=plaintext with server cert params — conflicting intent. \
494                     Remove serverCertPath/serverKeyPath."
495                        .to_string(),
496                ));
497            }
498            (
499                TransportIntent::Plaintext,
500                ClientTransport::Plaintext,
501                ServerTransport::Plaintext,
502            )
503        }
504        "tls" => {
505            let client = ClientTransport::Tls(ClientTlsConfig {
506                server_name: client_server_name,
507                ca_cert_path,
508                client_cert_path,
509                client_key_path,
510                insecure_skip_verify: false,
511            });
512            let server = match (server_cert_path, server_key_path) {
513                (Some(cert), Some(key)) => ServerTransport::Tls(ServerTlsConfig {
514                    server_cert_path: cert,
515                    server_key_path: key,
516                    client_ca_path,
517                }),
518                (None, None) => {
519                    if client_ca_path.is_some() {
520                        return Err(CamelError::Config(
521                            "gRPC transport=tls with clientCaPath requires serverCertPath + \
522                             serverKeyPath (inbound TLS). clientCaPath cannot apply to \
523                             plaintext serve."
524                                .to_string(),
525                        ));
526                    }
527                    ServerTransport::Plaintext
528                }
529                _ => {
530                    return Err(CamelError::Config(
531                        "gRPC transport=tls inbound requires BOTH serverCertPath and \
532                         serverKeyPath (or neither for plaintext serve)."
533                            .to_string(),
534                    ));
535                }
536            };
537            (TransportIntent::Tls, client, server)
538        }
539        other => {
540            return Err(CamelError::Config(format!(
541                "gRPC invalid transport='{other}'. Use transport=plaintext|tls."
542            )));
543        }
544    };
545
546    let max_receive_message_length = map
547        .remove("max_receive_message_length")
548        .map(|v| parse_numeric_param(&v, "max_receive_message_length"))
549        .transpose()?
550        .unwrap_or_else(default_max_msg_len);
551
552    let deadline_ms = map
553        .remove("deadline_ms")
554        .map(|v| parse_numeric_param(&v, "deadline_ms"))
555        .transpose()?;
556
557    let connect_timeout_ms = map
558        .remove("connectTimeoutMs")
559        .map(|v| parse_numeric_param(&v, "connectTimeoutMs"))
560        .transpose()?
561        .unwrap_or_else(default_connect_timeout_ms);
562
563    let default_deadline_ms = map
564        .remove("defaultDeadlineMs")
565        .map(|v| parse_numeric_param(&v, "defaultDeadlineMs"))
566        .transpose()?
567        .unwrap_or_else(default_deadline_ms);
568
569    // GRPC-007: Parse auth from query params
570    let auth = if let Some(token) = map.remove("bearerToken") {
571        AuthConfig::Bearer { token }
572    } else if let Some(json_path) = map.remove("googleServiceAccount") {
573        AuthConfig::GoogleServiceAccount { json_path }
574    } else {
575        AuthConfig::None
576    };
577
578    // GRPC-009: Parse strategies
579    let consumer_strategy = map
580        .remove("consumerStrategy")
581        .map(|v| ConsumerStrategy::from_str(&v))
582        .transpose()?
583        .unwrap_or_default();
584
585    let producer_strategy = map
586        .remove("producerStrategy")
587        .map(|v| ProducerStrategy::from_str(&v))
588        .transpose()?
589        .unwrap_or_default();
590
591    // Warn about any unrecognized params
592    for (k, v) in &map {
593        tracing::warn!("unrecognized gRPC URI parameter '{k}={v}' — ignored");
594    }
595
596    Ok(GrpcConfig {
597        proto_file,
598        service,
599        method,
600        reflection,
601        transport_intent,
602        client_transport,
603        server_transport,
604        max_receive_message_length,
605        deadline_ms,
606        metadata,
607        connect_timeout_ms,
608        default_deadline_ms,
609        auth,
610        interceptors: InterceptorConfig::default(),
611        consumer_strategy,
612        producer_strategy,
613        retry: NetworkRetryPolicy::default(),
614    })
615}
616
617pub fn parse_grpc_uri(uri: &str) -> Result<(String, u16, String, String, GrpcConfig), CamelError> {
618    let parsed = url::Url::parse(uri).map_err(|e| CamelError::RouteError(e.to_string()))?;
619    let host = parsed
620        .host_str()
621        .ok_or_else(|| CamelError::RouteError("missing host".to_string()))?
622        .to_string();
623    let port = parsed
624        .port()
625        .ok_or_else(|| CamelError::RouteError("missing port".to_string()))?;
626    let path = parsed.path().trim_start_matches('/');
627    let (service, method) = path.rsplit_once('/').ok_or_else(|| {
628        CamelError::RouteError("URI path must be package.Service/Method".to_string())
629    })?;
630    let config = parse_grpc_query_params(
631        parsed
632            .query_pairs()
633            .map(|(k, v)| (k.to_string(), v.to_string())),
634    )?;
635    if let Some(ref proto) = config.proto_file
636        && (proto.starts_with('/') || proto.contains(".."))
637    {
638        return Err(CamelError::RouteError(format!(
639            "proto path '{}' must be relative and cannot contain '..'",
640            proto
641        )));
642    }
643    if config.reflection {
644        tracing::warn!("gRPC reflection is not supported in v1 — parameter ignored");
645    }
646    Ok((host, port, service.to_string(), method.to_string(), config))
647}
648
649/// Apply config-level metadata to a tonic request (GRPC-004).
650///
651/// Parses `config.metadata` as `key1=value1,key2=value2` and injects
652/// each entry into the request's metadata map.
653pub fn apply_config_metadata<T>(config: &GrpcConfig, request: &mut tonic::Request<T>) {
654    if let Some(ref metadata_str) = config.metadata {
655        for pair in metadata_str.split(',') {
656            let pair = pair.trim();
657            if let Some((key, value)) = pair.split_once('=') {
658                let key = key.trim();
659                let value = value.trim();
660                if let Ok(name) = tonic::metadata::MetadataKey::from_bytes(key.as_bytes())
661                    && let Ok(meta_val) = tonic::metadata::MetadataValue::try_from(value)
662                {
663                    request.metadata_mut().insert(name, meta_val);
664                    tracing::debug!(key = key, "applied config metadata to gRPC request");
665                }
666            }
667        }
668    }
669}
670
671/// Apply auth headers from `AuthConfig` to a tonic request (GRPC-007).
672///
673/// Returns `Err` if auth is configured but cannot be applied (e.g. OAuth2
674/// token acquisition fails). Fail-closed: callers that configured auth
675/// expect authenticated requests.
676pub async fn apply_auth_metadata<T>(
677    auth: &AuthConfig,
678    request: &mut tonic::Request<T>,
679) -> Result<(), camel_api::CamelError> {
680    match auth {
681        AuthConfig::Bearer { token } => {
682            if let Ok(name) = tonic::metadata::MetadataKey::from_bytes("authorization".as_bytes()) {
683                let value = format!("Bearer {token}"); // allow-secret
684                if let Ok(meta_val) = tonic::metadata::MetadataValue::try_from(value.as_str()) {
685                    request.metadata_mut().insert(name, meta_val);
686                    tracing::debug!("applied bearer auth to gRPC request");
687                } else {
688                    return Err(camel_api::CamelError::ProcessorError(
689                        "bearer token contains invalid characters".into(),
690                    ));
691                }
692            }
693        }
694        AuthConfig::OAuth2 { token_provider } => {
695            let token = token_provider.get_token().await.map_err(|e| {
696                let message = format!("failed to acquire OAuth2 token for gRPC producer: {e}"); // allow-secret
697                camel_api::CamelError::ProcessorError(message)
698            })?;
699            if let Ok(name) = tonic::metadata::MetadataKey::from_bytes("authorization".as_bytes()) {
700                let value = format!("Bearer {token}"); // allow-secret
701                if let Ok(meta_val) = tonic::metadata::MetadataValue::try_from(value.as_str()) {
702                    request.metadata_mut().insert(name, meta_val);
703                }
704            }
705        }
706        AuthConfig::None | AuthConfig::GoogleServiceAccount { .. } => {}
707    }
708    Ok(())
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn test_parse_grpc_uri_valid() {
717        let uri = "grpc://localhost:50051/com.example.MyService/MyMethod?transport=plaintext";
718        let (host, port, service, method, config) = parse_grpc_uri(uri).unwrap();
719        assert_eq!(host, "localhost");
720        assert_eq!(port, 50051);
721        assert_eq!(service, "com.example.MyService");
722        assert_eq!(method, "MyMethod");
723        assert_eq!(config.max_receive_message_length, 4 * 1024 * 1024);
724        assert!(!config.reflection);
725        assert_eq!(config.transport_intent, TransportIntent::Plaintext);
726    }
727
728    #[test]
729    fn test_parse_grpc_uri_bool_query_params_case_insensitive() {
730        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=true&transport=plaintext";
731        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
732        assert!(config.reflection);
733
734        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=TRUE&transport=plaintext";
735        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
736        assert!(config.reflection);
737
738        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=1&transport=plaintext";
739        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
740        assert!(config.reflection);
741
742        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=yes&transport=plaintext";
743        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
744        assert!(config.reflection);
745
746        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=false&transport=plaintext";
747        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
748        assert!(!config.reflection);
749
750        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=0&transport=plaintext";
751        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
752        assert!(!config.reflection);
753
754        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=no&transport=plaintext";
755        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
756        assert!(!config.reflection);
757    }
758
759    #[test]
760    fn test_parse_grpc_uri_bool_query_params_invalid() {
761        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=maybe&transport=plaintext";
762        let result = parse_grpc_uri(uri);
763        assert!(result.is_err());
764        assert!(
765            result
766                .unwrap_err()
767                .to_string()
768                .contains("invalid bool value")
769        );
770    }
771
772    #[test]
773    fn test_parse_grpc_uri_with_proto_file() {
774        let uri = "grpc://localhost:50051/pkg.Svc/Method?protoFile=my.proto&transport=plaintext";
775        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
776        assert_eq!(config.proto_file, Some("my.proto".to_string()));
777    }
778
779    #[test]
780    fn test_parse_grpc_uri_numeric_query_params_work() {
781        let uri = "grpc://localhost:50051/pkg.Svc/Method?max_receive_message_length=8388608&transport=plaintext";
782        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
783        assert_eq!(config.max_receive_message_length, 8388608);
784
785        let uri = "grpc://localhost:50051/pkg.Svc/Method?deadline_ms=5000&transport=plaintext";
786        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
787        assert_eq!(config.deadline_ms, Some(5000));
788    }
789
790    #[test]
791    fn test_parse_grpc_uri_connect_timeout_and_default_deadline() {
792        let uri = "grpc://localhost:50051/pkg.Svc/Method?connectTimeoutMs=5000&defaultDeadlineMs=15000&transport=plaintext";
793        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
794        assert_eq!(config.connect_timeout_ms, 5000);
795        assert_eq!(config.default_deadline_ms, 15000);
796    }
797
798    #[test]
799    fn test_parse_grpc_uri_numeric_query_params_invalid() {
800        let uri =
801            "grpc://localhost:50051/pkg.Svc/Method?deadline_ms=notanumber&transport=plaintext";
802        let result = parse_grpc_uri(uri);
803        assert!(result.is_err());
804        assert!(
805            result
806                .unwrap_err()
807                .to_string()
808                .contains("invalid numeric value")
809        );
810    }
811
812    #[test]
813    fn test_parse_grpc_uri_with_metadata() {
814        let uri = "grpc://localhost:50051/pkg.Svc/Method?metadata=some-value&transport=plaintext";
815        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
816        assert_eq!(config.metadata, Some("some-value".to_string()));
817    }
818
819    #[test]
820    fn test_parse_grpc_uri_invalid_uri() {
821        let result = parse_grpc_uri("not-a-valid-uri");
822        assert!(result.is_err());
823        assert!(result.unwrap_err().to_string().contains("relative URL"));
824    }
825
826    #[test]
827    fn test_parse_grpc_uri_missing_host() {
828        let result = parse_grpc_uri("grpc:/pkg.Svc/Method");
829        assert!(result.is_err());
830        let err = result.unwrap_err().to_string();
831        assert!(err.contains("missing host") || err.contains("empty host"));
832    }
833
834    #[test]
835    fn test_parse_grpc_uri_missing_port() {
836        let result = parse_grpc_uri("grpc://localhost/pkg.Svc/Method?transport=plaintext");
837        assert!(result.is_err());
838        assert!(result.unwrap_err().to_string().contains("missing port"));
839    }
840
841    #[test]
842    fn test_parse_grpc_uri_missing_method_separator() {
843        let result = parse_grpc_uri("grpc://localhost:50051/NoSlashHere?transport=plaintext");
844        assert!(result.is_err());
845        assert!(
846            result
847                .unwrap_err()
848                .to_string()
849                .contains("package.Service/Method")
850        );
851    }
852
853    #[test]
854    fn test_parse_grpc_uri_proto_absolute_path_rejected() {
855        let uri = "grpc://localhost:50051/pkg.Svc/Method?protoFile=/etc/passwd&transport=plaintext";
856        let result = parse_grpc_uri(uri);
857        assert!(result.is_err());
858        assert!(result.unwrap_err().to_string().contains("proto path"));
859    }
860
861    #[test]
862    fn test_parse_grpc_uri_proto_traversal_rejected() {
863        let uri =
864            "grpc://localhost:50051/pkg.Svc/Method?protoFile=../secret.proto&transport=plaintext";
865        let result = parse_grpc_uri(uri);
866        assert!(result.is_err());
867        assert!(result.unwrap_err().to_string().contains(".."));
868    }
869
870    /// C1 Batch 1: `tls=true` via URI is rejected at parse time — a URI
871    /// cannot carry a tls_config (certificates), so `tls=true` from a URI
872    /// can never be satisfied and MUST fail-closed instead of silently
873    /// running h2c. Explicit plaintext (`tls=false` or omitted) still parses.
874    #[test]
875    fn test_parse_grpc_uri_tls_true_without_config_rejected() {
876        let uri = "grpc://localhost:50051/pkg.Svc/Method?tls=true&transport=plaintext";
877        let result = parse_grpc_uri(uri);
878        assert!(
879            result.is_err(),
880            "tls=true via URI must fail-closed at parse"
881        );
882        let msg = result.unwrap_err().to_string();
883        assert!(msg.contains("tls"), "error must mention tls: {msg}");
884    }
885
886    #[test]
887    fn test_grpc_config_defaults_via_deserialize() {
888        // ADR-0033: transport fields are REQUIRED — omitting them must error.
889        let result: Result<GrpcConfig, _> = serde_json::from_value(serde_json::json!({}));
890        assert!(
891            result.is_err(),
892            "GrpcConfig without explicit transport must fail (ADR-0033)"
893        );
894
895        // With explicit transports, defaults work:
896        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
897            "client_transport": "Plaintext",
898            "server_transport": "Plaintext"
899        }))
900        .unwrap();
901        assert_eq!(config.max_receive_message_length, 4 * 1024 * 1024);
902        assert!(!config.reflection);
903        assert_eq!(config.transport_intent, TransportIntent::Plaintext);
904        assert!(config.proto_file.is_none());
905        assert!(config.service.is_none());
906        assert!(config.method.is_none());
907        assert!(config.deadline_ms.is_none());
908        assert!(config.metadata.is_none());
909        assert_eq!(config.connect_timeout_ms, 10_000);
910        assert_eq!(config.default_deadline_ms, 30_000);
911    }
912
913    #[test]
914    fn test_grpc_config_deserialize_all_fields() {
915        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
916            "protoFile": "test.proto",
917            "service": "MyService",
918            "method": "MyMethod",
919            "reflection": true,
920            "transport_intent": "Tls",
921            "client_transport": {"Tls": {"server_name": "example.com"}},
922            "server_transport": "Plaintext",
923            "max_receive_message_length": 1024,
924            "deadline_ms": 3000,
925            "metadata": "auth-token"
926        }))
927        .unwrap();
928        assert_eq!(config.proto_file, Some("test.proto".to_string()));
929        assert_eq!(config.service, Some("MyService".to_string()));
930        assert_eq!(config.method, Some("MyMethod".to_string()));
931        assert!(config.reflection);
932        assert_eq!(config.transport_intent, TransportIntent::Tls);
933        assert_eq!(config.max_receive_message_length, 1024);
934        assert_eq!(config.deadline_ms, Some(3000));
935        assert_eq!(config.metadata, Some("auth-token".to_string()));
936    }
937
938    #[test]
939    fn test_grpc_config_clone_and_debug() {
940        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
941            "protoFile": "test.proto",
942            "client_transport": "Plaintext",
943            "server_transport": "Plaintext"
944        }))
945        .unwrap();
946        let cloned = config.clone();
947        assert_eq!(config.proto_file, cloned.proto_file);
948        let debug_str = format!("{config:?}");
949        assert!(debug_str.contains("GrpcConfig"));
950    }
951
952    // ── GrpcServerConfig tests ─────────────────────────────────────────────
953
954    #[test]
955    fn test_server_config_default() {
956        let cfg = GrpcServerConfig::default();
957        assert!(cfg.max_receive_message_len.is_none());
958        assert!(matches!(cfg.transport, ServerTransport::Plaintext));
959    }
960
961    #[test]
962    fn test_server_config_max_receive_message_len_applied() {
963        let cfg = GrpcServerConfig {
964            max_receive_message_len: Some(4096),
965            transport: ServerTransport::Plaintext,
966        };
967        assert_eq!(cfg.max_receive_message_len, Some(4096));
968    }
969
970    #[test]
971    fn test_server_config_clone_and_debug() {
972        let cfg = GrpcServerConfig {
973            max_receive_message_len: Some(8192),
974            transport: ServerTransport::Plaintext,
975        };
976        let cloned = cfg.clone();
977        assert_eq!(cfg.max_receive_message_len, cloned.max_receive_message_len);
978        let debug_str = format!("{cfg:?}");
979        assert!(debug_str.contains("GrpcServerConfig"));
980    }
981
982    // ── parse_bool_param tests ─────────────────────────────────────────────
983
984    #[test]
985    fn test_bool_param_case_insensitive() {
986        assert!(parse_bool_param("True").unwrap());
987        assert!(!parse_bool_param("FALSE").unwrap());
988        assert!(parse_bool_param("1").unwrap());
989        assert!(!parse_bool_param("0").unwrap());
990        assert!(parse_bool_param("yes").unwrap());
991        assert!(!parse_bool_param("no").unwrap());
992        assert!(parse_bool_param("YES").unwrap());
993        assert!(!parse_bool_param("NO").unwrap());
994    }
995
996    #[test]
997    fn test_bool_param_invalid_values() {
998        assert!(parse_bool_param("maybe").is_err());
999        assert!(parse_bool_param("").is_err());
1000        assert!(parse_bool_param("2").is_err());
1001        assert!(parse_bool_param("-1").is_err());
1002    }
1003
1004    // ── GRPC-004: apply_config_metadata tests ──────────────────────────────
1005
1006    #[test]
1007    fn test_apply_config_metadata_single_pair() {
1008        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
1009            "metadata": "x-custom=hello",
1010            "client_transport": "Plaintext",
1011            "server_transport": "Plaintext"
1012        }))
1013        .unwrap();
1014        let mut request = tonic::Request::new(());
1015        apply_config_metadata(&config, &mut request);
1016        assert_eq!(
1017            request
1018                .metadata()
1019                .get("x-custom")
1020                .unwrap()
1021                .to_str()
1022                .unwrap(),
1023            "hello"
1024        );
1025    }
1026
1027    #[test]
1028    fn test_apply_config_metadata_multiple_pairs() {
1029        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
1030            "metadata": "x-a=1, x-b=2",
1031            "client_transport": "Plaintext",
1032            "server_transport": "Plaintext"
1033        }))
1034        .unwrap();
1035        let mut request = tonic::Request::new(());
1036        apply_config_metadata(&config, &mut request);
1037        assert_eq!(
1038            request.metadata().get("x-a").unwrap().to_str().unwrap(),
1039            "1"
1040        );
1041        assert_eq!(
1042            request.metadata().get("x-b").unwrap().to_str().unwrap(),
1043            "2"
1044        );
1045    }
1046
1047    #[test]
1048    fn test_apply_config_metadata_empty_metadata() {
1049        let config: GrpcConfig =
1050            serde_json::from_value(serde_json::json!({"metadata": "", "client_transport": "Plaintext", "server_transport": "Plaintext"})).unwrap();
1051        let mut request = tonic::Request::new(());
1052        apply_config_metadata(&config, &mut request);
1053        assert!(request.metadata().is_empty());
1054    }
1055
1056    // ── ADR-0033: ClientTlsConfig tests ─────────────────────────────────────
1057
1058    #[test]
1059    fn test_client_tls_config_default() {
1060        let tls = ClientTlsConfig::default();
1061        assert!(tls.ca_cert_path.is_none());
1062        assert!(tls.client_cert_path.is_none());
1063        assert!(tls.client_key_path.is_none());
1064        assert!(!tls.insecure_skip_verify);
1065        assert!(tls.server_name.is_none());
1066    }
1067
1068    #[test]
1069    fn test_client_tls_config_deserialize() {
1070        let tls: ClientTlsConfig = serde_json::from_value(serde_json::json!({
1071            "ca_cert_path": "/path/to/ca.pem",
1072            "client_cert_path": "/path/to/client.pem",
1073            "client_key_path": "/path/to/client.key",
1074            "insecure_skip_verify": true,
1075            "server_name": "grpc.example.com"
1076        }))
1077        .unwrap();
1078        assert_eq!(tls.ca_cert_path, Some("/path/to/ca.pem".to_string()));
1079        assert_eq!(
1080            tls.client_cert_path,
1081            Some("/path/to/client.pem".to_string())
1082        );
1083        assert_eq!(tls.client_key_path, Some("/path/to/client.key".to_string()));
1084        assert!(tls.insecure_skip_verify);
1085        assert_eq!(tls.server_name, Some("grpc.example.com".to_string()));
1086    }
1087
1088    #[test]
1089    fn test_client_tls_config_clone_and_debug() {
1090        let tls = ClientTlsConfig {
1091            ca_cert_path: Some("/ca.pem".to_string()),
1092            client_cert_path: None,
1093            client_key_path: None,
1094            insecure_skip_verify: false,
1095            server_name: None,
1096        };
1097        let cloned = tls.clone();
1098        assert_eq!(tls.ca_cert_path, cloned.ca_cert_path);
1099        let debug_str = format!("{tls:?}");
1100        assert!(debug_str.contains("ClientTlsConfig"));
1101    }
1102
1103    // ── GRPC-007: AuthConfig tests ─────────────────────────────────────────
1104
1105    #[test]
1106    fn test_auth_config_default_is_none() {
1107        let auth = AuthConfig::default();
1108        assert!(matches!(auth, AuthConfig::None));
1109    }
1110
1111    #[tokio::test]
1112    async fn test_auth_config_bearer_applies_metadata() {
1113        let auth = AuthConfig::Bearer {
1114            token: "my-secret-token".to_string(),
1115        };
1116        let mut request = tonic::Request::new(());
1117        apply_auth_metadata(&auth, &mut request).await.unwrap();
1118        let val = request
1119            .metadata()
1120            .get("authorization")
1121            .unwrap()
1122            .to_str()
1123            .unwrap();
1124        assert_eq!(val, "Bearer my-secret-token");
1125    }
1126
1127    #[tokio::test]
1128    async fn test_auth_config_none_no_metadata() {
1129        let auth = AuthConfig::None;
1130        let mut request = tonic::Request::new(());
1131        apply_auth_metadata(&auth, &mut request).await.unwrap();
1132        assert!(request.metadata().get("authorization").is_none());
1133    }
1134
1135    #[tokio::test]
1136    async fn test_auth_config_google_scaffold_no_metadata() {
1137        let auth = AuthConfig::GoogleServiceAccount {
1138            json_path: "/path/to/sa.json".to_string(),
1139        };
1140        let mut request = tonic::Request::new(());
1141        apply_auth_metadata(&auth, &mut request).await.unwrap();
1142        assert!(request.metadata().get("authorization").is_none());
1143    }
1144
1145    #[tokio::test]
1146    async fn test_auth_config_oauth2_sets_bearer() {
1147        #[derive(Debug)]
1148        struct MockProvider;
1149        #[async_trait::async_trait]
1150        impl camel_auth::TokenProvider for MockProvider {
1151            async fn get_token(&self) -> Result<String, camel_auth::AuthError> {
1152                Ok("mock-oauth2-token".to_string())
1153            }
1154        }
1155        let auth = AuthConfig::OAuth2 {
1156            token_provider: std::sync::Arc::new(MockProvider),
1157        };
1158        let mut request = tonic::Request::new(());
1159        apply_auth_metadata(&auth, &mut request).await.unwrap();
1160        let auth_header = request.metadata().get("authorization").unwrap();
1161        assert_eq!(auth_header, "Bearer mock-oauth2-token");
1162    }
1163
1164    #[tokio::test]
1165    async fn test_auth_config_oauth2_failure_returns_error() {
1166        #[derive(Debug)]
1167        struct FailingProvider;
1168        #[async_trait::async_trait]
1169        impl camel_auth::TokenProvider for FailingProvider {
1170            async fn get_token(&self) -> Result<String, camel_auth::AuthError> {
1171                Err(camel_auth::AuthError::ProviderUnavailable(
1172                    "mock failure".into(),
1173                ))
1174            }
1175        }
1176        let auth = AuthConfig::OAuth2 {
1177            token_provider: std::sync::Arc::new(FailingProvider),
1178        };
1179        let mut request = tonic::Request::new(());
1180        let result = apply_auth_metadata(&auth, &mut request).await;
1181        assert!(result.is_err());
1182        assert!(request.metadata().get("authorization").is_none());
1183    }
1184
1185    // ── GRPC-008: InterceptorConfig tests ──────────────────────────────────
1186
1187    #[test]
1188    fn test_interceptor_config_default_empty() {
1189        let ic = InterceptorConfig::default();
1190        assert!(ic.interceptors.is_empty());
1191    }
1192
1193    #[test]
1194    fn test_interceptor_config_deserialize() {
1195        let ic: InterceptorConfig = serde_json::from_value(serde_json::json!({
1196            "interceptors": ["logging", "auth"]
1197        }))
1198        .unwrap();
1199        assert_eq!(ic.interceptors.len(), 2);
1200        assert_eq!(ic.interceptors[0], "logging");
1201        assert_eq!(ic.interceptors[1], "auth");
1202    }
1203
1204    // ── GRPC-009: ConsumerStrategy tests ───────────────────────────────────
1205
1206    #[test]
1207    fn test_consumer_strategy_default() {
1208        assert_eq!(ConsumerStrategy::default(), ConsumerStrategy::RoundRobin);
1209    }
1210
1211    #[test]
1212    fn test_consumer_strategy_from_str() {
1213        assert_eq!(
1214            ConsumerStrategy::from_str("roundRobin").unwrap(),
1215            ConsumerStrategy::RoundRobin
1216        );
1217        assert_eq!(
1218            ConsumerStrategy::from_str("first").unwrap(),
1219            ConsumerStrategy::First
1220        );
1221        assert_eq!(
1222            ConsumerStrategy::from_str("last").unwrap(),
1223            ConsumerStrategy::Last
1224        );
1225    }
1226
1227    #[test]
1228    fn test_consumer_strategy_display() {
1229        assert_eq!(ConsumerStrategy::RoundRobin.to_string(), "roundRobin");
1230        assert_eq!(ConsumerStrategy::First.to_string(), "first");
1231        assert_eq!(ConsumerStrategy::Last.to_string(), "last");
1232    }
1233
1234    #[test]
1235    fn test_consumer_strategy_invalid() {
1236        assert!(ConsumerStrategy::from_str("invalid").is_err());
1237    }
1238
1239    // ── GRPC-009: ProducerStrategy tests ───────────────────────────────────
1240
1241    #[test]
1242    fn test_producer_strategy_default() {
1243        assert_eq!(ProducerStrategy::default(), ProducerStrategy::RequestReply);
1244    }
1245
1246    #[test]
1247    fn test_producer_strategy_from_str() {
1248        assert_eq!(
1249            ProducerStrategy::from_str("fireAndForget").unwrap(),
1250            ProducerStrategy::FireAndForget
1251        );
1252        assert_eq!(
1253            ProducerStrategy::from_str("requestReply").unwrap(),
1254            ProducerStrategy::RequestReply
1255        );
1256    }
1257
1258    #[test]
1259    fn test_producer_strategy_display() {
1260        assert_eq!(ProducerStrategy::FireAndForget.to_string(), "fireAndForget");
1261        assert_eq!(ProducerStrategy::RequestReply.to_string(), "requestReply");
1262    }
1263
1264    #[test]
1265    fn test_producer_strategy_invalid() {
1266        assert!(ProducerStrategy::from_str("invalid").is_err());
1267    }
1268
1269    // ── GRPC-013: Debug redaction tests ────────────────────────────────────
1270
1271    #[test]
1272    fn test_grpc_config_debug_redacts_metadata() {
1273        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
1274            "metadata": "secret-key=value",
1275            "client_transport": "Plaintext",
1276            "server_transport": "Plaintext"
1277        }))
1278        .unwrap();
1279        let debug_str = format!("{config:?}");
1280        assert!(debug_str.contains("[REDACTED]"));
1281        assert!(!debug_str.contains("secret-key=value"));
1282    }
1283
1284    #[test]
1285    fn test_grpc_config_debug_no_redaction_without_secrets() {
1286        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
1287            "protoFile": "test.proto",
1288            "client_transport": "Plaintext",
1289            "server_transport": "Plaintext"
1290        }))
1291        .unwrap();
1292        let debug_str = format!("{config:?}");
1293        assert!(debug_str.contains("test.proto"));
1294    }
1295
1296    #[test]
1297    fn test_auth_config_debug_redacts_bearer_token() {
1298        let auth = AuthConfig::Bearer {
1299            token: "super-secret-token".to_string(),
1300        };
1301        let debug_str = format!("{auth:?}");
1302        assert!(debug_str.contains("[REDACTED]"));
1303        assert!(!debug_str.contains("super-secret-token"));
1304    }
1305
1306    #[test]
1307    fn test_auth_config_debug_redacts_google_json_path() {
1308        let auth = AuthConfig::GoogleServiceAccount {
1309            json_path: "/secret/sa.json".to_string(),
1310        };
1311        let debug_str = format!("{auth:?}");
1312        assert!(debug_str.contains("[REDACTED]"));
1313        assert!(!debug_str.contains("/secret/sa.json"));
1314    }
1315
1316    #[test]
1317    fn test_auth_config_debug_none_is_clean() {
1318        let auth = AuthConfig::None;
1319        let debug_str = format!("{auth:?}");
1320        assert_eq!(debug_str, "None");
1321    }
1322
1323    // ── GRPC-007: Bearer token parsed from URI ─────────────────────────────
1324
1325    #[test]
1326    fn test_parse_grpc_uri_bearer_token() {
1327        let uri = "grpc://localhost:50051/pkg.Svc/Method?bearerToken=my-token&transport=plaintext";
1328        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
1329        match config.auth {
1330            AuthConfig::Bearer { ref token } => assert_eq!(token, "my-token"),
1331            _ => panic!("expected Bearer auth"),
1332        }
1333    }
1334
1335    // ── retry: NetworkRetryPolicy tests ────────────────────────────────────
1336
1337    #[test]
1338    fn grpc_config_has_retry_policy_toml() {
1339        let toml_str = r#"
1340            protoFile = "helloworld.proto"
1341            service = "Greeter"
1342            method = "SayHello"
1343            client_transport = "Plaintext"
1344            server_transport = "Plaintext"
1345            [retry]
1346            max_attempts = 3
1347            initial_delay_ms = 500
1348        "#;
1349        let cfg: GrpcConfig = toml::from_str(toml_str).expect("parse");
1350        assert_eq!(cfg.retry.max_attempts, 3);
1351        assert_eq!(
1352            cfg.retry.initial_delay,
1353            std::time::Duration::from_millis(500)
1354        );
1355    }
1356
1357    #[test]
1358    fn grpc_config_retry_defaults_when_not_specified() {
1359        let toml_str = r#"
1360            protoFile = "helloworld.proto"
1361            client_transport = "Plaintext"
1362            server_transport = "Plaintext"
1363        "#;
1364        let cfg: GrpcConfig = toml::from_str(toml_str).expect("parse");
1365        assert_eq!(
1366            cfg.retry,
1367            camel_component_api::NetworkRetryPolicy::default()
1368        );
1369    }
1370
1371    // ── GRPC-009: Strategy parsed from URI ─────────────────────────────────
1372
1373    #[test]
1374    fn test_parse_grpc_uri_consumer_strategy() {
1375        let uri =
1376            "grpc://localhost:50051/pkg.Svc/Method?consumerStrategy=first&transport=plaintext";
1377        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
1378        assert_eq!(config.consumer_strategy, ConsumerStrategy::First);
1379    }
1380
1381    #[test]
1382    fn test_parse_grpc_uri_producer_strategy() {
1383        let uri = "grpc://localhost:50051/pkg.Svc/Method?producerStrategy=fireAndForget&transport=plaintext";
1384        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
1385        assert_eq!(config.producer_strategy, ProducerStrategy::FireAndForget);
1386    }
1387
1388    // ── Fail-closed regression tests (rc-j0gl, rc-vnrl, rc-xp76) ───────────
1389
1390    /// Regression: omitted transport must error (ADR-0033).
1391    #[test]
1392    fn test_parse_rejects_omitted_transport() {
1393        let res =
1394            parse_grpc_uri("grpc://127.0.0.1:50051/helloworld.Greeter/SayHello?protoFile=a.proto");
1395        assert!(res.is_err(), "omitted transport must fail-closed");
1396        assert!(
1397            res.unwrap_err().to_string().contains("transport"),
1398            "error must mention transport"
1399        );
1400    }
1401
1402    /// Regression: legacy tls=true key rejected even alongside transport=tls.
1403    #[test]
1404    fn test_parse_rejects_legacy_tls_key_alone() {
1405        let res = parse_grpc_uri(
1406            "grpc://127.0.0.1:50051/helloworld.Greeter/SayHello?protoFile=a.proto&tls=true",
1407        );
1408        assert!(res.is_err(), "legacy tls= alone must fail");
1409        assert!(
1410            res.unwrap_err().to_string().contains("tls"),
1411            "error must mention tls"
1412        );
1413    }
1414
1415    /// Regression: clientCaPath without server certs rejected at parse time.
1416    #[test]
1417    fn test_parse_rejects_client_ca_without_server_certs() {
1418        let res = parse_grpc_uri(
1419            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=tls&clientCaPath=/ca.pem",
1420        );
1421        assert!(
1422            res.is_err(),
1423            "clientCaPath without serverCertPath/serverKeyPath must fail at parse"
1424        );
1425        let msg = res.unwrap_err().to_string();
1426        assert!(
1427            msg.contains("clientCaPath") || msg.contains("serverCertPath"),
1428            "error must name the missing params: {msg}"
1429        );
1430    }
1431
1432    /// Regression: transport=tls with serverCertPath but not serverKeyPath → error.
1433    #[test]
1434    fn test_parse_rejects_tls_with_server_cert_only() {
1435        let res = parse_grpc_uri(
1436            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=tls&serverCertPath=/c.pem",
1437        );
1438        assert!(
1439            res.is_err(),
1440            "serverCertPath without serverKeyPath must fail"
1441        );
1442        let msg = res.unwrap_err().to_string();
1443        assert!(
1444            msg.contains("serverCertPath") && msg.contains("serverKeyPath"),
1445            "error must name both params: {msg}"
1446        );
1447    }
1448
1449    /// Regression: transport=plaintext with caCertPath → conflicting-intent error.
1450    #[test]
1451    fn test_parse_rejects_plaintext_with_ca_cert() {
1452        let res = parse_grpc_uri(
1453            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&caCertPath=/ca.pem",
1454        );
1455        assert!(res.is_err(), "plaintext + caCertPath must conflict");
1456        assert!(
1457            res.unwrap_err().to_string().contains("conflicting"),
1458            "error must mention conflicting intent"
1459        );
1460    }
1461
1462    /// Regression: transport=plaintext with serverCertPath → conflicting-intent error.
1463    #[test]
1464    fn test_parse_rejects_plaintext_with_server_cert() {
1465        let res = parse_grpc_uri(
1466            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&serverCertPath=/cert.pem",
1467        );
1468        assert!(res.is_err(), "plaintext + serverCertPath must conflict");
1469        assert!(
1470            res.unwrap_err().to_string().contains("conflicting"),
1471            "error must mention conflicting intent"
1472        );
1473    }
1474
1475    /// Regression: transport=plaintext with clientCertPath → conflicting-intent error.
1476    #[test]
1477    fn test_parse_rejects_plaintext_with_client_cert() {
1478        let res = parse_grpc_uri(
1479            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&clientCertPath=/cert.pem",
1480        );
1481        assert!(res.is_err(), "plaintext + clientCertPath must conflict");
1482        assert!(
1483            res.unwrap_err().to_string().contains("conflicting"),
1484            "error must mention conflicting intent"
1485        );
1486    }
1487
1488    /// Regression: transport=plaintext with clientKeyPath → conflicting-intent error.
1489    #[test]
1490    fn test_parse_rejects_plaintext_with_client_key() {
1491        let res = parse_grpc_uri(
1492            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&clientKeyPath=/key.pem",
1493        );
1494        assert!(res.is_err(), "plaintext + clientKeyPath must conflict");
1495        assert!(
1496            res.unwrap_err().to_string().contains("conflicting"),
1497            "error must mention conflicting intent"
1498        );
1499    }
1500
1501    /// Regression: transport=plaintext with serverName → conflicting-intent error.
1502    #[test]
1503    fn test_parse_rejects_plaintext_with_server_name() {
1504        let res = parse_grpc_uri(
1505            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&serverName=example.com",
1506        );
1507        assert!(res.is_err(), "plaintext + serverName must conflict");
1508        assert!(
1509            res.unwrap_err().to_string().contains("conflicting"),
1510            "error must mention conflicting intent"
1511        );
1512    }
1513
1514    /// Regression: transport=plaintext with clientCaPath → conflicting-intent error.
1515    #[test]
1516    fn test_parse_rejects_plaintext_with_client_ca() {
1517        let res = parse_grpc_uri(
1518            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&clientCaPath=/ca.pem",
1519        );
1520        assert!(res.is_err(), "plaintext + clientCaPath must conflict");
1521        assert!(
1522            res.unwrap_err().to_string().contains("conflicting"),
1523            "error must mention conflicting intent"
1524        );
1525    }
1526}