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. SECURITY (audit 2026-08-31, F5-1):
592    // log the KEY only — unknown params carry no metadata-driven redaction, so
593    // a mistyped secret param (`authToken=…`) would otherwise land in cleartext
594    // logs on every endpoint creation.
595    for k in map.keys() {
596        tracing::warn!("unrecognized gRPC URI parameter '{k}' — ignored");
597    }
598
599    Ok(GrpcConfig {
600        proto_file,
601        service,
602        method,
603        reflection,
604        transport_intent,
605        client_transport,
606        server_transport,
607        max_receive_message_length,
608        deadline_ms,
609        metadata,
610        connect_timeout_ms,
611        default_deadline_ms,
612        auth,
613        interceptors: InterceptorConfig::default(),
614        consumer_strategy,
615        producer_strategy,
616        retry: NetworkRetryPolicy::default(),
617    })
618}
619
620pub fn parse_grpc_uri(uri: &str) -> Result<(String, u16, String, String, GrpcConfig), CamelError> {
621    let parsed = url::Url::parse(uri).map_err(|e| CamelError::RouteError(e.to_string()))?;
622    let host = parsed
623        .host_str()
624        .ok_or_else(|| CamelError::RouteError("missing host".to_string()))?
625        .to_string();
626    let port = parsed
627        .port()
628        .ok_or_else(|| CamelError::RouteError("missing port".to_string()))?;
629    let path = parsed.path().trim_start_matches('/');
630    let (service, method) = path.rsplit_once('/').ok_or_else(|| {
631        CamelError::RouteError("URI path must be package.Service/Method".to_string())
632    })?;
633    let config = parse_grpc_query_params(
634        parsed
635            .query_pairs()
636            .map(|(k, v)| (k.to_string(), v.to_string())),
637    )?;
638    if let Some(ref proto) = config.proto_file
639        && (proto.starts_with('/') || proto.contains(".."))
640    {
641        return Err(CamelError::RouteError(format!(
642            "proto path '{}' must be relative and cannot contain '..'",
643            proto
644        )));
645    }
646    if config.reflection {
647        tracing::warn!("gRPC reflection is not supported in v1 — parameter ignored");
648    }
649    Ok((host, port, service.to_string(), method.to_string(), config))
650}
651
652/// Apply config-level metadata to a tonic request (GRPC-004).
653///
654/// Parses `config.metadata` as `key1=value1,key2=value2` and injects
655/// each entry into the request's metadata map.
656pub fn apply_config_metadata<T>(config: &GrpcConfig, request: &mut tonic::Request<T>) {
657    if let Some(ref metadata_str) = config.metadata {
658        for pair in metadata_str.split(',') {
659            let pair = pair.trim();
660            if let Some((key, value)) = pair.split_once('=') {
661                let key = key.trim();
662                let value = value.trim();
663                if let Ok(name) = tonic::metadata::MetadataKey::from_bytes(key.as_bytes())
664                    && let Ok(meta_val) = tonic::metadata::MetadataValue::try_from(value)
665                {
666                    request.metadata_mut().insert(name, meta_val);
667                    tracing::debug!(key = key, "applied config metadata to gRPC request");
668                }
669            }
670        }
671    }
672}
673
674/// Apply auth headers from `AuthConfig` to a tonic request (GRPC-007).
675///
676/// Returns `Err` if auth is configured but cannot be applied (e.g. OAuth2
677/// token acquisition fails). Fail-closed: callers that configured auth
678/// expect authenticated requests.
679pub async fn apply_auth_metadata<T>(
680    auth: &AuthConfig,
681    request: &mut tonic::Request<T>,
682) -> Result<(), camel_api::CamelError> {
683    match auth {
684        AuthConfig::Bearer { token } => {
685            if let Ok(name) = tonic::metadata::MetadataKey::from_bytes("authorization".as_bytes()) {
686                let value = format!("Bearer {token}"); // allow-secret
687                if let Ok(meta_val) = tonic::metadata::MetadataValue::try_from(value.as_str()) {
688                    request.metadata_mut().insert(name, meta_val);
689                    tracing::debug!("applied bearer auth to gRPC request");
690                } else {
691                    return Err(camel_api::CamelError::ProcessorError(
692                        "bearer token contains invalid characters".into(),
693                    ));
694                }
695            }
696        }
697        AuthConfig::OAuth2 { token_provider } => {
698            let token = token_provider.get_token().await.map_err(|e| {
699                let message = format!("failed to acquire OAuth2 token for gRPC producer: {e}"); // allow-secret
700                camel_api::CamelError::ProcessorError(message)
701            })?;
702            if let Ok(name) = tonic::metadata::MetadataKey::from_bytes("authorization".as_bytes()) {
703                let value = format!("Bearer {token}"); // allow-secret
704                if let Ok(meta_val) = tonic::metadata::MetadataValue::try_from(value.as_str()) {
705                    request.metadata_mut().insert(name, meta_val);
706                }
707            }
708        }
709        AuthConfig::None | AuthConfig::GoogleServiceAccount { .. } => {}
710    }
711    Ok(())
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn test_parse_grpc_uri_valid() {
720        let uri = "grpc://localhost:50051/com.example.MyService/MyMethod?transport=plaintext";
721        let (host, port, service, method, config) = parse_grpc_uri(uri).unwrap();
722        assert_eq!(host, "localhost");
723        assert_eq!(port, 50051);
724        assert_eq!(service, "com.example.MyService");
725        assert_eq!(method, "MyMethod");
726        assert_eq!(config.max_receive_message_length, 4 * 1024 * 1024);
727        assert!(!config.reflection);
728        assert_eq!(config.transport_intent, TransportIntent::Plaintext);
729    }
730
731    #[test]
732    fn test_parse_grpc_uri_bool_query_params_case_insensitive() {
733        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=true&transport=plaintext";
734        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
735        assert!(config.reflection);
736
737        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=TRUE&transport=plaintext";
738        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
739        assert!(config.reflection);
740
741        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=1&transport=plaintext";
742        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
743        assert!(config.reflection);
744
745        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=yes&transport=plaintext";
746        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
747        assert!(config.reflection);
748
749        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=false&transport=plaintext";
750        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
751        assert!(!config.reflection);
752
753        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=0&transport=plaintext";
754        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
755        assert!(!config.reflection);
756
757        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=no&transport=plaintext";
758        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
759        assert!(!config.reflection);
760    }
761
762    #[test]
763    fn test_parse_grpc_uri_bool_query_params_invalid() {
764        let uri = "grpc://localhost:50051/pkg.Svc/Method?reflection=maybe&transport=plaintext";
765        let result = parse_grpc_uri(uri);
766        assert!(result.is_err());
767        assert!(
768            result
769                .unwrap_err()
770                .to_string()
771                .contains("invalid bool value")
772        );
773    }
774
775    #[test]
776    fn test_parse_grpc_uri_with_proto_file() {
777        let uri = "grpc://localhost:50051/pkg.Svc/Method?protoFile=my.proto&transport=plaintext";
778        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
779        assert_eq!(config.proto_file, Some("my.proto".to_string()));
780    }
781
782    #[test]
783    fn test_parse_grpc_uri_numeric_query_params_work() {
784        let uri = "grpc://localhost:50051/pkg.Svc/Method?max_receive_message_length=8388608&transport=plaintext";
785        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
786        assert_eq!(config.max_receive_message_length, 8388608);
787
788        let uri = "grpc://localhost:50051/pkg.Svc/Method?deadline_ms=5000&transport=plaintext";
789        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
790        assert_eq!(config.deadline_ms, Some(5000));
791    }
792
793    #[test]
794    fn test_parse_grpc_uri_connect_timeout_and_default_deadline() {
795        let uri = "grpc://localhost:50051/pkg.Svc/Method?connectTimeoutMs=5000&defaultDeadlineMs=15000&transport=plaintext";
796        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
797        assert_eq!(config.connect_timeout_ms, 5000);
798        assert_eq!(config.default_deadline_ms, 15000);
799    }
800
801    #[test]
802    fn test_parse_grpc_uri_numeric_query_params_invalid() {
803        let uri =
804            "grpc://localhost:50051/pkg.Svc/Method?deadline_ms=notanumber&transport=plaintext";
805        let result = parse_grpc_uri(uri);
806        assert!(result.is_err());
807        assert!(
808            result
809                .unwrap_err()
810                .to_string()
811                .contains("invalid numeric value")
812        );
813    }
814
815    #[test]
816    fn test_parse_grpc_uri_with_metadata() {
817        let uri = "grpc://localhost:50051/pkg.Svc/Method?metadata=some-value&transport=plaintext";
818        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
819        assert_eq!(config.metadata, Some("some-value".to_string()));
820    }
821
822    #[test]
823    fn test_parse_grpc_uri_invalid_uri() {
824        let result = parse_grpc_uri("not-a-valid-uri");
825        assert!(result.is_err());
826        assert!(result.unwrap_err().to_string().contains("relative URL"));
827    }
828
829    #[test]
830    fn test_parse_grpc_uri_missing_host() {
831        let result = parse_grpc_uri("grpc:/pkg.Svc/Method");
832        assert!(result.is_err());
833        let err = result.unwrap_err().to_string();
834        assert!(err.contains("missing host") || err.contains("empty host"));
835    }
836
837    #[test]
838    fn test_parse_grpc_uri_missing_port() {
839        let result = parse_grpc_uri("grpc://localhost/pkg.Svc/Method?transport=plaintext");
840        assert!(result.is_err());
841        assert!(result.unwrap_err().to_string().contains("missing port"));
842    }
843
844    #[test]
845    fn test_parse_grpc_uri_missing_method_separator() {
846        let result = parse_grpc_uri("grpc://localhost:50051/NoSlashHere?transport=plaintext");
847        assert!(result.is_err());
848        assert!(
849            result
850                .unwrap_err()
851                .to_string()
852                .contains("package.Service/Method")
853        );
854    }
855
856    #[test]
857    fn test_parse_grpc_uri_proto_absolute_path_rejected() {
858        let uri = "grpc://localhost:50051/pkg.Svc/Method?protoFile=/etc/passwd&transport=plaintext";
859        let result = parse_grpc_uri(uri);
860        assert!(result.is_err());
861        assert!(result.unwrap_err().to_string().contains("proto path"));
862    }
863
864    #[test]
865    fn test_parse_grpc_uri_proto_traversal_rejected() {
866        let uri =
867            "grpc://localhost:50051/pkg.Svc/Method?protoFile=../secret.proto&transport=plaintext";
868        let result = parse_grpc_uri(uri);
869        assert!(result.is_err());
870        assert!(result.unwrap_err().to_string().contains(".."));
871    }
872
873    /// C1 Batch 1: `tls=true` via URI is rejected at parse time — a URI
874    /// cannot carry a tls_config (certificates), so `tls=true` from a URI
875    /// can never be satisfied and MUST fail-closed instead of silently
876    /// running h2c. Explicit plaintext (`tls=false` or omitted) still parses.
877    #[test]
878    fn test_parse_grpc_uri_tls_true_without_config_rejected() {
879        let uri = "grpc://localhost:50051/pkg.Svc/Method?tls=true&transport=plaintext";
880        let result = parse_grpc_uri(uri);
881        assert!(
882            result.is_err(),
883            "tls=true via URI must fail-closed at parse"
884        );
885        let msg = result.unwrap_err().to_string();
886        assert!(msg.contains("tls"), "error must mention tls: {msg}");
887    }
888
889    #[test]
890    fn test_grpc_config_defaults_via_deserialize() {
891        // ADR-0033: transport fields are REQUIRED — omitting them must error.
892        let result: Result<GrpcConfig, _> = serde_json::from_value(serde_json::json!({}));
893        assert!(
894            result.is_err(),
895            "GrpcConfig without explicit transport must fail (ADR-0033)"
896        );
897
898        // With explicit transports, defaults work:
899        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
900            "client_transport": "Plaintext",
901            "server_transport": "Plaintext"
902        }))
903        .unwrap();
904        assert_eq!(config.max_receive_message_length, 4 * 1024 * 1024);
905        assert!(!config.reflection);
906        assert_eq!(config.transport_intent, TransportIntent::Plaintext);
907        assert!(config.proto_file.is_none());
908        assert!(config.service.is_none());
909        assert!(config.method.is_none());
910        assert!(config.deadline_ms.is_none());
911        assert!(config.metadata.is_none());
912        assert_eq!(config.connect_timeout_ms, 10_000);
913        assert_eq!(config.default_deadline_ms, 30_000);
914    }
915
916    #[test]
917    fn test_grpc_config_deserialize_all_fields() {
918        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
919            "protoFile": "test.proto",
920            "service": "MyService",
921            "method": "MyMethod",
922            "reflection": true,
923            "transport_intent": "Tls",
924            "client_transport": {"Tls": {"server_name": "example.com"}},
925            "server_transport": "Plaintext",
926            "max_receive_message_length": 1024,
927            "deadline_ms": 3000,
928            "metadata": "auth-token"
929        }))
930        .unwrap();
931        assert_eq!(config.proto_file, Some("test.proto".to_string()));
932        assert_eq!(config.service, Some("MyService".to_string()));
933        assert_eq!(config.method, Some("MyMethod".to_string()));
934        assert!(config.reflection);
935        assert_eq!(config.transport_intent, TransportIntent::Tls);
936        assert_eq!(config.max_receive_message_length, 1024);
937        assert_eq!(config.deadline_ms, Some(3000));
938        assert_eq!(config.metadata, Some("auth-token".to_string()));
939    }
940
941    #[test]
942    fn test_grpc_config_clone_and_debug() {
943        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
944            "protoFile": "test.proto",
945            "client_transport": "Plaintext",
946            "server_transport": "Plaintext"
947        }))
948        .unwrap();
949        let cloned = config.clone();
950        assert_eq!(config.proto_file, cloned.proto_file);
951        let debug_str = format!("{config:?}");
952        assert!(debug_str.contains("GrpcConfig"));
953    }
954
955    // ── GrpcServerConfig tests ─────────────────────────────────────────────
956
957    #[test]
958    fn test_server_config_default() {
959        let cfg = GrpcServerConfig::default();
960        assert!(cfg.max_receive_message_len.is_none());
961        assert!(matches!(cfg.transport, ServerTransport::Plaintext));
962    }
963
964    #[test]
965    fn test_server_config_max_receive_message_len_applied() {
966        let cfg = GrpcServerConfig {
967            max_receive_message_len: Some(4096),
968            transport: ServerTransport::Plaintext,
969        };
970        assert_eq!(cfg.max_receive_message_len, Some(4096));
971    }
972
973    #[test]
974    fn test_server_config_clone_and_debug() {
975        let cfg = GrpcServerConfig {
976            max_receive_message_len: Some(8192),
977            transport: ServerTransport::Plaintext,
978        };
979        let cloned = cfg.clone();
980        assert_eq!(cfg.max_receive_message_len, cloned.max_receive_message_len);
981        let debug_str = format!("{cfg:?}");
982        assert!(debug_str.contains("GrpcServerConfig"));
983    }
984
985    // ── parse_bool_param tests ─────────────────────────────────────────────
986
987    #[test]
988    fn test_bool_param_case_insensitive() {
989        assert!(parse_bool_param("True").unwrap());
990        assert!(!parse_bool_param("FALSE").unwrap());
991        assert!(parse_bool_param("1").unwrap());
992        assert!(!parse_bool_param("0").unwrap());
993        assert!(parse_bool_param("yes").unwrap());
994        assert!(!parse_bool_param("no").unwrap());
995        assert!(parse_bool_param("YES").unwrap());
996        assert!(!parse_bool_param("NO").unwrap());
997    }
998
999    #[test]
1000    fn test_bool_param_invalid_values() {
1001        assert!(parse_bool_param("maybe").is_err());
1002        assert!(parse_bool_param("").is_err());
1003        assert!(parse_bool_param("2").is_err());
1004        assert!(parse_bool_param("-1").is_err());
1005    }
1006
1007    // ── GRPC-004: apply_config_metadata tests ──────────────────────────────
1008
1009    #[test]
1010    fn test_apply_config_metadata_single_pair() {
1011        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
1012            "metadata": "x-custom=hello",
1013            "client_transport": "Plaintext",
1014            "server_transport": "Plaintext"
1015        }))
1016        .unwrap();
1017        let mut request = tonic::Request::new(());
1018        apply_config_metadata(&config, &mut request);
1019        assert_eq!(
1020            request
1021                .metadata()
1022                .get("x-custom")
1023                .unwrap()
1024                .to_str()
1025                .unwrap(),
1026            "hello"
1027        );
1028    }
1029
1030    #[test]
1031    fn test_apply_config_metadata_multiple_pairs() {
1032        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
1033            "metadata": "x-a=1, x-b=2",
1034            "client_transport": "Plaintext",
1035            "server_transport": "Plaintext"
1036        }))
1037        .unwrap();
1038        let mut request = tonic::Request::new(());
1039        apply_config_metadata(&config, &mut request);
1040        assert_eq!(
1041            request.metadata().get("x-a").unwrap().to_str().unwrap(),
1042            "1"
1043        );
1044        assert_eq!(
1045            request.metadata().get("x-b").unwrap().to_str().unwrap(),
1046            "2"
1047        );
1048    }
1049
1050    #[test]
1051    fn test_apply_config_metadata_empty_metadata() {
1052        let config: GrpcConfig =
1053            serde_json::from_value(serde_json::json!({"metadata": "", "client_transport": "Plaintext", "server_transport": "Plaintext"})).unwrap();
1054        let mut request = tonic::Request::new(());
1055        apply_config_metadata(&config, &mut request);
1056        assert!(request.metadata().is_empty());
1057    }
1058
1059    // ── ADR-0033: ClientTlsConfig tests ─────────────────────────────────────
1060
1061    #[test]
1062    fn test_client_tls_config_default() {
1063        let tls = ClientTlsConfig::default();
1064        assert!(tls.ca_cert_path.is_none());
1065        assert!(tls.client_cert_path.is_none());
1066        assert!(tls.client_key_path.is_none());
1067        assert!(!tls.insecure_skip_verify);
1068        assert!(tls.server_name.is_none());
1069    }
1070
1071    #[test]
1072    fn test_client_tls_config_deserialize() {
1073        let tls: ClientTlsConfig = serde_json::from_value(serde_json::json!({
1074            "ca_cert_path": "/path/to/ca.pem",
1075            "client_cert_path": "/path/to/client.pem",
1076            "client_key_path": "/path/to/client.key",
1077            "insecure_skip_verify": true,
1078            "server_name": "grpc.example.com"
1079        }))
1080        .unwrap();
1081        assert_eq!(tls.ca_cert_path, Some("/path/to/ca.pem".to_string()));
1082        assert_eq!(
1083            tls.client_cert_path,
1084            Some("/path/to/client.pem".to_string())
1085        );
1086        assert_eq!(tls.client_key_path, Some("/path/to/client.key".to_string()));
1087        assert!(tls.insecure_skip_verify);
1088        assert_eq!(tls.server_name, Some("grpc.example.com".to_string()));
1089    }
1090
1091    #[test]
1092    fn test_client_tls_config_clone_and_debug() {
1093        let tls = ClientTlsConfig {
1094            ca_cert_path: Some("/ca.pem".to_string()),
1095            client_cert_path: None,
1096            client_key_path: None,
1097            insecure_skip_verify: false,
1098            server_name: None,
1099        };
1100        let cloned = tls.clone();
1101        assert_eq!(tls.ca_cert_path, cloned.ca_cert_path);
1102        let debug_str = format!("{tls:?}");
1103        assert!(debug_str.contains("ClientTlsConfig"));
1104    }
1105
1106    // ── GRPC-007: AuthConfig tests ─────────────────────────────────────────
1107
1108    #[test]
1109    fn test_auth_config_default_is_none() {
1110        let auth = AuthConfig::default();
1111        assert!(matches!(auth, AuthConfig::None));
1112    }
1113
1114    #[tokio::test]
1115    async fn test_auth_config_bearer_applies_metadata() {
1116        let auth = AuthConfig::Bearer {
1117            token: "my-secret-token".to_string(),
1118        };
1119        let mut request = tonic::Request::new(());
1120        apply_auth_metadata(&auth, &mut request).await.unwrap();
1121        let val = request
1122            .metadata()
1123            .get("authorization")
1124            .unwrap()
1125            .to_str()
1126            .unwrap();
1127        assert_eq!(val, "Bearer my-secret-token");
1128    }
1129
1130    #[tokio::test]
1131    async fn test_auth_config_none_no_metadata() {
1132        let auth = AuthConfig::None;
1133        let mut request = tonic::Request::new(());
1134        apply_auth_metadata(&auth, &mut request).await.unwrap();
1135        assert!(request.metadata().get("authorization").is_none());
1136    }
1137
1138    #[tokio::test]
1139    async fn test_auth_config_google_scaffold_no_metadata() {
1140        let auth = AuthConfig::GoogleServiceAccount {
1141            json_path: "/path/to/sa.json".to_string(),
1142        };
1143        let mut request = tonic::Request::new(());
1144        apply_auth_metadata(&auth, &mut request).await.unwrap();
1145        assert!(request.metadata().get("authorization").is_none());
1146    }
1147
1148    #[tokio::test]
1149    async fn test_auth_config_oauth2_sets_bearer() {
1150        #[derive(Debug)]
1151        struct MockProvider;
1152        #[async_trait::async_trait]
1153        impl camel_auth::TokenProvider for MockProvider {
1154            async fn get_token(&self) -> Result<String, camel_auth::AuthError> {
1155                Ok("mock-oauth2-token".to_string())
1156            }
1157        }
1158        let auth = AuthConfig::OAuth2 {
1159            token_provider: std::sync::Arc::new(MockProvider),
1160        };
1161        let mut request = tonic::Request::new(());
1162        apply_auth_metadata(&auth, &mut request).await.unwrap();
1163        let auth_header = request.metadata().get("authorization").unwrap();
1164        assert_eq!(auth_header, "Bearer mock-oauth2-token");
1165    }
1166
1167    #[tokio::test]
1168    async fn test_auth_config_oauth2_failure_returns_error() {
1169        #[derive(Debug)]
1170        struct FailingProvider;
1171        #[async_trait::async_trait]
1172        impl camel_auth::TokenProvider for FailingProvider {
1173            async fn get_token(&self) -> Result<String, camel_auth::AuthError> {
1174                Err(camel_auth::AuthError::ProviderUnavailable(
1175                    "mock failure".into(),
1176                ))
1177            }
1178        }
1179        let auth = AuthConfig::OAuth2 {
1180            token_provider: std::sync::Arc::new(FailingProvider),
1181        };
1182        let mut request = tonic::Request::new(());
1183        let result = apply_auth_metadata(&auth, &mut request).await;
1184        assert!(result.is_err());
1185        assert!(request.metadata().get("authorization").is_none());
1186    }
1187
1188    // ── GRPC-008: InterceptorConfig tests ──────────────────────────────────
1189
1190    #[test]
1191    fn test_interceptor_config_default_empty() {
1192        let ic = InterceptorConfig::default();
1193        assert!(ic.interceptors.is_empty());
1194    }
1195
1196    #[test]
1197    fn test_interceptor_config_deserialize() {
1198        let ic: InterceptorConfig = serde_json::from_value(serde_json::json!({
1199            "interceptors": ["logging", "auth"]
1200        }))
1201        .unwrap();
1202        assert_eq!(ic.interceptors.len(), 2);
1203        assert_eq!(ic.interceptors[0], "logging");
1204        assert_eq!(ic.interceptors[1], "auth");
1205    }
1206
1207    // ── GRPC-009: ConsumerStrategy tests ───────────────────────────────────
1208
1209    #[test]
1210    fn test_consumer_strategy_default() {
1211        assert_eq!(ConsumerStrategy::default(), ConsumerStrategy::RoundRobin);
1212    }
1213
1214    #[test]
1215    fn test_consumer_strategy_from_str() {
1216        assert_eq!(
1217            ConsumerStrategy::from_str("roundRobin").unwrap(),
1218            ConsumerStrategy::RoundRobin
1219        );
1220        assert_eq!(
1221            ConsumerStrategy::from_str("first").unwrap(),
1222            ConsumerStrategy::First
1223        );
1224        assert_eq!(
1225            ConsumerStrategy::from_str("last").unwrap(),
1226            ConsumerStrategy::Last
1227        );
1228    }
1229
1230    #[test]
1231    fn test_consumer_strategy_display() {
1232        assert_eq!(ConsumerStrategy::RoundRobin.to_string(), "roundRobin");
1233        assert_eq!(ConsumerStrategy::First.to_string(), "first");
1234        assert_eq!(ConsumerStrategy::Last.to_string(), "last");
1235    }
1236
1237    #[test]
1238    fn test_consumer_strategy_invalid() {
1239        assert!(ConsumerStrategy::from_str("invalid").is_err());
1240    }
1241
1242    // ── GRPC-009: ProducerStrategy tests ───────────────────────────────────
1243
1244    #[test]
1245    fn test_producer_strategy_default() {
1246        assert_eq!(ProducerStrategy::default(), ProducerStrategy::RequestReply);
1247    }
1248
1249    #[test]
1250    fn test_producer_strategy_from_str() {
1251        assert_eq!(
1252            ProducerStrategy::from_str("fireAndForget").unwrap(),
1253            ProducerStrategy::FireAndForget
1254        );
1255        assert_eq!(
1256            ProducerStrategy::from_str("requestReply").unwrap(),
1257            ProducerStrategy::RequestReply
1258        );
1259    }
1260
1261    #[test]
1262    fn test_producer_strategy_display() {
1263        assert_eq!(ProducerStrategy::FireAndForget.to_string(), "fireAndForget");
1264        assert_eq!(ProducerStrategy::RequestReply.to_string(), "requestReply");
1265    }
1266
1267    #[test]
1268    fn test_producer_strategy_invalid() {
1269        assert!(ProducerStrategy::from_str("invalid").is_err());
1270    }
1271
1272    // ── GRPC-013: Debug redaction tests ────────────────────────────────────
1273
1274    #[test]
1275    fn test_grpc_config_debug_redacts_metadata() {
1276        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
1277            "metadata": "secret-key=value",
1278            "client_transport": "Plaintext",
1279            "server_transport": "Plaintext"
1280        }))
1281        .unwrap();
1282        let debug_str = format!("{config:?}");
1283        assert!(debug_str.contains("[REDACTED]"));
1284        assert!(!debug_str.contains("secret-key=value"));
1285    }
1286
1287    #[test]
1288    fn test_grpc_config_debug_no_redaction_without_secrets() {
1289        let config: GrpcConfig = serde_json::from_value(serde_json::json!({
1290            "protoFile": "test.proto",
1291            "client_transport": "Plaintext",
1292            "server_transport": "Plaintext"
1293        }))
1294        .unwrap();
1295        let debug_str = format!("{config:?}");
1296        assert!(debug_str.contains("test.proto"));
1297    }
1298
1299    #[test]
1300    fn test_auth_config_debug_redacts_bearer_token() {
1301        let auth = AuthConfig::Bearer {
1302            token: "super-secret-token".to_string(),
1303        };
1304        let debug_str = format!("{auth:?}");
1305        assert!(debug_str.contains("[REDACTED]"));
1306        assert!(!debug_str.contains("super-secret-token"));
1307    }
1308
1309    #[test]
1310    fn test_auth_config_debug_redacts_google_json_path() {
1311        let auth = AuthConfig::GoogleServiceAccount {
1312            json_path: "/secret/sa.json".to_string(),
1313        };
1314        let debug_str = format!("{auth:?}");
1315        assert!(debug_str.contains("[REDACTED]"));
1316        assert!(!debug_str.contains("/secret/sa.json"));
1317    }
1318
1319    #[test]
1320    fn test_auth_config_debug_none_is_clean() {
1321        let auth = AuthConfig::None;
1322        let debug_str = format!("{auth:?}");
1323        assert_eq!(debug_str, "None");
1324    }
1325
1326    // ── GRPC-007: Bearer token parsed from URI ─────────────────────────────
1327
1328    #[test]
1329    fn test_parse_grpc_uri_bearer_token() {
1330        let uri = "grpc://localhost:50051/pkg.Svc/Method?bearerToken=my-token&transport=plaintext";
1331        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
1332        match config.auth {
1333            AuthConfig::Bearer { ref token } => assert_eq!(token, "my-token"),
1334            _ => panic!("expected Bearer auth"),
1335        }
1336    }
1337
1338    // ── retry: NetworkRetryPolicy tests ────────────────────────────────────
1339
1340    #[test]
1341    fn grpc_config_has_retry_policy_toml() {
1342        let toml_str = r#"
1343            protoFile = "helloworld.proto"
1344            service = "Greeter"
1345            method = "SayHello"
1346            client_transport = "Plaintext"
1347            server_transport = "Plaintext"
1348            [retry]
1349            max_attempts = 3
1350            initial_delay_ms = 500
1351        "#;
1352        let cfg: GrpcConfig = toml::from_str(toml_str).expect("parse");
1353        assert_eq!(cfg.retry.max_attempts, 3);
1354        assert_eq!(
1355            cfg.retry.initial_delay,
1356            std::time::Duration::from_millis(500)
1357        );
1358    }
1359
1360    #[test]
1361    fn grpc_config_retry_defaults_when_not_specified() {
1362        let toml_str = r#"
1363            protoFile = "helloworld.proto"
1364            client_transport = "Plaintext"
1365            server_transport = "Plaintext"
1366        "#;
1367        let cfg: GrpcConfig = toml::from_str(toml_str).expect("parse");
1368        assert_eq!(
1369            cfg.retry,
1370            camel_component_api::NetworkRetryPolicy::default()
1371        );
1372    }
1373
1374    // ── GRPC-009: Strategy parsed from URI ─────────────────────────────────
1375
1376    #[test]
1377    fn test_parse_grpc_uri_consumer_strategy() {
1378        let uri =
1379            "grpc://localhost:50051/pkg.Svc/Method?consumerStrategy=first&transport=plaintext";
1380        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
1381        assert_eq!(config.consumer_strategy, ConsumerStrategy::First);
1382    }
1383
1384    #[test]
1385    fn test_parse_grpc_uri_producer_strategy() {
1386        let uri = "grpc://localhost:50051/pkg.Svc/Method?producerStrategy=fireAndForget&transport=plaintext";
1387        let (_, _, _, _, config) = parse_grpc_uri(uri).unwrap();
1388        assert_eq!(config.producer_strategy, ProducerStrategy::FireAndForget);
1389    }
1390
1391    // ── Fail-closed regression tests (rc-j0gl, rc-vnrl, rc-xp76) ───────────
1392
1393    /// Regression: omitted transport must error (ADR-0033).
1394    #[test]
1395    fn test_parse_rejects_omitted_transport() {
1396        let res =
1397            parse_grpc_uri("grpc://127.0.0.1:50051/helloworld.Greeter/SayHello?protoFile=a.proto");
1398        assert!(res.is_err(), "omitted transport must fail-closed");
1399        assert!(
1400            res.unwrap_err().to_string().contains("transport"),
1401            "error must mention transport"
1402        );
1403    }
1404
1405    /// Regression: legacy tls=true key rejected even alongside transport=tls.
1406    #[test]
1407    fn test_parse_rejects_legacy_tls_key_alone() {
1408        let res = parse_grpc_uri(
1409            "grpc://127.0.0.1:50051/helloworld.Greeter/SayHello?protoFile=a.proto&tls=true",
1410        );
1411        assert!(res.is_err(), "legacy tls= alone must fail");
1412        assert!(
1413            res.unwrap_err().to_string().contains("tls"),
1414            "error must mention tls"
1415        );
1416    }
1417
1418    /// Regression: clientCaPath without server certs rejected at parse time.
1419    #[test]
1420    fn test_parse_rejects_client_ca_without_server_certs() {
1421        let res = parse_grpc_uri(
1422            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=tls&clientCaPath=/ca.pem",
1423        );
1424        assert!(
1425            res.is_err(),
1426            "clientCaPath without serverCertPath/serverKeyPath must fail at parse"
1427        );
1428        let msg = res.unwrap_err().to_string();
1429        assert!(
1430            msg.contains("clientCaPath") || msg.contains("serverCertPath"),
1431            "error must name the missing params: {msg}"
1432        );
1433    }
1434
1435    /// Regression: transport=tls with serverCertPath but not serverKeyPath → error.
1436    #[test]
1437    fn test_parse_rejects_tls_with_server_cert_only() {
1438        let res = parse_grpc_uri(
1439            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=tls&serverCertPath=/c.pem",
1440        );
1441        assert!(
1442            res.is_err(),
1443            "serverCertPath without serverKeyPath must fail"
1444        );
1445        let msg = res.unwrap_err().to_string();
1446        assert!(
1447            msg.contains("serverCertPath") && msg.contains("serverKeyPath"),
1448            "error must name both params: {msg}"
1449        );
1450    }
1451
1452    /// Regression: transport=plaintext with caCertPath → conflicting-intent error.
1453    #[test]
1454    fn test_parse_rejects_plaintext_with_ca_cert() {
1455        let res = parse_grpc_uri(
1456            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&caCertPath=/ca.pem",
1457        );
1458        assert!(res.is_err(), "plaintext + caCertPath must conflict");
1459        assert!(
1460            res.unwrap_err().to_string().contains("conflicting"),
1461            "error must mention conflicting intent"
1462        );
1463    }
1464
1465    /// Regression: transport=plaintext with serverCertPath → conflicting-intent error.
1466    #[test]
1467    fn test_parse_rejects_plaintext_with_server_cert() {
1468        let res = parse_grpc_uri(
1469            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&serverCertPath=/cert.pem",
1470        );
1471        assert!(res.is_err(), "plaintext + serverCertPath must conflict");
1472        assert!(
1473            res.unwrap_err().to_string().contains("conflicting"),
1474            "error must mention conflicting intent"
1475        );
1476    }
1477
1478    /// Regression: transport=plaintext with clientCertPath → conflicting-intent error.
1479    #[test]
1480    fn test_parse_rejects_plaintext_with_client_cert() {
1481        let res = parse_grpc_uri(
1482            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&clientCertPath=/cert.pem",
1483        );
1484        assert!(res.is_err(), "plaintext + clientCertPath must conflict");
1485        assert!(
1486            res.unwrap_err().to_string().contains("conflicting"),
1487            "error must mention conflicting intent"
1488        );
1489    }
1490
1491    /// Regression: transport=plaintext with clientKeyPath → conflicting-intent error.
1492    #[test]
1493    fn test_parse_rejects_plaintext_with_client_key() {
1494        let res = parse_grpc_uri(
1495            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&clientKeyPath=/key.pem",
1496        );
1497        assert!(res.is_err(), "plaintext + clientKeyPath must conflict");
1498        assert!(
1499            res.unwrap_err().to_string().contains("conflicting"),
1500            "error must mention conflicting intent"
1501        );
1502    }
1503
1504    /// Regression: transport=plaintext with serverName → conflicting-intent error.
1505    #[test]
1506    fn test_parse_rejects_plaintext_with_server_name() {
1507        let res = parse_grpc_uri(
1508            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&serverName=example.com",
1509        );
1510        assert!(res.is_err(), "plaintext + serverName must conflict");
1511        assert!(
1512            res.unwrap_err().to_string().contains("conflicting"),
1513            "error must mention conflicting intent"
1514        );
1515    }
1516
1517    /// Regression: transport=plaintext with clientCaPath → conflicting-intent error.
1518    #[test]
1519    fn test_parse_rejects_plaintext_with_client_ca() {
1520        let res = parse_grpc_uri(
1521            "grpc://127.0.0.1:50051/pkg.Svc/Method?transport=plaintext&clientCaPath=/ca.pem",
1522        );
1523        assert!(res.is_err(), "plaintext + clientCaPath must conflict");
1524        assert!(
1525            res.unwrap_err().to_string().contains("conflicting"),
1526            "error must mention conflicting intent"
1527        );
1528    }
1529}