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    /// Returns `true` when the bind host is reachable beyond the local machine.
266    ///
267    /// A loopback host is treated as private: `localhost`, any `127.0.0.0/8`
268    /// address (`127.0.0.1`, `127.0.0.5`, …), IPv6 loopback `::1`, and the
269    /// IPv4-mapped form `::ffff:127.0.0.1`. Matching is case-insensitive and
270    /// tolerates surrounding whitespace and `[...]` brackets. Anything else —
271    /// including the wildcards `0.0.0.0`/`::` and any routable address or
272    /// hostname — is considered publicly reachable. Errs toward *over*-warning
273    /// (an unrecognised host is treated as public), never under-warning.
274    pub fn binds_publicly(&self) -> bool {
275        // Case-insensitive, whitespace- and bracket-tolerant (`[::1]`).
276        let host = self
277            .host
278            .trim()
279            .trim_start_matches('[')
280            .trim_end_matches(']')
281            .to_ascii_lowercase();
282        if matches!(host.as_str(), "localhost" | "::1") || host.starts_with("127.") {
283            return false;
284        }
285        // IPv4-mapped IPv6 loopback, e.g. `::ffff:127.0.0.1`.
286        if let Some(v4) = host.strip_prefix("::ffff:") {
287            if v4.starts_with("127.") {
288                return false;
289            }
290        }
291        true
292    }
293}
294
295// ============================================================================
296// CLI overrides (filled by clap in main.rs)
297// ============================================================================
298
299/// Values explicitly provided via CLI flags or environment variables.
300/// `None` means "not provided — fall through to TOML or default".
301#[derive(Debug, Default)]
302pub struct CliOverrides {
303    pub config_path: Option<PathBuf>,
304    pub host: Option<String>,
305    pub port: Option<u16>,
306    pub data_dir: Option<String>,
307    pub api_keys: Option<Vec<String>>,
308    pub tls_cert: Option<String>,
309    pub tls_key: Option<String>,
310    pub rate_limit: Option<u32>,
311}
312
313// ============================================================================
314// TOML file loader
315// ============================================================================
316
317fn load_toml_file(path: &Option<PathBuf>) -> anyhow::Result<FileConfig> {
318    let candidate = match path {
319        Some(p) => {
320            if !p.exists() {
321                anyhow::bail!("config file not found: {}", p.display());
322            }
323            p.clone()
324        }
325        None => {
326            let default_path = PathBuf::from("velesdb.toml");
327            if !default_path.exists() {
328                return Ok(FileConfig::default());
329            }
330            default_path
331        }
332    };
333
334    let contents = std::fs::read_to_string(&candidate)
335        .map_err(|e| anyhow::anyhow!("failed to read config file {}: {e}", candidate.display()))?;
336
337    let cfg: FileConfig = toml::from_str(&contents)
338        .map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", candidate.display()))?;
339
340    Ok(cfg)
341}
342
343// ============================================================================
344// CORS resolution & layer builder
345// ============================================================================
346
347/// Merges a `CorsSection` (from TOML) over `CorsConfig` defaults.
348fn resolve_cors(defaults: CorsConfig, section: CorsSection) -> CorsConfig {
349    CorsConfig {
350        allowed_origins: section.allowed_origins.unwrap_or(defaults.allowed_origins),
351        allowed_methods: section.allowed_methods.unwrap_or(defaults.allowed_methods),
352        allowed_headers: section.allowed_headers.unwrap_or(defaults.allowed_headers),
353        allow_credentials: section
354            .allow_credentials
355            .unwrap_or(defaults.allow_credentials),
356        max_age_secs: section.max_age_secs.unwrap_or(defaults.max_age_secs),
357    }
358}
359
360/// Builds a [`tower_http::cors::CorsLayer`] from the resolved CORS config.
361///
362/// When `allowed_origins` contains `"*"`, returns `CorsLayer::permissive()`
363/// for full backward compatibility. Otherwise, constructs a restrictive
364/// layer with the specified origins, methods, and headers.
365pub fn build_cors_layer(cors: &CorsConfig) -> tower_http::cors::CorsLayer {
366    use tower_http::cors::{AllowOrigin, CorsLayer};
367
368    if cors.is_permissive() {
369        return CorsLayer::permissive();
370    }
371
372    let origins: Vec<axum::http::HeaderValue> = cors
373        .allowed_origins
374        .iter()
375        .filter_map(|o| o.parse().ok())
376        .collect();
377    let methods: Vec<axum::http::Method> = cors
378        .allowed_methods
379        .iter()
380        .filter_map(|m| m.parse().ok())
381        .collect();
382
383    let layer = CorsLayer::new()
384        .allow_origin(AllowOrigin::list(origins))
385        .allow_methods(methods)
386        .max_age(std::time::Duration::from_secs(cors.max_age_secs));
387
388    let layer = apply_cors_headers_policy(layer, cors);
389
390    if cors.allow_credentials {
391        layer.allow_credentials(true)
392    } else {
393        layer
394    }
395}
396
397/// Applies the headers policy to a `CorsLayer`, honouring the CORS spec rule that
398/// `allow_credentials=true` is incompatible with wildcard headers (browsers reject
399/// the preflight). Logs a warning and falls back to default headers in that case.
400fn apply_cors_headers_policy(
401    layer: tower_http::cors::CorsLayer,
402    cors: &CorsConfig,
403) -> tower_http::cors::CorsLayer {
404    use tower_http::cors::Any;
405
406    let has_wildcard = cors.allowed_headers.iter().any(|h| h == "*");
407    if has_wildcard && !cors.allow_credentials {
408        return layer.allow_headers(Any);
409    }
410    if has_wildcard && cors.allow_credentials {
411        tracing::warn!(
412            "CORS: allow_credentials=true is incompatible with wildcard \
413             headers per CORS spec. Falling back to default headers \
414             (Content-Type, Authorization)."
415        );
416    }
417    let headers: Vec<axum::http::HeaderName> = cors
418        .allowed_headers
419        .iter()
420        .filter(|h| h.as_str() != "*")
421        .filter_map(|h| h.parse().ok())
422        .collect();
423    if headers.is_empty() && cors.allow_credentials {
424        layer.allow_headers([
425            axum::http::header::CONTENT_TYPE,
426            axum::http::header::AUTHORIZATION,
427        ])
428    } else {
429        layer.allow_headers(headers)
430    }
431}
432
433// ============================================================================
434// Helper: parse comma-separated API keys from env var
435// ============================================================================
436
437/// Parse `VELESDB_API_KEYS` env var (comma-separated) into a `Vec<String>`.
438pub fn parse_api_keys_env() -> Option<Vec<String>> {
439    let val = std::env::var("VELESDB_API_KEYS").ok()?;
440    let keys: Vec<String> = val
441        .split(',')
442        .map(|s| s.trim().to_string())
443        .filter(|s| !s.is_empty())
444        .collect();
445    if keys.is_empty() {
446        None
447    } else {
448        Some(keys)
449    }
450}
451
452// ============================================================================
453// Tests
454// ============================================================================
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use std::io::Write;
460
461    #[test]
462    fn test_binds_publicly() {
463        let mut cfg = ServerConfig::default();
464        // Loopback hosts are private — including case variants, brackets,
465        // whitespace, 127.0.0.0/8, and the IPv4-mapped IPv6 loopback.
466        for host in [
467            "127.0.0.1",
468            "::1",
469            "[::1]",
470            "localhost",
471            "LOCALHOST",
472            " localhost ",
473            "127.0.0.5",
474            "::ffff:127.0.0.1",
475        ] {
476            cfg.host = host.to_string();
477            assert!(!cfg.binds_publicly(), "{host} should be private");
478        }
479        // Wildcard and routable addresses are public.
480        for host in ["0.0.0.0", "::", "192.168.1.10", "10.0.0.1", ""] {
481            cfg.host = host.to_string();
482            assert!(cfg.binds_publicly(), "{host} should be public");
483        }
484    }
485
486    #[test]
487    fn test_defaults() {
488        let cfg = ServerConfig::default();
489        assert_eq!(cfg.host, "127.0.0.1");
490        assert_eq!(cfg.port, 8080);
491        assert_eq!(cfg.data_dir, "./velesdb_data");
492        assert!(cfg.api_keys.is_empty());
493        assert!(cfg.tls.cert.is_none());
494        assert!(cfg.tls.key.is_none());
495        assert_eq!(cfg.shutdown_timeout_secs, 30);
496        assert_eq!(cfg.rate_limit, 100);
497        assert!(!cfg.auth_enabled());
498        assert!(!cfg.tls_enabled());
499        assert!(cfg.rate_limit_enabled());
500        assert!(cfg.cors.is_permissive());
501    }
502
503    #[test]
504    fn test_toml_overrides_defaults() {
505        let toml_content = r#"
506[server]
507host = "0.0.0.0"
508port = 9090
509data_dir = "/var/velesdb"
510shutdown_timeout_secs = 60
511
512[auth]
513api_keys = ["key-alpha", "key-beta"]
514
515[tls]
516cert = "/etc/ssl/cert.pem"
517key = "/etc/ssl/key.pem"
518"#;
519        let file_cfg: FileConfig =
520            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
521        let cli = CliOverrides::default();
522        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
523
524        assert_eq!(cfg.host, "0.0.0.0");
525        assert_eq!(cfg.port, 9090);
526        assert_eq!(cfg.data_dir, "/var/velesdb");
527        assert_eq!(cfg.shutdown_timeout_secs, 60);
528        assert_eq!(cfg.api_keys, vec!["key-alpha", "key-beta"]);
529        assert_eq!(cfg.tls.cert.as_deref(), Some("/etc/ssl/cert.pem"));
530        assert_eq!(cfg.tls.key.as_deref(), Some("/etc/ssl/key.pem"));
531        assert!(cfg.auth_enabled());
532        assert!(cfg.tls_enabled());
533    }
534
535    #[test]
536    fn test_cli_overrides_toml() {
537        let toml_content = r#"
538[server]
539host = "0.0.0.0"
540port = 9090
541"#;
542        let file_cfg: FileConfig =
543            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
544        let cli = CliOverrides {
545            port: Some(3000),
546            host: Some("10.0.0.1".to_string()),
547            ..Default::default()
548        };
549        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
550
551        // CLI wins over TOML
552        assert_eq!(cfg.host, "10.0.0.1");
553        assert_eq!(cfg.port, 3000);
554        // TOML didn't set data_dir, so default applies
555        assert_eq!(cfg.data_dir, "./velesdb_data");
556    }
557
558    #[test]
559    fn test_partial_toml_uses_defaults_for_missing() {
560        let toml_content = r#"
561[server]
562port = 4000
563"#;
564        let file_cfg: FileConfig =
565            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
566        let cli = CliOverrides::default();
567        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
568
569        assert_eq!(cfg.port, 4000);
570        assert_eq!(cfg.host, "127.0.0.1"); // default
571        assert_eq!(cfg.data_dir, "./velesdb_data"); // default
572    }
573
574    #[test]
575    fn test_empty_toml_uses_all_defaults() {
576        let file_cfg: FileConfig = toml::from_str("").expect("test: empty TOML parses to default");
577        let cli = CliOverrides::default();
578        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
579
580        assert_eq!(cfg, ServerConfig::default());
581    }
582
583    #[test]
584    fn test_validate_port_zero_rejected() {
585        let cfg = ServerConfig {
586            port: 0,
587            ..ServerConfig::default()
588        };
589        let err = cfg.validate().unwrap_err();
590        assert!(err.to_string().contains("port"));
591    }
592
593    #[test]
594    fn test_validate_empty_data_dir_rejected() {
595        let cfg = ServerConfig {
596            data_dir: String::new(),
597            ..ServerConfig::default()
598        };
599        let err = cfg.validate().unwrap_err();
600        assert!(err.to_string().contains("data_dir"));
601    }
602
603    #[test]
604    fn test_validate_tls_cert_without_key() {
605        let cfg = ServerConfig {
606            tls: TlsConfig {
607                cert: Some("/tmp/cert.pem".to_string()),
608                key: None,
609            },
610            ..ServerConfig::default()
611        };
612        let err = cfg.validate().unwrap_err();
613        assert!(err.to_string().contains("tls_key is missing"));
614    }
615
616    #[test]
617    fn test_validate_tls_key_without_cert() {
618        let cfg = ServerConfig {
619            tls: TlsConfig {
620                cert: None,
621                key: Some("/tmp/key.pem".to_string()),
622            },
623            ..ServerConfig::default()
624        };
625        let err = cfg.validate().unwrap_err();
626        assert!(err.to_string().contains("tls_cert is missing"));
627    }
628
629    #[test]
630    fn test_validate_tls_missing_cert_file() {
631        let cfg = ServerConfig {
632            tls: TlsConfig {
633                cert: Some("/nonexistent/cert.pem".to_string()),
634                key: Some("/nonexistent/key.pem".to_string()),
635            },
636            ..ServerConfig::default()
637        };
638        let err = cfg.validate().unwrap_err();
639        assert!(err.to_string().contains("cert file not found"));
640    }
641
642    #[test]
643    fn test_validate_tls_valid_files() {
644        let dir = tempfile::tempdir().expect("test: create temp dir");
645        let cert_path = dir.path().join("cert.pem");
646        let key_path = dir.path().join("key.pem");
647        std::fs::File::create(&cert_path)
648            .expect("test: create cert file")
649            .write_all(b"cert")
650            .expect("test: write cert content");
651        std::fs::File::create(&key_path)
652            .expect("test: create key file")
653            .write_all(b"key")
654            .expect("test: write key content");
655
656        let cfg = ServerConfig {
657            tls: TlsConfig {
658                cert: Some(cert_path.to_string_lossy().to_string()),
659                key: Some(key_path.to_string_lossy().to_string()),
660            },
661            ..ServerConfig::default()
662        };
663        cfg.validate().expect("valid TLS config should pass");
664    }
665
666    #[test]
667    fn test_parse_api_keys_env() {
668        // Simulate by directly testing the parsing logic
669        let input = "key1, key2 , key3";
670        let keys: Vec<String> = input
671            .split(',')
672            .map(|s| s.trim().to_string())
673            .filter(|s| !s.is_empty())
674            .collect();
675        assert_eq!(keys, vec!["key1", "key2", "key3"]);
676    }
677
678    #[test]
679    fn test_load_toml_file_not_found_explicit_path() {
680        let result = load_toml_file(&Some(PathBuf::from("/nonexistent/velesdb.toml")));
681        assert!(result.is_err());
682        assert!(result
683            .unwrap_err()
684            .to_string()
685            .contains("config file not found"));
686    }
687
688    #[test]
689    fn test_load_toml_file_no_default_returns_empty() {
690        // When no explicit path and no velesdb.toml in cwd, returns defaults
691        let result = load_toml_file(&None);
692        assert!(result.is_ok());
693    }
694
695    #[test]
696    fn test_full_priority_chain() {
697        // Scenario: default=8080, TOML=9090, CLI=3000 → expect 3000
698        let toml_content = r#"
699[server]
700port = 9090
701host = "0.0.0.0"
702data_dir = "/toml/data"
703"#;
704        let file_cfg: FileConfig =
705            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
706        let cli = CliOverrides {
707            port: Some(3000),
708            // host not set in CLI → TOML should win
709            ..Default::default()
710        };
711        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
712
713        assert_eq!(cfg.port, 3000); // CLI wins
714        assert_eq!(cfg.host, "0.0.0.0"); // TOML wins (no CLI override)
715        assert_eq!(cfg.data_dir, "/toml/data"); // TOML wins (no CLI override)
716    }
717
718    #[test]
719    fn test_rate_limit_from_toml() {
720        let toml_content = r#"
721[server]
722rate_limit = 50
723"#;
724        let file_cfg: FileConfig =
725            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
726        let cli = CliOverrides::default();
727        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
728
729        assert_eq!(cfg.rate_limit, 50);
730        assert!(cfg.rate_limit_enabled());
731    }
732
733    #[test]
734    fn test_rate_limit_disabled_via_toml() {
735        let toml_content = r#"
736[server]
737rate_limit = 0
738"#;
739        let file_cfg: FileConfig =
740            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
741        let cli = CliOverrides::default();
742        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
743
744        assert_eq!(cfg.rate_limit, 0);
745        assert!(!cfg.rate_limit_enabled());
746    }
747
748    #[test]
749    fn test_rate_limit_cli_overrides_toml() {
750        let toml_content = r#"
751[server]
752rate_limit = 50
753"#;
754        let file_cfg: FileConfig =
755            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
756        let cli = CliOverrides {
757            rate_limit: Some(200),
758            ..Default::default()
759        };
760        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
761
762        assert_eq!(cfg.rate_limit, 200);
763    }
764
765    #[test]
766    fn test_rate_limit_cli_disables() {
767        let file_cfg = FileConfig::default();
768        let cli = CliOverrides {
769            rate_limit: Some(0),
770            ..Default::default()
771        };
772        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
773
774        assert_eq!(cfg.rate_limit, 0);
775        assert!(!cfg.rate_limit_enabled());
776    }
777
778    // ====================================================================
779    // CORS configuration tests
780    // ====================================================================
781
782    #[test]
783    fn test_cors_default_is_permissive() {
784        let cors = CorsConfig::default();
785        assert!(cors.is_permissive());
786        assert_eq!(cors.allowed_origins, vec!["*"]);
787        assert_eq!(cors.allowed_headers, vec!["*"]);
788        assert!(!cors.allow_credentials);
789        assert_eq!(cors.max_age_secs, 3600);
790    }
791
792    #[test]
793    fn test_cors_specific_origins_not_permissive() {
794        let cors = CorsConfig {
795            allowed_origins: vec![
796                "https://app.example.com".to_string(),
797                "https://admin.example.com".to_string(),
798            ],
799            ..CorsConfig::default()
800        };
801        assert!(!cors.is_permissive());
802        assert_eq!(cors.allowed_origins.len(), 2);
803    }
804
805    #[test]
806    fn test_cors_from_toml_specific_origins() {
807        let toml_content = r#"
808[cors]
809allowed_origins = ["https://app.example.com", "https://admin.example.com"]
810allowed_methods = ["GET", "POST"]
811allowed_headers = ["Content-Type", "Authorization"]
812allow_credentials = true
813max_age_secs = 7200
814"#;
815        let file_cfg: FileConfig =
816            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
817        let cli = CliOverrides::default();
818        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
819
820        assert!(!cfg.cors.is_permissive());
821        assert_eq!(
822            cfg.cors.allowed_origins,
823            vec!["https://app.example.com", "https://admin.example.com"]
824        );
825        assert_eq!(cfg.cors.allowed_methods, vec!["GET", "POST"]);
826        assert_eq!(
827            cfg.cors.allowed_headers,
828            vec!["Content-Type", "Authorization"]
829        );
830        assert!(cfg.cors.allow_credentials);
831        assert_eq!(cfg.cors.max_age_secs, 7200);
832    }
833
834    #[test]
835    fn test_cors_from_toml_partial_uses_defaults() {
836        let toml_content = r#"
837[cors]
838allowed_origins = ["https://myapp.com"]
839"#;
840        let file_cfg: FileConfig =
841            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
842        let cli = CliOverrides::default();
843        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
844
845        assert!(!cfg.cors.is_permissive());
846        assert_eq!(cfg.cors.allowed_origins, vec!["https://myapp.com"]);
847        // Other fields use defaults
848        assert_eq!(cfg.cors.allowed_headers, vec!["*"]);
849        assert!(!cfg.cors.allow_credentials);
850        assert_eq!(cfg.cors.max_age_secs, 3600);
851        assert_eq!(cfg.cors.allowed_methods.len(), 6); // default methods
852    }
853
854    #[test]
855    fn test_cors_absent_from_toml_uses_permissive_default() {
856        let toml_content = r#"
857[server]
858port = 9090
859"#;
860        let file_cfg: FileConfig =
861            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
862        let cli = CliOverrides::default();
863        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
864
865        assert!(cfg.cors.is_permissive());
866        assert_eq!(cfg.cors, CorsConfig::default());
867    }
868
869    #[test]
870    fn test_cors_empty_section_uses_defaults() {
871        let toml_content = r#"
872[cors]
873"#;
874        let file_cfg: FileConfig =
875            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
876        let cli = CliOverrides::default();
877        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
878
879        assert!(cfg.cors.is_permissive());
880    }
881
882    #[test]
883    fn test_build_cors_layer_permissive() {
884        let cors = CorsConfig::default();
885        // Should not panic — produces a valid CorsLayer
886        let _layer = build_cors_layer(&cors);
887    }
888
889    #[test]
890    fn test_build_cors_layer_specific_origins() {
891        let cors = CorsConfig {
892            allowed_origins: vec![
893                "https://app.example.com".to_string(),
894                "http://localhost:3000".to_string(),
895            ],
896            allowed_methods: vec!["GET".to_string(), "POST".to_string()],
897            allowed_headers: vec!["Content-Type".to_string(), "Authorization".to_string()],
898            allow_credentials: true,
899            max_age_secs: 600,
900        };
901        // Should not panic — produces a valid CorsLayer
902        let _layer = build_cors_layer(&cors);
903    }
904
905    #[test]
906    fn test_build_cors_layer_wildcard_headers() {
907        let cors = CorsConfig {
908            allowed_origins: vec!["https://myapp.com".to_string()],
909            allowed_headers: vec!["*".to_string()],
910            ..CorsConfig::default()
911        };
912        let _layer = build_cors_layer(&cors);
913    }
914
915    #[test]
916    fn test_build_cors_layer_invalid_origin_skipped() {
917        let cors = CorsConfig {
918            allowed_origins: vec![
919                "https://valid.com".to_string(),
920                "not a valid \x00 origin".to_string(),
921            ],
922            ..CorsConfig::default()
923        };
924        // Invalid origins are silently filtered via filter_map
925        let _layer = build_cors_layer(&cors);
926    }
927
928    #[test]
929    fn test_server_config_default_includes_cors() {
930        let cfg = ServerConfig::default();
931        assert!(cfg.cors.is_permissive());
932    }
933}