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