Skip to main content

fraiseql_server/
server_config.rs

1//! Server configuration.
2
3use std::{net::SocketAddr, path::PathBuf};
4
5use fraiseql_core::security::OidcConfig;
6use serde::{Deserialize, Serialize};
7
8/// GraphQL IDE/playground tool to use.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
10#[serde(rename_all = "kebab-case")]
11pub enum PlaygroundTool {
12    /// GraphiQL - the classic GraphQL IDE.
13    GraphiQL,
14    /// Apollo Sandbox - Apollo's embeddable GraphQL IDE (default).
15    ///
16    /// Apollo Sandbox offers a better UX with features like:
17    /// - Query collections and history
18    /// - Schema documentation explorer
19    /// - Variables and headers panels
20    /// - Operation tracing
21    #[default]
22    ApolloSandbox,
23}
24
25/// TLS server configuration for HTTPS and secure connections.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TlsServerConfig {
28    /// Enable TLS for HTTP/gRPC endpoints.
29    pub enabled: bool,
30
31    /// Path to TLS certificate file (PEM format).
32    pub cert_path: PathBuf,
33
34    /// Path to TLS private key file (PEM format).
35    pub key_path: PathBuf,
36
37    /// Require client certificate (mTLS) for all connections.
38    #[serde(default)]
39    pub require_client_cert: bool,
40
41    /// Path to CA certificate for validating client certificates (for mTLS).
42    #[serde(default)]
43    pub client_ca_path: Option<PathBuf>,
44
45    /// Minimum TLS version ("1.2" or "1.3", default: "1.2").
46    #[serde(default = "default_tls_min_version")]
47    pub min_version: String,
48}
49
50/// Database TLS configuration for encrypted database connections.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct DatabaseTlsConfig {
53    /// PostgreSQL SSL mode: disable, allow, prefer, require, verify-ca, verify-full.
54    #[serde(default = "default_postgres_ssl_mode")]
55    pub postgres_ssl_mode: String,
56
57    /// Enable TLS for Redis connections (use rediss:// protocol).
58    #[serde(default = "default_redis_ssl")]
59    pub redis_ssl: bool,
60
61    /// Enable HTTPS for ClickHouse connections.
62    #[serde(default = "default_clickhouse_https")]
63    pub clickhouse_https: bool,
64
65    /// Enable HTTPS for Elasticsearch connections.
66    #[serde(default = "default_elasticsearch_https")]
67    pub elasticsearch_https: bool,
68
69    /// Verify server certificates for HTTPS connections.
70    #[serde(default = "default_verify_certs")]
71    pub verify_certificates: bool,
72
73    /// Path to CA certificate bundle for verifying server certificates.
74    #[serde(default)]
75    pub ca_bundle_path: Option<PathBuf>,
76}
77
78/// Rate limiting configuration for GraphQL requests.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RateLimitingConfig {
81    /// Enable rate limiting (default: true for security).
82    #[serde(default = "default_rate_limiting_enabled")]
83    pub enabled: bool,
84
85    /// Requests per second per IP address.
86    #[serde(default = "default_rate_limit_rps_per_ip")]
87    pub rps_per_ip: u32,
88
89    /// Requests per second per authenticated user.
90    #[serde(default = "default_rate_limit_rps_per_user")]
91    pub rps_per_user: u32,
92
93    /// Burst capacity (maximum accumulated tokens).
94    #[serde(default = "default_rate_limit_burst_size")]
95    pub burst_size: u32,
96
97    /// Cleanup interval for stale entries (seconds).
98    #[serde(default = "default_rate_limit_cleanup_interval")]
99    pub cleanup_interval_secs: u64,
100}
101
102/// Server configuration.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ServerConfig {
105    /// Path to compiled schema JSON file.
106    #[serde(default = "default_schema_path")]
107    pub schema_path: PathBuf,
108
109    /// Database connection URL (PostgreSQL, MySQL, SQLite, SQL Server).
110    #[serde(default = "default_database_url")]
111    pub database_url: String,
112
113    /// Server bind address.
114    #[serde(default = "default_bind_addr")]
115    pub bind_addr: SocketAddr,
116
117    /// Enable CORS.
118    #[serde(default = "default_true")]
119    pub cors_enabled: bool,
120
121    /// CORS allowed origins (if empty, allows all).
122    #[serde(default)]
123    pub cors_origins: Vec<String>,
124
125    /// Enable compression.
126    #[serde(default = "default_true")]
127    pub compression_enabled: bool,
128
129    /// Enable request tracing.
130    #[serde(default = "default_true")]
131    pub tracing_enabled: bool,
132
133    /// Enable APQ (Automatic Persisted Queries).
134    #[serde(default = "default_true")]
135    pub apq_enabled: bool,
136
137    /// Enable query caching.
138    #[serde(default = "default_true")]
139    pub cache_enabled: bool,
140
141    /// GraphQL endpoint path.
142    #[serde(default = "default_graphql_path")]
143    pub graphql_path: String,
144
145    /// Health check endpoint path.
146    #[serde(default = "default_health_path")]
147    pub health_path: String,
148
149    /// Introspection endpoint path.
150    #[serde(default = "default_introspection_path")]
151    pub introspection_path: String,
152
153    /// Metrics endpoint path (Prometheus format).
154    #[serde(default = "default_metrics_path")]
155    pub metrics_path: String,
156
157    /// Metrics JSON endpoint path.
158    #[serde(default = "default_metrics_json_path")]
159    pub metrics_json_path: String,
160
161    /// Playground (GraphQL IDE) endpoint path.
162    #[serde(default = "default_playground_path")]
163    pub playground_path: String,
164
165    /// Enable GraphQL playground/IDE (default: false for production safety).
166    ///
167    /// When enabled, serves a GraphQL IDE (GraphiQL or Apollo Sandbox)
168    /// at the configured `playground_path`.
169    ///
170    /// **Security**: Disabled by default for production safety. Set to true for development
171    /// environments only. The playground exposes schema information and can be a
172    /// reconnaissance vector for attackers.
173    #[serde(default)]
174    pub playground_enabled: bool,
175
176    /// Which GraphQL IDE to use.
177    ///
178    /// - `graphiql`: The classic GraphQL IDE (default)
179    /// - `apollo-sandbox`: Apollo's embeddable sandbox
180    #[serde(default)]
181    pub playground_tool: PlaygroundTool,
182
183    /// WebSocket endpoint path for GraphQL subscriptions.
184    #[serde(default = "default_subscription_path")]
185    pub subscription_path: String,
186
187    /// Enable GraphQL subscriptions over WebSocket.
188    ///
189    /// When enabled, provides graphql-ws (graphql-transport-ws) protocol
190    /// support for real-time subscription events.
191    #[serde(default = "default_true")]
192    pub subscriptions_enabled: bool,
193
194    /// Enable metrics endpoints.
195    ///
196    /// **Security**: Disabled by default for production safety.
197    /// When enabled, requires `metrics_token` to be set for authentication.
198    #[serde(default)]
199    pub metrics_enabled: bool,
200
201    /// Bearer token for metrics endpoint authentication.
202    ///
203    /// Required when `metrics_enabled` is true. Requests must include:
204    /// `Authorization: Bearer <token>`
205    ///
206    /// **Security**: Use a strong, random token (e.g., 32+ characters).
207    #[serde(default)]
208    pub metrics_token: Option<String>,
209
210    /// Enable admin API endpoints (default: false for production safety).
211    ///
212    /// **Security**: Disabled by default. When enabled, requires `admin_token` to be set.
213    /// Admin endpoints allow schema reloading, cache management, and config inspection.
214    #[serde(default)]
215    pub admin_api_enabled: bool,
216
217    /// Bearer token for admin API authentication.
218    ///
219    /// Required when `admin_api_enabled` is true. Requests must include:
220    /// `Authorization: Bearer <token>`
221    ///
222    /// **Security**: Use a strong, random token (minimum 32 characters).
223    /// This token grants access to sensitive operations like schema reloading.
224    #[serde(default)]
225    pub admin_token: Option<String>,
226
227    /// Enable introspection endpoint (default: false for production safety).
228    ///
229    /// **Security**: Disabled by default. When enabled, the introspection endpoint
230    /// exposes the complete GraphQL schema structure. Combined with `introspection_require_auth`,
231    /// you can optionally protect it with OIDC authentication.
232    #[serde(default)]
233    pub introspection_enabled: bool,
234
235    /// Require authentication for introspection endpoint (default: true).
236    ///
237    /// When true and OIDC is configured, introspection requires same auth as GraphQL endpoint.
238    /// When false, introspection is publicly accessible (use only in development).
239    #[serde(default = "default_true")]
240    pub introspection_require_auth: bool,
241
242    /// Require authentication for design audit API endpoints (default: true).
243    ///
244    /// Design audit endpoints expose system architecture and optimization opportunities.
245    /// When true and OIDC is configured, design endpoints require same auth as GraphQL endpoint.
246    /// When false, design endpoints are publicly accessible (use only in development).
247    #[serde(default = "default_true")]
248    pub design_api_require_auth: bool,
249
250    /// Database connection pool minimum size.
251    #[serde(default = "default_pool_min_size")]
252    pub pool_min_size: usize,
253
254    /// Database connection pool maximum size.
255    #[serde(default = "default_pool_max_size")]
256    pub pool_max_size: usize,
257
258    /// Database connection pool timeout in seconds.
259    #[serde(default = "default_pool_timeout")]
260    pub pool_timeout_secs: u64,
261
262    /// OIDC authentication configuration (optional).
263    ///
264    /// When set, enables JWT authentication using OIDC discovery.
265    /// Supports Auth0, Keycloak, Okta, Cognito, Azure AD, and any
266    /// OIDC-compliant provider.
267    ///
268    /// # Example (TOML)
269    ///
270    /// ```toml
271    /// [auth]
272    /// issuer = "https://your-tenant.auth0.com/"
273    /// audience = "your-api-identifier"
274    /// ```
275    #[serde(default)]
276    pub auth: Option<OidcConfig>,
277
278    /// TLS/SSL configuration for HTTPS and encrypted connections.
279    ///
280    /// When set, enables TLS enforcement for HTTP/gRPC endpoints and
281    /// optionally requires mutual TLS (mTLS) for client certificates.
282    ///
283    /// # Example (TOML)
284    ///
285    /// ```toml
286    /// [tls]
287    /// enabled = true
288    /// cert_path = "/etc/fraiseql/cert.pem"
289    /// key_path = "/etc/fraiseql/key.pem"
290    /// require_client_cert = false
291    /// min_version = "1.2"  # "1.2" or "1.3"
292    /// ```
293    #[serde(default)]
294    pub tls: Option<TlsServerConfig>,
295
296    /// Database TLS configuration.
297    ///
298    /// Enables TLS for database connections and configures
299    /// per-database TLS settings (PostgreSQL, Redis, ClickHouse, etc.).
300    ///
301    /// # Example (TOML)
302    ///
303    /// ```toml
304    /// [database_tls]
305    /// postgres_ssl_mode = "require"  # disable, allow, prefer, require, verify-ca, verify-full
306    /// redis_ssl = true               # Use rediss:// protocol
307    /// clickhouse_https = true         # Use HTTPS
308    /// elasticsearch_https = true      # Use HTTPS
309    /// verify_certificates = true      # Verify server certificates
310    /// ```
311    #[serde(default)]
312    pub database_tls: Option<DatabaseTlsConfig>,
313
314    /// Require `Content-Type: application/json` on POST requests (default: true).
315    ///
316    /// CSRF protection: rejects POST requests with non-JSON Content-Type
317    /// (e.g. `text/plain`, `application/x-www-form-urlencoded`) with 415.
318    #[serde(default = "default_true")]
319    pub require_json_content_type: bool,
320
321    /// Maximum request body size in bytes (default: 1 MB).
322    ///
323    /// Requests exceeding this limit receive 413 Payload Too Large.
324    /// Set to 0 to use axum's default (no limit).
325    #[serde(default = "default_max_request_body_bytes")]
326    pub max_request_body_bytes: usize,
327
328    /// Rate limiting configuration for GraphQL requests.
329    ///
330    /// When configured, enables per-IP and per-user rate limiting with token bucket algorithm.
331    /// Defaults to enabled with sensible per-IP limits for security-by-default.
332    ///
333    /// # Example (TOML)
334    ///
335    /// ```toml
336    /// [rate_limiting]
337    /// enabled = true
338    /// rps_per_ip = 100      # 100 requests/second per IP
339    /// rps_per_user = 1000   # 1000 requests/second per authenticated user
340    /// burst_size = 500      # Allow bursts up to 500 requests
341    /// ```
342    #[serde(default)]
343    pub rate_limiting: Option<RateLimitingConfig>,
344
345    /// Observer runtime configuration (optional, requires `observers` feature).
346    #[cfg(feature = "observers")]
347    #[serde(default)]
348    pub observers: Option<ObserverConfig>,
349}
350
351#[cfg(feature = "observers")]
352fn default_observers_enabled() -> bool {
353    true
354}
355
356#[cfg(feature = "observers")]
357fn default_poll_interval_ms() -> u64 {
358    100
359}
360
361#[cfg(feature = "observers")]
362fn default_batch_size() -> usize {
363    100
364}
365
366#[cfg(feature = "observers")]
367fn default_channel_capacity() -> usize {
368    1000
369}
370
371#[cfg(feature = "observers")]
372fn default_auto_reload() -> bool {
373    true
374}
375
376#[cfg(feature = "observers")]
377fn default_reload_interval_secs() -> u64 {
378    60
379}
380
381/// Observer runtime configuration.
382#[cfg(feature = "observers")]
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct ObserverConfig {
385    /// Enable observer runtime (default: true).
386    #[serde(default = "default_observers_enabled")]
387    pub enabled: bool,
388
389    /// Poll interval for change log in milliseconds (default: 100).
390    #[serde(default = "default_poll_interval_ms")]
391    pub poll_interval_ms: u64,
392
393    /// Batch size for fetching change log entries (default: 100).
394    #[serde(default = "default_batch_size")]
395    pub batch_size: usize,
396
397    /// Channel capacity for event buffering (default: 1000).
398    #[serde(default = "default_channel_capacity")]
399    pub channel_capacity: usize,
400
401    /// Auto-reload observers on changes (default: true).
402    #[serde(default = "default_auto_reload")]
403    pub auto_reload: bool,
404
405    /// Reload interval in seconds (default: 60).
406    #[serde(default = "default_reload_interval_secs")]
407    pub reload_interval_secs: u64,
408}
409
410impl Default for ServerConfig {
411    fn default() -> Self {
412        Self {
413            schema_path: default_schema_path(),
414            database_url: default_database_url(),
415            bind_addr: default_bind_addr(),
416            cors_enabled: true,
417            cors_origins: Vec::new(),
418            compression_enabled: true,
419            tracing_enabled: true,
420            apq_enabled: true,
421            cache_enabled: true,
422            graphql_path: default_graphql_path(),
423            health_path: default_health_path(),
424            introspection_path: default_introspection_path(),
425            metrics_path: default_metrics_path(),
426            metrics_json_path: default_metrics_json_path(),
427            playground_path: default_playground_path(),
428            playground_enabled: false, // Disabled by default for security
429            playground_tool: PlaygroundTool::default(),
430            subscription_path: default_subscription_path(),
431            subscriptions_enabled: true,
432            metrics_enabled: false, // Disabled by default for security
433            metrics_token: None,
434            admin_api_enabled: false, // Disabled by default for security
435            admin_token: None,
436            introspection_enabled: false, // Disabled by default for security
437            introspection_require_auth: true, // Require auth when enabled
438            design_api_require_auth: true, // Require auth for design endpoints
439            pool_min_size: default_pool_min_size(),
440            pool_max_size: default_pool_max_size(),
441            pool_timeout_secs: default_pool_timeout(),
442            auth: None,          // No auth by default
443            tls: None,           // TLS disabled by default
444            database_tls: None,  // Database TLS disabled by default
445            require_json_content_type: true, // CSRF protection
446            max_request_body_bytes: default_max_request_body_bytes(), // 1 MB
447            rate_limiting: None, // Rate limiting uses defaults
448            #[cfg(feature = "observers")]
449            observers: None, // Observers disabled by default
450        }
451    }
452}
453
454impl ServerConfig {
455    /// Check if running in production mode.
456    ///
457    /// Production mode is detected via `FRAISEQL_ENV` environment variable.
458    /// - `production` or `prod` (or any value other than `development`/`dev`) → production mode
459    /// - `development` or `dev` → development mode
460    #[must_use]
461    pub fn is_production_mode() -> bool {
462        let env = std::env::var("FRAISEQL_ENV")
463            .unwrap_or_else(|_| "production".to_string())
464            .to_lowercase();
465        env != "development" && env != "dev"
466    }
467
468    /// Validate configuration.
469    ///
470    /// # Errors
471    ///
472    /// Returns error if:
473    /// - `metrics_enabled` is true but `metrics_token` is not set
474    /// - `metrics_token` is set but too short (< 16 characters)
475    /// - `auth` config is set but invalid (e.g., empty issuer)
476    /// - `tls` is enabled but cert or key path is missing
477    /// - TLS minimum version is invalid
478    /// - In production mode: `playground_enabled` is true
479    /// - In production mode: `cors_enabled` is true but `cors_origins` is empty
480    pub fn validate(&self) -> Result<(), String> {
481        if self.metrics_enabled {
482            match &self.metrics_token {
483                None => {
484                    return Err("metrics_enabled is true but metrics_token is not set. \
485                         Set FRAISEQL_METRICS_TOKEN or metrics_token in config."
486                        .to_string());
487                },
488                Some(token) if token.len() < 16 => {
489                    return Err(
490                        "metrics_token must be at least 16 characters for security.".to_string()
491                    );
492                },
493                Some(_) => {},
494            }
495        }
496
497        // Admin API validation
498        if self.admin_api_enabled {
499            match &self.admin_token {
500                None => {
501                    return Err("admin_api_enabled is true but admin_token is not set. \
502                         Set FRAISEQL_ADMIN_TOKEN or admin_token in config."
503                        .to_string());
504                },
505                Some(token) if token.len() < 32 => {
506                    return Err(
507                        "admin_token must be at least 32 characters for security.".to_string()
508                    );
509                },
510                Some(_) => {},
511            }
512        }
513
514        // Validate OIDC config if present
515        if let Some(ref auth) = self.auth {
516            auth.validate().map_err(|e| e.to_string())?;
517        }
518
519        // Validate TLS config if present and enabled
520        if let Some(ref tls) = self.tls {
521            if tls.enabled {
522                if !tls.cert_path.exists() {
523                    return Err(format!(
524                        "TLS enabled but certificate file not found: {}",
525                        tls.cert_path.display()
526                    ));
527                }
528                if !tls.key_path.exists() {
529                    return Err(format!(
530                        "TLS enabled but key file not found: {}",
531                        tls.key_path.display()
532                    ));
533                }
534
535                // Validate TLS version
536                if !["1.2", "1.3"].contains(&tls.min_version.as_str()) {
537                    return Err("TLS min_version must be '1.2' or '1.3'".to_string());
538                }
539
540                // Validate mTLS config if required
541                if tls.require_client_cert {
542                    if let Some(ref ca_path) = tls.client_ca_path {
543                        if !ca_path.exists() {
544                            return Err(format!("Client CA file not found: {}", ca_path.display()));
545                        }
546                    } else {
547                        return Err(
548                            "require_client_cert is true but client_ca_path is not set".to_string()
549                        );
550                    }
551                }
552            }
553        }
554
555        // Validate database TLS config if present
556        if let Some(ref db_tls) = self.database_tls {
557            // Validate PostgreSQL SSL mode
558            if ![
559                "disable",
560                "allow",
561                "prefer",
562                "require",
563                "verify-ca",
564                "verify-full",
565            ]
566            .contains(&db_tls.postgres_ssl_mode.as_str())
567            {
568                return Err("Invalid postgres_ssl_mode. Must be one of: \
569                     disable, allow, prefer, require, verify-ca, verify-full"
570                    .to_string());
571            }
572
573            // Validate CA bundle path if provided
574            if let Some(ref ca_path) = db_tls.ca_bundle_path {
575                if !ca_path.exists() {
576                    return Err(format!("CA bundle file not found: {}", ca_path.display()));
577                }
578            }
579        }
580
581        // Production safety validation
582        if Self::is_production_mode() {
583            // Playground should be disabled in production
584            if self.playground_enabled {
585                return Err("playground_enabled is true in production mode. \
586                     Disable the playground or set FRAISEQL_ENV=development. \
587                     The playground exposes sensitive schema information."
588                    .to_string());
589            }
590
591            // CORS origins must be explicitly configured in production
592            if self.cors_enabled && self.cors_origins.is_empty() {
593                return Err("cors_enabled is true but cors_origins is empty in production mode. \
594                     This allows requests from ANY origin, which is a security risk. \
595                     Explicitly configure cors_origins with your allowed domains, \
596                     or disable CORS and set FRAISEQL_ENV=development to bypass this check."
597                    .to_string());
598            }
599        }
600
601        Ok(())
602    }
603
604    /// Check if authentication is enabled.
605    #[must_use]
606    pub fn auth_enabled(&self) -> bool {
607        self.auth.is_some()
608    }
609}
610
611fn default_schema_path() -> PathBuf {
612    PathBuf::from("schema.compiled.json")
613}
614
615fn default_database_url() -> String {
616    "postgresql://localhost/fraiseql".to_string()
617}
618
619fn default_bind_addr() -> SocketAddr {
620    "127.0.0.1:8000".parse().unwrap()
621}
622
623fn default_true() -> bool {
624    true
625}
626
627/// 1 MB default body limit.
628fn default_max_request_body_bytes() -> usize {
629    1_048_576
630}
631
632fn default_graphql_path() -> String {
633    "/graphql".to_string()
634}
635
636fn default_health_path() -> String {
637    "/health".to_string()
638}
639
640fn default_introspection_path() -> String {
641    "/introspection".to_string()
642}
643
644fn default_metrics_path() -> String {
645    "/metrics".to_string()
646}
647
648fn default_metrics_json_path() -> String {
649    "/metrics/json".to_string()
650}
651
652fn default_playground_path() -> String {
653    "/playground".to_string()
654}
655
656fn default_subscription_path() -> String {
657    "/ws".to_string()
658}
659
660fn default_pool_min_size() -> usize {
661    5
662}
663
664fn default_pool_max_size() -> usize {
665    20
666}
667
668fn default_pool_timeout() -> u64 {
669    30
670}
671
672fn default_tls_min_version() -> String {
673    "1.2".to_string()
674}
675
676fn default_postgres_ssl_mode() -> String {
677    "prefer".to_string()
678}
679
680fn default_redis_ssl() -> bool {
681    false
682}
683
684fn default_clickhouse_https() -> bool {
685    false
686}
687
688fn default_elasticsearch_https() -> bool {
689    false
690}
691
692fn default_verify_certs() -> bool {
693    true
694}
695
696fn default_rate_limiting_enabled() -> bool {
697    true
698}
699
700fn default_rate_limit_rps_per_ip() -> u32 {
701    100
702}
703
704fn default_rate_limit_rps_per_user() -> u32 {
705    1000
706}
707
708fn default_rate_limit_burst_size() -> u32 {
709    500
710}
711
712fn default_rate_limit_cleanup_interval() -> u64 {
713    300
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    #[test]
721    fn test_default_config() {
722        let config = ServerConfig::default();
723        assert_eq!(config.schema_path, PathBuf::from("schema.compiled.json"));
724        assert_eq!(config.database_url, "postgresql://localhost/fraiseql");
725        assert_eq!(config.graphql_path, "/graphql");
726        assert_eq!(config.health_path, "/health");
727        assert_eq!(config.metrics_path, "/metrics");
728        assert_eq!(config.metrics_json_path, "/metrics/json");
729        assert!(config.cors_enabled);
730        assert!(config.compression_enabled);
731    }
732
733    #[test]
734    fn test_default_config_metrics_disabled() {
735        let config = ServerConfig::default();
736        assert!(!config.metrics_enabled, "Metrics should be disabled by default for security");
737        assert!(config.metrics_token.is_none());
738    }
739
740    #[test]
741    fn test_config_with_custom_database_url() {
742        let config = ServerConfig {
743            database_url: "postgresql://user:pass@db.example.com/mydb".to_string(),
744            ..ServerConfig::default()
745        };
746        assert_eq!(config.database_url, "postgresql://user:pass@db.example.com/mydb");
747    }
748
749    #[test]
750    fn test_default_pool_config() {
751        let config = ServerConfig::default();
752        assert_eq!(config.pool_min_size, 5);
753        assert_eq!(config.pool_max_size, 20);
754        assert_eq!(config.pool_timeout_secs, 30);
755    }
756
757    #[test]
758    fn test_config_with_custom_pool_size() {
759        let config = ServerConfig {
760            pool_min_size: 2,
761            pool_max_size: 50,
762            pool_timeout_secs: 60,
763            ..ServerConfig::default()
764        };
765        assert_eq!(config.pool_min_size, 2);
766        assert_eq!(config.pool_max_size, 50);
767        assert_eq!(config.pool_timeout_secs, 60);
768    }
769
770    #[test]
771    fn test_validate_metrics_disabled_ok() {
772        let config = ServerConfig {
773            cors_enabled: false,
774            ..ServerConfig::default()
775        };
776        assert!(config.validate().is_ok());
777    }
778
779    #[test]
780    fn test_validate_metrics_enabled_without_token_fails() {
781        let config = ServerConfig {
782            metrics_enabled: true,
783            metrics_token: None,
784            ..ServerConfig::default()
785        };
786        let result = config.validate();
787        assert!(result.is_err());
788        assert!(result.unwrap_err().contains("metrics_token is not set"));
789    }
790
791    #[test]
792    fn test_validate_metrics_enabled_with_short_token_fails() {
793        let config = ServerConfig {
794            metrics_enabled: true,
795            metrics_token: Some("short".to_string()), // < 16 chars
796            ..ServerConfig::default()
797        };
798        let result = config.validate();
799        assert!(result.is_err());
800        assert!(result.unwrap_err().contains("at least 16 characters"));
801    }
802
803    #[test]
804    fn test_validate_metrics_enabled_with_valid_token_ok() {
805        let config = ServerConfig {
806            metrics_enabled: true,
807            metrics_token: Some("a-secure-token-that-is-long-enough".to_string()),
808            cors_enabled: false,
809            ..ServerConfig::default()
810        };
811        assert!(config.validate().is_ok());
812    }
813
814    #[test]
815    fn test_default_subscription_config() {
816        let config = ServerConfig::default();
817        assert_eq!(config.subscription_path, "/ws");
818        assert!(config.subscriptions_enabled);
819    }
820
821    #[test]
822    fn test_subscription_config_with_custom_path() {
823        let config = ServerConfig {
824            subscription_path: "/subscriptions".to_string(),
825            ..ServerConfig::default()
826        };
827        assert_eq!(config.subscription_path, "/subscriptions");
828        assert!(config.subscriptions_enabled);
829    }
830
831    #[test]
832    fn test_subscriptions_can_be_disabled() {
833        let config = ServerConfig {
834            subscriptions_enabled: false,
835            ..ServerConfig::default()
836        };
837        assert!(!config.subscriptions_enabled);
838        assert_eq!(config.subscription_path, "/ws");
839    }
840
841    #[test]
842    fn test_subscription_path_serialization() {
843        let config = ServerConfig::default();
844        let json = serde_json::to_string(&config).expect(
845            "ServerConfig derives Serialize with serializable fields; serialization is infallible",
846        );
847        let restored: ServerConfig = serde_json::from_str(&json).expect(
848            "ServerConfig roundtrip: deserialization of just-serialized data is infallible",
849        );
850
851        assert_eq!(restored.subscription_path, config.subscription_path);
852        assert_eq!(restored.subscriptions_enabled, config.subscriptions_enabled);
853    }
854
855    #[test]
856    fn test_subscription_config_with_partial_toml() {
857        let toml_str = r#"
858            subscription_path = "/graphql-ws"
859            subscriptions_enabled = false
860        "#;
861
862        let decoded: ServerConfig = toml::from_str(toml_str).expect(
863            "TOML config parsing: valid TOML syntax with expected fields deserializes correctly",
864        );
865        assert_eq!(decoded.subscription_path, "/graphql-ws");
866        assert!(!decoded.subscriptions_enabled);
867    }
868
869    #[test]
870    fn test_tls_config_defaults() {
871        let config = ServerConfig::default();
872        assert!(config.tls.is_none());
873        assert!(config.database_tls.is_none());
874    }
875
876    #[test]
877    fn test_database_tls_config_defaults() {
878        let db_tls = DatabaseTlsConfig {
879            postgres_ssl_mode:   "prefer".to_string(),
880            redis_ssl:           false,
881            clickhouse_https:    false,
882            elasticsearch_https: false,
883            verify_certificates: true,
884            ca_bundle_path:      None,
885        };
886
887        assert_eq!(db_tls.postgres_ssl_mode, "prefer");
888        assert!(!db_tls.redis_ssl);
889        assert!(!db_tls.clickhouse_https);
890        assert!(!db_tls.elasticsearch_https);
891        assert!(db_tls.verify_certificates);
892    }
893
894    #[test]
895    fn test_tls_server_config_fields() {
896        let tls = TlsServerConfig {
897            enabled:             true,
898            cert_path:           PathBuf::from("/etc/fraiseql/cert.pem"),
899            key_path:            PathBuf::from("/etc/fraiseql/key.pem"),
900            require_client_cert: false,
901            client_ca_path:      None,
902            min_version:         "1.3".to_string(),
903        };
904
905        assert!(tls.enabled);
906        assert_eq!(tls.cert_path, PathBuf::from("/etc/fraiseql/cert.pem"));
907        assert_eq!(tls.key_path, PathBuf::from("/etc/fraiseql/key.pem"));
908        assert!(!tls.require_client_cert);
909        assert_eq!(tls.min_version, "1.3");
910    }
911
912    #[test]
913    fn test_validate_tls_enabled_without_cert() {
914        let config = ServerConfig {
915            tls: Some(TlsServerConfig {
916                enabled:             true,
917                cert_path:           PathBuf::from("/nonexistent/cert.pem"),
918                key_path:            PathBuf::from("/etc/fraiseql/key.pem"),
919                require_client_cert: false,
920                client_ca_path:      None,
921                min_version:         "1.2".to_string(),
922            }),
923            ..ServerConfig::default()
924        };
925
926        let result = config.validate();
927        assert!(result.is_err());
928        assert!(result.unwrap_err().contains("certificate file not found"));
929    }
930
931    #[test]
932    fn test_validate_tls_invalid_min_version() {
933        // Create temp cert and key files that exist
934        let cert_path = PathBuf::from("/tmp/test_cert.pem");
935        let key_path = PathBuf::from("/tmp/test_key.pem");
936        std::fs::write(&cert_path, "test").ok();
937        std::fs::write(&key_path, "test").ok();
938
939        let config = ServerConfig {
940            tls: Some(TlsServerConfig {
941                enabled: true,
942                cert_path,
943                key_path,
944                require_client_cert: false,
945                client_ca_path: None,
946                min_version: "1.1".to_string(),
947            }),
948            ..ServerConfig::default()
949        };
950
951        let result = config.validate();
952        assert!(result.is_err());
953        assert!(result.unwrap_err().contains("min_version must be"));
954    }
955
956    #[test]
957    fn test_validate_database_tls_invalid_postgres_ssl_mode() {
958        let config = ServerConfig {
959            database_tls: Some(DatabaseTlsConfig {
960                postgres_ssl_mode:   "invalid_mode".to_string(),
961                redis_ssl:           false,
962                clickhouse_https:    false,
963                elasticsearch_https: false,
964                verify_certificates: true,
965                ca_bundle_path:      None,
966            }),
967            ..ServerConfig::default()
968        };
969
970        let result = config.validate();
971        assert!(result.is_err());
972        assert!(result.unwrap_err().contains("Invalid postgres_ssl_mode"));
973    }
974
975    #[test]
976    fn test_validate_tls_requires_client_ca() {
977        // Create temp cert and key files that exist
978        let cert_path = PathBuf::from("/tmp/test_cert2.pem");
979        let key_path = PathBuf::from("/tmp/test_key2.pem");
980        std::fs::write(&cert_path, "test").ok();
981        std::fs::write(&key_path, "test").ok();
982
983        let config = ServerConfig {
984            tls: Some(TlsServerConfig {
985                enabled: true,
986                cert_path,
987                key_path,
988                require_client_cert: true,
989                client_ca_path: None,
990                min_version: "1.3".to_string(),
991            }),
992            ..ServerConfig::default()
993        };
994
995        let result = config.validate();
996        assert!(result.is_err());
997        assert!(result.unwrap_err().contains("client_ca_path is not set"));
998    }
999
1000    #[test]
1001    fn test_database_tls_serialization() {
1002        let db_tls = DatabaseTlsConfig {
1003            postgres_ssl_mode:   "require".to_string(),
1004            redis_ssl:           true,
1005            clickhouse_https:    true,
1006            elasticsearch_https: true,
1007            verify_certificates: true,
1008            ca_bundle_path:      Some(PathBuf::from("/etc/ssl/certs/ca-bundle.crt")),
1009        };
1010
1011        let json = serde_json::to_string(&db_tls)
1012            .expect("DatabaseTlsConfig derives Serialize with serializable fields; serialization is infallible");
1013        let restored: DatabaseTlsConfig = serde_json::from_str(&json).expect(
1014            "DatabaseTlsConfig roundtrip: deserialization of just-serialized data is infallible",
1015        );
1016
1017        assert_eq!(restored.postgres_ssl_mode, db_tls.postgres_ssl_mode);
1018        assert_eq!(restored.redis_ssl, db_tls.redis_ssl);
1019        assert_eq!(restored.clickhouse_https, db_tls.clickhouse_https);
1020        assert_eq!(restored.elasticsearch_https, db_tls.elasticsearch_https);
1021        assert_eq!(restored.ca_bundle_path, db_tls.ca_bundle_path);
1022    }
1023
1024    #[test]
1025    fn test_admin_api_disabled_by_default() {
1026        let config = ServerConfig::default();
1027        assert!(
1028            !config.admin_api_enabled,
1029            "Admin API should be disabled by default for security"
1030        );
1031        assert!(config.admin_token.is_none());
1032    }
1033
1034    #[test]
1035    fn test_validate_admin_api_enabled_without_token_fails() {
1036        let config = ServerConfig {
1037            admin_api_enabled: true,
1038            admin_token: None,
1039            ..ServerConfig::default()
1040        };
1041        let result = config.validate();
1042        assert!(result.is_err());
1043        assert!(result.unwrap_err().contains("admin_token is not set"));
1044    }
1045
1046    #[test]
1047    fn test_validate_admin_api_enabled_with_short_token_fails() {
1048        let config = ServerConfig {
1049            admin_api_enabled: true,
1050            admin_token: Some("short".to_string()), // < 32 chars
1051            ..ServerConfig::default()
1052        };
1053        let result = config.validate();
1054        assert!(result.is_err());
1055        assert!(result.unwrap_err().contains("at least 32 characters"));
1056    }
1057
1058    #[test]
1059    fn test_validate_admin_api_enabled_with_valid_token_ok() {
1060        let config = ServerConfig {
1061            admin_api_enabled: true,
1062            admin_token: Some("a-very-secure-admin-token-that-is-long-enough".to_string()),
1063            cors_enabled: false,
1064            ..ServerConfig::default()
1065        };
1066        assert!(config.validate().is_ok());
1067    }
1068}