Skip to main content

velesdb_server/
config.rs

1//! Server configuration module.
2//!
3//! Loads configuration from multiple sources with priority:
4//! CLI flags > environment variables > velesdb.toml > defaults.
5
6use serde::Deserialize;
7use std::path::{Path, PathBuf};
8
9// ============================================================================
10// TOML file configuration (all fields optional)
11// ============================================================================
12
13/// Root structure for `velesdb.toml`.
14#[derive(Debug, Deserialize, Default)]
15struct FileConfig {
16    server: Option<ServerSection>,
17    auth: Option<AuthSection>,
18    tls: Option<TlsSection>,
19    cors: Option<CorsSection>,
20}
21
22#[derive(Debug, Deserialize, Default)]
23struct ServerSection {
24    host: Option<String>,
25    port: Option<u16>,
26    data_dir: Option<String>,
27    shutdown_timeout_secs: Option<u64>,
28    rate_limit: Option<u32>,
29}
30
31#[derive(Debug, Deserialize, Default)]
32struct AuthSection {
33    api_keys: Option<Vec<String>>,
34}
35
36#[derive(Debug, Deserialize, Default)]
37struct TlsSection {
38    cert: Option<String>,
39    key: Option<String>,
40}
41
42#[derive(Debug, Deserialize, Default)]
43struct CorsSection {
44    allowed_origins: Option<Vec<String>>,
45    allowed_methods: Option<Vec<String>>,
46    allowed_headers: Option<Vec<String>>,
47    allow_credentials: Option<bool>,
48    max_age_secs: Option<u64>,
49}
50
51// ============================================================================
52// Resolved configuration
53// ============================================================================
54
55/// TLS certificate and key paths.
56///
57/// Both fields must be `Some` together or both `None`; a partial pair is
58/// rejected by [`ServerConfig::validate`].
59#[derive(Debug, Clone, PartialEq, Default)]
60pub struct TlsConfig {
61    /// Path to the PEM-encoded TLS certificate file.
62    pub cert: Option<String>,
63    /// Path to the PEM-encoded TLS private key file.
64    pub key: Option<String>,
65}
66
67impl TlsConfig {
68    /// Returns `true` when both cert and key are configured.
69    pub fn is_enabled(&self) -> bool {
70        self.cert.is_some() && self.key.is_some()
71    }
72}
73
74/// Final resolved server configuration.
75#[derive(Debug, Clone, PartialEq)]
76pub struct ServerConfig {
77    pub host: String,
78    pub port: u16,
79    pub data_dir: String,
80    pub api_keys: Vec<String>,
81    /// TLS certificate and key configuration (both or neither).
82    pub tls: TlsConfig,
83    pub shutdown_timeout_secs: u64,
84    /// Maximum requests per second per IP address (0 = disabled).
85    pub rate_limit: u32,
86    /// CORS configuration for cross-origin requests.
87    pub cors: CorsConfig,
88}
89
90/// CORS configuration for the server.
91///
92/// When `allowed_origins` contains `"*"`, the server uses a fully permissive
93/// CORS policy (equivalent to `CorsLayer::permissive()`). Otherwise, only the
94/// listed origins are allowed.
95///
96/// Defaults to permissive (`["*"]`) for backward compatibility.
97#[derive(Debug, Clone, PartialEq)]
98pub struct CorsConfig {
99    /// Allowed origins. Use `["*"]` for permissive mode.
100    pub allowed_origins: Vec<String>,
101    /// Allowed HTTP methods (e.g. `["GET", "POST"]`).
102    pub allowed_methods: Vec<String>,
103    /// Allowed request headers (e.g. `["Content-Type", "Authorization"]`).
104    /// Use `["*"]` to allow any header.
105    pub allowed_headers: Vec<String>,
106    /// Whether to allow credentials (cookies, authorization headers).
107    pub allow_credentials: bool,
108    /// How long (in seconds) browsers may cache preflight responses.
109    pub max_age_secs: u64,
110}
111
112/// Default burst budget for rate limiting (requests per second per IP).
113const DEFAULT_RATE_LIMIT: u32 = 100;
114
115/// Default preflight cache duration in seconds (1 hour).
116const DEFAULT_CORS_MAX_AGE_SECS: u64 = 3600;
117
118impl Default for CorsConfig {
119    fn default() -> Self {
120        Self {
121            allowed_origins: vec!["*".to_string()],
122            allowed_methods: vec![
123                "GET".to_string(),
124                "POST".to_string(),
125                "PUT".to_string(),
126                "DELETE".to_string(),
127                "PATCH".to_string(),
128                "OPTIONS".to_string(),
129            ],
130            allowed_headers: vec!["*".to_string()],
131            allow_credentials: false,
132            max_age_secs: DEFAULT_CORS_MAX_AGE_SECS,
133        }
134    }
135}
136
137impl CorsConfig {
138    /// Returns `true` when CORS is in fully permissive mode (any origin).
139    pub fn is_permissive(&self) -> bool {
140        self.allowed_origins.iter().any(|o| o == "*")
141    }
142}
143
144impl Default for ServerConfig {
145    fn default() -> Self {
146        Self {
147            host: "127.0.0.1".to_string(),
148            port: 8080,
149            data_dir: "./velesdb_data".to_string(),
150            api_keys: Vec::new(),
151            tls: TlsConfig::default(),
152            shutdown_timeout_secs: 30,
153            rate_limit: DEFAULT_RATE_LIMIT,
154            cors: CorsConfig::default(),
155        }
156    }
157}
158
159// ============================================================================
160// Loading logic
161// ============================================================================
162
163impl ServerConfig {
164    /// Load configuration with priority: CLI > env > TOML file > defaults.
165    ///
166    /// `cli` contains values from clap (which merges CLI flags + env vars).
167    /// `cli_sources` indicates which fields were explicitly set via CLI/env
168    /// (as opposed to falling back to clap defaults).
169    pub fn load(cli: CliOverrides) -> anyhow::Result<Self> {
170        let defaults = Self::default();
171        let file_cfg = load_toml_file(&cli.config_path)?;
172        Ok(Self::merge(defaults, file_cfg, cli))
173    }
174
175    fn merge(defaults: Self, file: FileConfig, cli: CliOverrides) -> Self {
176        let server = file.server.unwrap_or_default();
177        let auth = file.auth.unwrap_or_default();
178        let tls = file.tls.unwrap_or_default();
179        let cors_section = file.cors.unwrap_or_default();
180
181        // Layer: TOML over defaults
182        let host = server.host.unwrap_or(defaults.host);
183        let port = server.port.unwrap_or(defaults.port);
184        let data_dir = server.data_dir.unwrap_or(defaults.data_dir);
185        let shutdown_timeout_secs = server
186            .shutdown_timeout_secs
187            .unwrap_or(defaults.shutdown_timeout_secs);
188        let rate_limit = server.rate_limit.unwrap_or(defaults.rate_limit);
189        let api_keys = auth.api_keys.unwrap_or(defaults.api_keys);
190        let tls = TlsConfig {
191            cert: tls.cert.or(defaults.tls.cert),
192            key: tls.key.or(defaults.tls.key),
193        };
194        let cors = resolve_cors(defaults.cors, cors_section);
195
196        // Layer: CLI/env over TOML (only override when explicitly set)
197        let host = cli.host.unwrap_or(host);
198        let port = cli.port.unwrap_or(port);
199        let data_dir = cli.data_dir.unwrap_or(data_dir);
200        let api_keys = cli.api_keys.unwrap_or(api_keys);
201        let tls = TlsConfig {
202            cert: cli.tls_cert.or(tls.cert),
203            key: cli.tls_key.or(tls.key),
204        };
205        let rate_limit = cli.rate_limit.unwrap_or(rate_limit);
206
207        Self {
208            host,
209            port,
210            data_dir,
211            api_keys,
212            tls,
213            shutdown_timeout_secs,
214            rate_limit,
215            cors,
216        }
217    }
218
219    /// Validate the configuration at startup.
220    pub fn validate(&self) -> anyhow::Result<()> {
221        if self.port == 0 {
222            anyhow::bail!("invalid port: 0 is not allowed");
223        }
224        if self.data_dir.is_empty() {
225            anyhow::bail!("data_dir must not be empty");
226        }
227
228        // TLS: both cert and key must be provided together
229        match (&self.tls.cert, &self.tls.key) {
230            (Some(_), None) => {
231                anyhow::bail!("tls_cert is set but tls_key is missing");
232            }
233            (None, Some(_)) => {
234                anyhow::bail!("tls_key is set but tls_cert is missing");
235            }
236            (Some(cert), Some(key)) => {
237                if !Path::new(cert).exists() {
238                    anyhow::bail!("TLS cert file not found: {cert}");
239                }
240                if !Path::new(key).exists() {
241                    anyhow::bail!("TLS key file not found: {key}");
242                }
243            }
244            (None, None) => {}
245        }
246
247        Ok(())
248    }
249
250    /// Returns `true` when API key authentication is enabled.
251    pub fn auth_enabled(&self) -> bool {
252        !self.api_keys.is_empty()
253    }
254
255    /// Returns `true` when TLS is configured.
256    pub fn tls_enabled(&self) -> bool {
257        self.tls.is_enabled()
258    }
259
260    /// Returns `true` when rate limiting is enabled (rate_limit > 0).
261    pub fn rate_limit_enabled(&self) -> bool {
262        self.rate_limit > 0
263    }
264}
265
266// ============================================================================
267// CLI overrides (filled by clap in main.rs)
268// ============================================================================
269
270/// Values explicitly provided via CLI flags or environment variables.
271/// `None` means "not provided — fall through to TOML or default".
272#[derive(Debug, Default)]
273pub struct CliOverrides {
274    pub config_path: Option<PathBuf>,
275    pub host: Option<String>,
276    pub port: Option<u16>,
277    pub data_dir: Option<String>,
278    pub api_keys: Option<Vec<String>>,
279    pub tls_cert: Option<String>,
280    pub tls_key: Option<String>,
281    pub rate_limit: Option<u32>,
282}
283
284// ============================================================================
285// TOML file loader
286// ============================================================================
287
288fn load_toml_file(path: &Option<PathBuf>) -> anyhow::Result<FileConfig> {
289    let candidate = match path {
290        Some(p) => {
291            if !p.exists() {
292                anyhow::bail!("config file not found: {}", p.display());
293            }
294            p.clone()
295        }
296        None => {
297            let default_path = PathBuf::from("velesdb.toml");
298            if !default_path.exists() {
299                return Ok(FileConfig::default());
300            }
301            default_path
302        }
303    };
304
305    let contents = std::fs::read_to_string(&candidate)
306        .map_err(|e| anyhow::anyhow!("failed to read config file {}: {e}", candidate.display()))?;
307
308    let cfg: FileConfig = toml::from_str(&contents)
309        .map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", candidate.display()))?;
310
311    Ok(cfg)
312}
313
314// ============================================================================
315// CORS resolution & layer builder
316// ============================================================================
317
318/// Merges a `CorsSection` (from TOML) over `CorsConfig` defaults.
319fn resolve_cors(defaults: CorsConfig, section: CorsSection) -> CorsConfig {
320    CorsConfig {
321        allowed_origins: section.allowed_origins.unwrap_or(defaults.allowed_origins),
322        allowed_methods: section.allowed_methods.unwrap_or(defaults.allowed_methods),
323        allowed_headers: section.allowed_headers.unwrap_or(defaults.allowed_headers),
324        allow_credentials: section
325            .allow_credentials
326            .unwrap_or(defaults.allow_credentials),
327        max_age_secs: section.max_age_secs.unwrap_or(defaults.max_age_secs),
328    }
329}
330
331/// Builds a [`tower_http::cors::CorsLayer`] from the resolved CORS config.
332///
333/// When `allowed_origins` contains `"*"`, returns `CorsLayer::permissive()`
334/// for full backward compatibility. Otherwise, constructs a restrictive
335/// layer with the specified origins, methods, and headers.
336pub fn build_cors_layer(cors: &CorsConfig) -> tower_http::cors::CorsLayer {
337    use tower_http::cors::{AllowOrigin, CorsLayer};
338
339    if cors.is_permissive() {
340        return CorsLayer::permissive();
341    }
342
343    let origins: Vec<axum::http::HeaderValue> = cors
344        .allowed_origins
345        .iter()
346        .filter_map(|o| o.parse().ok())
347        .collect();
348    let methods: Vec<axum::http::Method> = cors
349        .allowed_methods
350        .iter()
351        .filter_map(|m| m.parse().ok())
352        .collect();
353
354    let layer = CorsLayer::new()
355        .allow_origin(AllowOrigin::list(origins))
356        .allow_methods(methods)
357        .max_age(std::time::Duration::from_secs(cors.max_age_secs));
358
359    let layer = apply_cors_headers_policy(layer, cors);
360
361    if cors.allow_credentials {
362        layer.allow_credentials(true)
363    } else {
364        layer
365    }
366}
367
368/// Applies the headers policy to a `CorsLayer`, honouring the CORS spec rule that
369/// `allow_credentials=true` is incompatible with wildcard headers (browsers reject
370/// the preflight). Logs a warning and falls back to default headers in that case.
371fn apply_cors_headers_policy(
372    layer: tower_http::cors::CorsLayer,
373    cors: &CorsConfig,
374) -> tower_http::cors::CorsLayer {
375    use tower_http::cors::Any;
376
377    let has_wildcard = cors.allowed_headers.iter().any(|h| h == "*");
378    if has_wildcard && !cors.allow_credentials {
379        return layer.allow_headers(Any);
380    }
381    if has_wildcard && cors.allow_credentials {
382        tracing::warn!(
383            "CORS: allow_credentials=true is incompatible with wildcard \
384             headers per CORS spec. Falling back to default headers \
385             (Content-Type, Authorization)."
386        );
387    }
388    let headers: Vec<axum::http::HeaderName> = cors
389        .allowed_headers
390        .iter()
391        .filter(|h| h.as_str() != "*")
392        .filter_map(|h| h.parse().ok())
393        .collect();
394    if headers.is_empty() && cors.allow_credentials {
395        layer.allow_headers([
396            axum::http::header::CONTENT_TYPE,
397            axum::http::header::AUTHORIZATION,
398        ])
399    } else {
400        layer.allow_headers(headers)
401    }
402}
403
404// ============================================================================
405// Helper: parse comma-separated API keys from env var
406// ============================================================================
407
408/// Parse `VELESDB_API_KEYS` env var (comma-separated) into a `Vec<String>`.
409pub fn parse_api_keys_env() -> Option<Vec<String>> {
410    let val = std::env::var("VELESDB_API_KEYS").ok()?;
411    let keys: Vec<String> = val
412        .split(',')
413        .map(|s| s.trim().to_string())
414        .filter(|s| !s.is_empty())
415        .collect();
416    if keys.is_empty() {
417        None
418    } else {
419        Some(keys)
420    }
421}
422
423// ============================================================================
424// Tests
425// ============================================================================
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use std::io::Write;
431
432    #[test]
433    fn test_defaults() {
434        let cfg = ServerConfig::default();
435        assert_eq!(cfg.host, "127.0.0.1");
436        assert_eq!(cfg.port, 8080);
437        assert_eq!(cfg.data_dir, "./velesdb_data");
438        assert!(cfg.api_keys.is_empty());
439        assert!(cfg.tls.cert.is_none());
440        assert!(cfg.tls.key.is_none());
441        assert_eq!(cfg.shutdown_timeout_secs, 30);
442        assert_eq!(cfg.rate_limit, 100);
443        assert!(!cfg.auth_enabled());
444        assert!(!cfg.tls_enabled());
445        assert!(cfg.rate_limit_enabled());
446        assert!(cfg.cors.is_permissive());
447    }
448
449    #[test]
450    fn test_toml_overrides_defaults() {
451        let toml_content = r#"
452[server]
453host = "0.0.0.0"
454port = 9090
455data_dir = "/var/velesdb"
456shutdown_timeout_secs = 60
457
458[auth]
459api_keys = ["key-alpha", "key-beta"]
460
461[tls]
462cert = "/etc/ssl/cert.pem"
463key = "/etc/ssl/key.pem"
464"#;
465        let file_cfg: FileConfig =
466            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
467        let cli = CliOverrides::default();
468        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
469
470        assert_eq!(cfg.host, "0.0.0.0");
471        assert_eq!(cfg.port, 9090);
472        assert_eq!(cfg.data_dir, "/var/velesdb");
473        assert_eq!(cfg.shutdown_timeout_secs, 60);
474        assert_eq!(cfg.api_keys, vec!["key-alpha", "key-beta"]);
475        assert_eq!(cfg.tls.cert.as_deref(), Some("/etc/ssl/cert.pem"));
476        assert_eq!(cfg.tls.key.as_deref(), Some("/etc/ssl/key.pem"));
477        assert!(cfg.auth_enabled());
478        assert!(cfg.tls_enabled());
479    }
480
481    #[test]
482    fn test_cli_overrides_toml() {
483        let toml_content = r#"
484[server]
485host = "0.0.0.0"
486port = 9090
487"#;
488        let file_cfg: FileConfig =
489            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
490        let cli = CliOverrides {
491            port: Some(3000),
492            host: Some("10.0.0.1".to_string()),
493            ..Default::default()
494        };
495        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
496
497        // CLI wins over TOML
498        assert_eq!(cfg.host, "10.0.0.1");
499        assert_eq!(cfg.port, 3000);
500        // TOML didn't set data_dir, so default applies
501        assert_eq!(cfg.data_dir, "./velesdb_data");
502    }
503
504    #[test]
505    fn test_partial_toml_uses_defaults_for_missing() {
506        let toml_content = r#"
507[server]
508port = 4000
509"#;
510        let file_cfg: FileConfig =
511            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
512        let cli = CliOverrides::default();
513        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
514
515        assert_eq!(cfg.port, 4000);
516        assert_eq!(cfg.host, "127.0.0.1"); // default
517        assert_eq!(cfg.data_dir, "./velesdb_data"); // default
518    }
519
520    #[test]
521    fn test_empty_toml_uses_all_defaults() {
522        let file_cfg: FileConfig = toml::from_str("").expect("test: empty TOML parses to default");
523        let cli = CliOverrides::default();
524        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
525
526        assert_eq!(cfg, ServerConfig::default());
527    }
528
529    #[test]
530    fn test_validate_port_zero_rejected() {
531        let cfg = ServerConfig {
532            port: 0,
533            ..ServerConfig::default()
534        };
535        let err = cfg.validate().unwrap_err();
536        assert!(err.to_string().contains("port"));
537    }
538
539    #[test]
540    fn test_validate_empty_data_dir_rejected() {
541        let cfg = ServerConfig {
542            data_dir: String::new(),
543            ..ServerConfig::default()
544        };
545        let err = cfg.validate().unwrap_err();
546        assert!(err.to_string().contains("data_dir"));
547    }
548
549    #[test]
550    fn test_validate_tls_cert_without_key() {
551        let cfg = ServerConfig {
552            tls: TlsConfig {
553                cert: Some("/tmp/cert.pem".to_string()),
554                key: None,
555            },
556            ..ServerConfig::default()
557        };
558        let err = cfg.validate().unwrap_err();
559        assert!(err.to_string().contains("tls_key is missing"));
560    }
561
562    #[test]
563    fn test_validate_tls_key_without_cert() {
564        let cfg = ServerConfig {
565            tls: TlsConfig {
566                cert: None,
567                key: Some("/tmp/key.pem".to_string()),
568            },
569            ..ServerConfig::default()
570        };
571        let err = cfg.validate().unwrap_err();
572        assert!(err.to_string().contains("tls_cert is missing"));
573    }
574
575    #[test]
576    fn test_validate_tls_missing_cert_file() {
577        let cfg = ServerConfig {
578            tls: TlsConfig {
579                cert: Some("/nonexistent/cert.pem".to_string()),
580                key: Some("/nonexistent/key.pem".to_string()),
581            },
582            ..ServerConfig::default()
583        };
584        let err = cfg.validate().unwrap_err();
585        assert!(err.to_string().contains("cert file not found"));
586    }
587
588    #[test]
589    fn test_validate_tls_valid_files() {
590        let dir = tempfile::tempdir().expect("test: create temp dir");
591        let cert_path = dir.path().join("cert.pem");
592        let key_path = dir.path().join("key.pem");
593        std::fs::File::create(&cert_path)
594            .expect("test: create cert file")
595            .write_all(b"cert")
596            .expect("test: write cert content");
597        std::fs::File::create(&key_path)
598            .expect("test: create key file")
599            .write_all(b"key")
600            .expect("test: write key content");
601
602        let cfg = ServerConfig {
603            tls: TlsConfig {
604                cert: Some(cert_path.to_string_lossy().to_string()),
605                key: Some(key_path.to_string_lossy().to_string()),
606            },
607            ..ServerConfig::default()
608        };
609        cfg.validate().expect("valid TLS config should pass");
610    }
611
612    #[test]
613    fn test_parse_api_keys_env() {
614        // Simulate by directly testing the parsing logic
615        let input = "key1, key2 , key3";
616        let keys: Vec<String> = input
617            .split(',')
618            .map(|s| s.trim().to_string())
619            .filter(|s| !s.is_empty())
620            .collect();
621        assert_eq!(keys, vec!["key1", "key2", "key3"]);
622    }
623
624    #[test]
625    fn test_load_toml_file_not_found_explicit_path() {
626        let result = load_toml_file(&Some(PathBuf::from("/nonexistent/velesdb.toml")));
627        assert!(result.is_err());
628        assert!(result
629            .unwrap_err()
630            .to_string()
631            .contains("config file not found"));
632    }
633
634    #[test]
635    fn test_load_toml_file_no_default_returns_empty() {
636        // When no explicit path and no velesdb.toml in cwd, returns defaults
637        let result = load_toml_file(&None);
638        assert!(result.is_ok());
639    }
640
641    #[test]
642    fn test_full_priority_chain() {
643        // Scenario: default=8080, TOML=9090, CLI=3000 → expect 3000
644        let toml_content = r#"
645[server]
646port = 9090
647host = "0.0.0.0"
648data_dir = "/toml/data"
649"#;
650        let file_cfg: FileConfig =
651            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
652        let cli = CliOverrides {
653            port: Some(3000),
654            // host not set in CLI → TOML should win
655            ..Default::default()
656        };
657        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
658
659        assert_eq!(cfg.port, 3000); // CLI wins
660        assert_eq!(cfg.host, "0.0.0.0"); // TOML wins (no CLI override)
661        assert_eq!(cfg.data_dir, "/toml/data"); // TOML wins (no CLI override)
662    }
663
664    #[test]
665    fn test_rate_limit_from_toml() {
666        let toml_content = r#"
667[server]
668rate_limit = 50
669"#;
670        let file_cfg: FileConfig =
671            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
672        let cli = CliOverrides::default();
673        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
674
675        assert_eq!(cfg.rate_limit, 50);
676        assert!(cfg.rate_limit_enabled());
677    }
678
679    #[test]
680    fn test_rate_limit_disabled_via_toml() {
681        let toml_content = r#"
682[server]
683rate_limit = 0
684"#;
685        let file_cfg: FileConfig =
686            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
687        let cli = CliOverrides::default();
688        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
689
690        assert_eq!(cfg.rate_limit, 0);
691        assert!(!cfg.rate_limit_enabled());
692    }
693
694    #[test]
695    fn test_rate_limit_cli_overrides_toml() {
696        let toml_content = r#"
697[server]
698rate_limit = 50
699"#;
700        let file_cfg: FileConfig =
701            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
702        let cli = CliOverrides {
703            rate_limit: Some(200),
704            ..Default::default()
705        };
706        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
707
708        assert_eq!(cfg.rate_limit, 200);
709    }
710
711    #[test]
712    fn test_rate_limit_cli_disables() {
713        let file_cfg = FileConfig::default();
714        let cli = CliOverrides {
715            rate_limit: Some(0),
716            ..Default::default()
717        };
718        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
719
720        assert_eq!(cfg.rate_limit, 0);
721        assert!(!cfg.rate_limit_enabled());
722    }
723
724    // ====================================================================
725    // CORS configuration tests
726    // ====================================================================
727
728    #[test]
729    fn test_cors_default_is_permissive() {
730        let cors = CorsConfig::default();
731        assert!(cors.is_permissive());
732        assert_eq!(cors.allowed_origins, vec!["*"]);
733        assert_eq!(cors.allowed_headers, vec!["*"]);
734        assert!(!cors.allow_credentials);
735        assert_eq!(cors.max_age_secs, 3600);
736    }
737
738    #[test]
739    fn test_cors_specific_origins_not_permissive() {
740        let cors = CorsConfig {
741            allowed_origins: vec![
742                "https://app.example.com".to_string(),
743                "https://admin.example.com".to_string(),
744            ],
745            ..CorsConfig::default()
746        };
747        assert!(!cors.is_permissive());
748        assert_eq!(cors.allowed_origins.len(), 2);
749    }
750
751    #[test]
752    fn test_cors_from_toml_specific_origins() {
753        let toml_content = r#"
754[cors]
755allowed_origins = ["https://app.example.com", "https://admin.example.com"]
756allowed_methods = ["GET", "POST"]
757allowed_headers = ["Content-Type", "Authorization"]
758allow_credentials = true
759max_age_secs = 7200
760"#;
761        let file_cfg: FileConfig =
762            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
763        let cli = CliOverrides::default();
764        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
765
766        assert!(!cfg.cors.is_permissive());
767        assert_eq!(
768            cfg.cors.allowed_origins,
769            vec!["https://app.example.com", "https://admin.example.com"]
770        );
771        assert_eq!(cfg.cors.allowed_methods, vec!["GET", "POST"]);
772        assert_eq!(
773            cfg.cors.allowed_headers,
774            vec!["Content-Type", "Authorization"]
775        );
776        assert!(cfg.cors.allow_credentials);
777        assert_eq!(cfg.cors.max_age_secs, 7200);
778    }
779
780    #[test]
781    fn test_cors_from_toml_partial_uses_defaults() {
782        let toml_content = r#"
783[cors]
784allowed_origins = ["https://myapp.com"]
785"#;
786        let file_cfg: FileConfig =
787            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
788        let cli = CliOverrides::default();
789        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
790
791        assert!(!cfg.cors.is_permissive());
792        assert_eq!(cfg.cors.allowed_origins, vec!["https://myapp.com"]);
793        // Other fields use defaults
794        assert_eq!(cfg.cors.allowed_headers, vec!["*"]);
795        assert!(!cfg.cors.allow_credentials);
796        assert_eq!(cfg.cors.max_age_secs, 3600);
797        assert_eq!(cfg.cors.allowed_methods.len(), 6); // default methods
798    }
799
800    #[test]
801    fn test_cors_absent_from_toml_uses_permissive_default() {
802        let toml_content = r#"
803[server]
804port = 9090
805"#;
806        let file_cfg: FileConfig =
807            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
808        let cli = CliOverrides::default();
809        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
810
811        assert!(cfg.cors.is_permissive());
812        assert_eq!(cfg.cors, CorsConfig::default());
813    }
814
815    #[test]
816    fn test_cors_empty_section_uses_defaults() {
817        let toml_content = r#"
818[cors]
819"#;
820        let file_cfg: FileConfig =
821            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
822        let cli = CliOverrides::default();
823        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
824
825        assert!(cfg.cors.is_permissive());
826    }
827
828    #[test]
829    fn test_build_cors_layer_permissive() {
830        let cors = CorsConfig::default();
831        // Should not panic — produces a valid CorsLayer
832        let _layer = build_cors_layer(&cors);
833    }
834
835    #[test]
836    fn test_build_cors_layer_specific_origins() {
837        let cors = CorsConfig {
838            allowed_origins: vec![
839                "https://app.example.com".to_string(),
840                "http://localhost:3000".to_string(),
841            ],
842            allowed_methods: vec!["GET".to_string(), "POST".to_string()],
843            allowed_headers: vec!["Content-Type".to_string(), "Authorization".to_string()],
844            allow_credentials: true,
845            max_age_secs: 600,
846        };
847        // Should not panic — produces a valid CorsLayer
848        let _layer = build_cors_layer(&cors);
849    }
850
851    #[test]
852    fn test_build_cors_layer_wildcard_headers() {
853        let cors = CorsConfig {
854            allowed_origins: vec!["https://myapp.com".to_string()],
855            allowed_headers: vec!["*".to_string()],
856            ..CorsConfig::default()
857        };
858        let _layer = build_cors_layer(&cors);
859    }
860
861    #[test]
862    fn test_build_cors_layer_invalid_origin_skipped() {
863        let cors = CorsConfig {
864            allowed_origins: vec![
865                "https://valid.com".to_string(),
866                "not a valid \x00 origin".to_string(),
867            ],
868            ..CorsConfig::default()
869        };
870        // Invalid origins are silently filtered via filter_map
871        let _layer = build_cors_layer(&cors);
872    }
873
874    #[test]
875    fn test_server_config_default_includes_cors() {
876        let cfg = ServerConfig::default();
877        assert!(cfg.cors.is_permissive());
878    }
879}