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// Core engine configuration (issue #1549)
11// ============================================================================
12
13/// Loads the core [`velesdb_core::config::VelesConfig`] (search/HNSW/storage/
14/// limits/quantization/WAL batching) from the same TOML file consumed by
15/// [`ServerConfig::load`] for the server-transport sections (`[server]`,
16/// `[auth]`, `[tls]`, `[cors]`).
17///
18/// The two structs deserialize independently from the same file, but
19/// **`VelesConfig` also has its own `[server]` and `[logging]` fields**
20/// (for standalone/embedded consumers) that collide in key name — not
21/// meaning — with this crate's own `[server]` transport section. A
22/// `[server] port = 443` meant as this crate's HTTP bind port would
23/// otherwise *also* land in `VelesConfig.server.port` and be rejected by
24/// its `>= 1024` validation rule, a spurious failure unrelated to the
25/// value actually being configured. This function therefore uses
26/// [`velesdb_core::config::VelesConfig::load_from_path_engine_only`],
27/// which parses **only** the engine sections (`[search]`/`[hnsw]`/
28/// `[storage]`/`[limits]`/`[quantization]`/`[wal_batch]`) and silently
29/// drops every other top-level table before validating — so
30/// `[server]`/`[auth]`/`[tls]`/`[cors]` stay exclusively owned by
31/// [`ServerConfig::load`].
32///
33/// `VELESDB_*` environment variables still layer on top of the (filtered)
34/// file, same as [`velesdb_core::config::VelesConfig::load_from_path`] —
35/// e.g. `VELESDB_LIMITS_MAX_COLLECTIONS=5` overrides a `[limits]` value
36/// from the file even though the file is filtered first.
37///
38/// - `config_path: Some(path)` — the caller passed `--config`/`VELESDB_CONFIG`
39///   explicitly. The file **must** exist and pass
40///   `load_from_path_engine_only` validation — never a silent fallback to
41///   defaults on a broken path.
42/// - `config_path: None` — mirrors [`load_toml_file`]'s default-file
43///   behaviour: falls back to `velesdb.toml` in the current directory if
44///   present (still validated, so a malformed default file still fails
45///   fast), otherwise core defaults apply.
46///
47/// # Errors
48///
49/// Returns an error if an explicit path is missing, or if the file fails to
50/// parse or validate. The underlying typed
51/// [`velesdb_core::config::ConfigError`] is preserved as the error source so
52/// callers can match on it if needed.
53pub fn load_core_config(
54    config_path: &Option<PathBuf>,
55) -> anyhow::Result<velesdb_core::config::VelesConfig> {
56    let path = match config_path {
57        Some(p) => {
58            if !p.exists() {
59                anyhow::bail!("config file not found: {}", p.display());
60            }
61            p.clone()
62        }
63        None => {
64            let default_path = PathBuf::from("velesdb.toml");
65            if !default_path.exists() {
66                return Ok(velesdb_core::config::VelesConfig::default());
67            }
68            default_path
69        }
70    };
71
72    velesdb_core::config::VelesConfig::load_from_path_engine_only(&path).map_err(|e| {
73        anyhow::anyhow!(
74            "failed to load VelesDB core config from {}: {e}",
75            path.display()
76        )
77    })
78}
79
80// ============================================================================
81// TOML file configuration (all fields optional)
82// ============================================================================
83
84/// Root structure for `velesdb.toml`.
85#[derive(Debug, Deserialize, Default)]
86struct FileConfig {
87    server: Option<ServerSection>,
88    auth: Option<AuthSection>,
89    tls: Option<TlsSection>,
90    cors: Option<CorsSection>,
91}
92
93#[derive(Debug, Deserialize, Default)]
94struct ServerSection {
95    host: Option<String>,
96    port: Option<u16>,
97    data_dir: Option<String>,
98    shutdown_timeout_secs: Option<u64>,
99    rate_limit: Option<u32>,
100}
101
102#[derive(Debug, Deserialize, Default)]
103struct AuthSection {
104    api_keys: Option<Vec<String>>,
105}
106
107#[derive(Debug, Deserialize, Default)]
108struct TlsSection {
109    cert: Option<String>,
110    key: Option<String>,
111}
112
113#[derive(Debug, Deserialize, Default)]
114struct CorsSection {
115    allowed_origins: Option<Vec<String>>,
116    allowed_methods: Option<Vec<String>>,
117    allowed_headers: Option<Vec<String>>,
118    allow_credentials: Option<bool>,
119    max_age_secs: Option<u64>,
120}
121
122// ============================================================================
123// Resolved configuration
124// ============================================================================
125
126/// TLS certificate and key paths.
127///
128/// Both fields must be `Some` together or both `None`; a partial pair is
129/// rejected by [`ServerConfig::validate`].
130#[derive(Debug, Clone, PartialEq, Default)]
131pub struct TlsConfig {
132    /// Path to the PEM-encoded TLS certificate file.
133    pub cert: Option<String>,
134    /// Path to the PEM-encoded TLS private key file.
135    pub key: Option<String>,
136}
137
138impl TlsConfig {
139    /// Returns `true` when both cert and key are configured.
140    pub fn is_enabled(&self) -> bool {
141        self.cert.is_some() && self.key.is_some()
142    }
143}
144
145/// Final resolved server configuration.
146#[derive(Debug, Clone, PartialEq)]
147pub struct ServerConfig {
148    pub host: String,
149    pub port: u16,
150    pub data_dir: String,
151    pub api_keys: Vec<String>,
152    /// TLS certificate and key configuration (both or neither).
153    pub tls: TlsConfig,
154    pub shutdown_timeout_secs: u64,
155    /// Maximum requests per second per IP address (0 = disabled).
156    pub rate_limit: u32,
157    /// CORS configuration for cross-origin requests.
158    pub cors: CorsConfig,
159}
160
161/// CORS configuration for the server.
162///
163/// When `allowed_origins` contains `"*"`, the server uses a fully permissive
164/// CORS policy (equivalent to `CorsLayer::permissive()`). Otherwise, only the
165/// listed origins are allowed.
166///
167/// Defaults to permissive (`["*"]`) for backward compatibility.
168#[derive(Debug, Clone, PartialEq)]
169pub struct CorsConfig {
170    /// Allowed origins. Use `["*"]` for permissive mode.
171    pub allowed_origins: Vec<String>,
172    /// Allowed HTTP methods (e.g. `["GET", "POST"]`).
173    pub allowed_methods: Vec<String>,
174    /// Allowed request headers (e.g. `["Content-Type", "Authorization"]`).
175    /// Use `["*"]` to allow any header.
176    pub allowed_headers: Vec<String>,
177    /// Whether to allow credentials (cookies, authorization headers).
178    pub allow_credentials: bool,
179    /// How long (in seconds) browsers may cache preflight responses.
180    pub max_age_secs: u64,
181}
182
183/// Default burst budget for rate limiting (requests per second per IP).
184const DEFAULT_RATE_LIMIT: u32 = 100;
185
186/// Default preflight cache duration in seconds (1 hour).
187const DEFAULT_CORS_MAX_AGE_SECS: u64 = 3600;
188
189impl Default for CorsConfig {
190    fn default() -> Self {
191        Self {
192            allowed_origins: vec!["*".to_string()],
193            allowed_methods: vec![
194                "GET".to_string(),
195                "POST".to_string(),
196                "PUT".to_string(),
197                "DELETE".to_string(),
198                "PATCH".to_string(),
199                "OPTIONS".to_string(),
200            ],
201            allowed_headers: vec!["*".to_string()],
202            allow_credentials: false,
203            max_age_secs: DEFAULT_CORS_MAX_AGE_SECS,
204        }
205    }
206}
207
208impl CorsConfig {
209    /// Returns `true` when CORS is in fully permissive mode (any origin).
210    pub fn is_permissive(&self) -> bool {
211        self.allowed_origins.iter().any(|o| o == "*")
212    }
213}
214
215impl Default for ServerConfig {
216    fn default() -> Self {
217        Self {
218            host: "127.0.0.1".to_string(),
219            port: 8080,
220            data_dir: "./velesdb_data".to_string(),
221            api_keys: Vec::new(),
222            tls: TlsConfig::default(),
223            shutdown_timeout_secs: 30,
224            rate_limit: DEFAULT_RATE_LIMIT,
225            cors: CorsConfig::default(),
226        }
227    }
228}
229
230// ============================================================================
231// Loading logic
232// ============================================================================
233
234impl ServerConfig {
235    /// Load configuration with priority: CLI > env > TOML file > defaults.
236    ///
237    /// `cli` contains values from clap (which merges CLI flags + env vars).
238    /// `cli_sources` indicates which fields were explicitly set via CLI/env
239    /// (as opposed to falling back to clap defaults).
240    pub fn load(cli: CliOverrides) -> anyhow::Result<Self> {
241        let defaults = Self::default();
242        let file_cfg = load_toml_file(&cli.config_path)?;
243        Ok(Self::merge(defaults, file_cfg, cli))
244    }
245
246    fn merge(defaults: Self, file: FileConfig, cli: CliOverrides) -> Self {
247        let server = file.server.unwrap_or_default();
248        let auth = file.auth.unwrap_or_default();
249        let tls = file.tls.unwrap_or_default();
250        let cors_section = file.cors.unwrap_or_default();
251
252        // Layer: TOML over defaults
253        let host = server.host.unwrap_or(defaults.host);
254        let port = server.port.unwrap_or(defaults.port);
255        let data_dir = server.data_dir.unwrap_or(defaults.data_dir);
256        let shutdown_timeout_secs = server
257            .shutdown_timeout_secs
258            .unwrap_or(defaults.shutdown_timeout_secs);
259        let rate_limit = server.rate_limit.unwrap_or(defaults.rate_limit);
260        let api_keys = auth.api_keys.unwrap_or(defaults.api_keys);
261        let tls = TlsConfig {
262            cert: tls.cert.or(defaults.tls.cert),
263            key: tls.key.or(defaults.tls.key),
264        };
265        let cors = resolve_cors(defaults.cors, cors_section);
266
267        // Layer: CLI/env over TOML (only override when explicitly set)
268        let host = cli.host.unwrap_or(host);
269        let port = cli.port.unwrap_or(port);
270        let data_dir = cli.data_dir.unwrap_or(data_dir);
271        let api_keys = cli.api_keys.unwrap_or(api_keys);
272        let tls = TlsConfig {
273            cert: cli.tls_cert.or(tls.cert),
274            key: cli.tls_key.or(tls.key),
275        };
276        let rate_limit = cli.rate_limit.unwrap_or(rate_limit);
277
278        Self {
279            host,
280            port,
281            data_dir,
282            api_keys,
283            tls,
284            shutdown_timeout_secs,
285            rate_limit,
286            cors,
287        }
288    }
289
290    /// Validate the configuration at startup.
291    pub fn validate(&self) -> anyhow::Result<()> {
292        if self.port == 0 {
293            anyhow::bail!("invalid port: 0 is not allowed");
294        }
295        if self.data_dir.is_empty() {
296            anyhow::bail!("data_dir must not be empty");
297        }
298
299        // TLS: both cert and key must be provided together
300        match (&self.tls.cert, &self.tls.key) {
301            (Some(_), None) => {
302                anyhow::bail!("tls_cert is set but tls_key is missing");
303            }
304            (None, Some(_)) => {
305                anyhow::bail!("tls_key is set but tls_cert is missing");
306            }
307            (Some(cert), Some(key)) => {
308                if !Path::new(cert).exists() {
309                    anyhow::bail!("TLS cert file not found: {cert}");
310                }
311                if !Path::new(key).exists() {
312                    anyhow::bail!("TLS key file not found: {key}");
313                }
314            }
315            (None, None) => {}
316        }
317
318        Ok(())
319    }
320
321    /// Returns `true` when API key authentication is enabled.
322    pub fn auth_enabled(&self) -> bool {
323        !self.api_keys.is_empty()
324    }
325
326    /// Returns `true` when TLS is configured.
327    pub fn tls_enabled(&self) -> bool {
328        self.tls.is_enabled()
329    }
330
331    /// Returns `true` when rate limiting is enabled (rate_limit > 0).
332    pub fn rate_limit_enabled(&self) -> bool {
333        self.rate_limit > 0
334    }
335
336    /// Returns `true` when the bind host is reachable beyond the local machine.
337    ///
338    /// A loopback host is treated as private: `localhost`, any `127.0.0.0/8`
339    /// address (`127.0.0.1`, `127.0.0.5`, …), IPv6 loopback `::1`, and the
340    /// IPv4-mapped form `::ffff:127.0.0.1`. Matching is case-insensitive and
341    /// tolerates surrounding whitespace and `[...]` brackets. Anything else —
342    /// including the wildcards `0.0.0.0`/`::` and any routable address or
343    /// hostname — is considered publicly reachable. Errs toward *over*-warning
344    /// (an unrecognised host is treated as public), never under-warning.
345    pub fn binds_publicly(&self) -> bool {
346        // Case-insensitive, whitespace- and bracket-tolerant (`[::1]`).
347        let host = self
348            .host
349            .trim()
350            .trim_start_matches('[')
351            .trim_end_matches(']')
352            .to_ascii_lowercase();
353        if matches!(host.as_str(), "localhost" | "::1") || host.starts_with("127.") {
354            return false;
355        }
356        // IPv4-mapped IPv6 loopback, e.g. `::ffff:127.0.0.1`.
357        if let Some(v4) = host.strip_prefix("::ffff:") {
358            if v4.starts_with("127.") {
359                return false;
360            }
361        }
362        true
363    }
364}
365
366// ============================================================================
367// CLI overrides (filled by clap in main.rs)
368// ============================================================================
369
370/// Values explicitly provided via CLI flags or environment variables.
371/// `None` means "not provided — fall through to TOML or default".
372#[derive(Debug, Default)]
373pub struct CliOverrides {
374    pub config_path: Option<PathBuf>,
375    pub host: Option<String>,
376    pub port: Option<u16>,
377    pub data_dir: Option<String>,
378    pub api_keys: Option<Vec<String>>,
379    pub tls_cert: Option<String>,
380    pub tls_key: Option<String>,
381    pub rate_limit: Option<u32>,
382}
383
384// ============================================================================
385// TOML file loader
386// ============================================================================
387
388fn load_toml_file(path: &Option<PathBuf>) -> anyhow::Result<FileConfig> {
389    let candidate = match path {
390        Some(p) => {
391            if !p.exists() {
392                anyhow::bail!("config file not found: {}", p.display());
393            }
394            p.clone()
395        }
396        None => {
397            let default_path = PathBuf::from("velesdb.toml");
398            if !default_path.exists() {
399                return Ok(FileConfig::default());
400            }
401            default_path
402        }
403    };
404
405    let contents = std::fs::read_to_string(&candidate)
406        .map_err(|e| anyhow::anyhow!("failed to read config file {}: {e}", candidate.display()))?;
407
408    let cfg: FileConfig = toml::from_str(&contents)
409        .map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", candidate.display()))?;
410
411    Ok(cfg)
412}
413
414// ============================================================================
415// CORS resolution & layer builder
416// ============================================================================
417
418/// Merges a `CorsSection` (from TOML) over `CorsConfig` defaults.
419fn resolve_cors(defaults: CorsConfig, section: CorsSection) -> CorsConfig {
420    CorsConfig {
421        allowed_origins: section.allowed_origins.unwrap_or(defaults.allowed_origins),
422        allowed_methods: section.allowed_methods.unwrap_or(defaults.allowed_methods),
423        allowed_headers: section.allowed_headers.unwrap_or(defaults.allowed_headers),
424        allow_credentials: section
425            .allow_credentials
426            .unwrap_or(defaults.allow_credentials),
427        max_age_secs: section.max_age_secs.unwrap_or(defaults.max_age_secs),
428    }
429}
430
431/// Builds a [`tower_http::cors::CorsLayer`] from the resolved CORS config.
432///
433/// When `allowed_origins` contains `"*"`, returns `CorsLayer::permissive()`
434/// for full backward compatibility. Otherwise, constructs a restrictive
435/// layer with the specified origins, methods, and headers.
436pub fn build_cors_layer(cors: &CorsConfig) -> tower_http::cors::CorsLayer {
437    use tower_http::cors::{AllowOrigin, CorsLayer};
438
439    if cors.is_permissive() {
440        return CorsLayer::permissive();
441    }
442
443    let origins: Vec<axum::http::HeaderValue> = cors
444        .allowed_origins
445        .iter()
446        .filter_map(|o| o.parse().ok())
447        .collect();
448    let methods: Vec<axum::http::Method> = cors
449        .allowed_methods
450        .iter()
451        .filter_map(|m| m.parse().ok())
452        .collect();
453
454    let layer = CorsLayer::new()
455        .allow_origin(AllowOrigin::list(origins))
456        .allow_methods(methods)
457        .max_age(std::time::Duration::from_secs(cors.max_age_secs));
458
459    let layer = apply_cors_headers_policy(layer, cors);
460
461    if cors.allow_credentials {
462        layer.allow_credentials(true)
463    } else {
464        layer
465    }
466}
467
468/// Applies the headers policy to a `CorsLayer`, honouring the CORS spec rule that
469/// `allow_credentials=true` is incompatible with wildcard headers (browsers reject
470/// the preflight). Logs a warning and falls back to default headers in that case.
471fn apply_cors_headers_policy(
472    layer: tower_http::cors::CorsLayer,
473    cors: &CorsConfig,
474) -> tower_http::cors::CorsLayer {
475    use tower_http::cors::Any;
476
477    let has_wildcard = cors.allowed_headers.iter().any(|h| h == "*");
478    if has_wildcard && !cors.allow_credentials {
479        return layer.allow_headers(Any);
480    }
481    if has_wildcard && cors.allow_credentials {
482        tracing::warn!(
483            "CORS: allow_credentials=true is incompatible with wildcard \
484             headers per CORS spec. Falling back to default headers \
485             (Content-Type, Authorization)."
486        );
487    }
488    let headers: Vec<axum::http::HeaderName> = cors
489        .allowed_headers
490        .iter()
491        .filter(|h| h.as_str() != "*")
492        .filter_map(|h| h.parse().ok())
493        .collect();
494    if headers.is_empty() && cors.allow_credentials {
495        layer.allow_headers([
496            axum::http::header::CONTENT_TYPE,
497            axum::http::header::AUTHORIZATION,
498        ])
499    } else {
500        layer.allow_headers(headers)
501    }
502}
503
504// ============================================================================
505// Helper: parse comma-separated API keys from env var
506// ============================================================================
507
508/// Parse `VELESDB_API_KEYS` env var (comma-separated) into a `Vec<String>`.
509pub fn parse_api_keys_env() -> Option<Vec<String>> {
510    let val = std::env::var("VELESDB_API_KEYS").ok()?;
511    let keys: Vec<String> = val
512        .split(',')
513        .map(|s| s.trim().to_string())
514        .filter(|s| !s.is_empty())
515        .collect();
516    if keys.is_empty() {
517        None
518    } else {
519        Some(keys)
520    }
521}
522
523// ============================================================================
524// Tests
525// ============================================================================
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use std::io::Write;
531
532    // ====================================================================
533    // `load_core_config` (issue #1549 — server `--config` wiring)
534    // ====================================================================
535
536    #[test]
537    fn test_load_core_config_none_returns_defaults_when_no_default_file() {
538        // No explicit path, and no `velesdb.toml` in cwd (the test binary's
539        // cwd is the crate root, which has no such file) — core defaults.
540        let config = load_core_config(&None).expect("test: default core config");
541        assert_eq!(
542            config.limits.max_collections,
543            velesdb_core::config::LimitsConfig::default().max_collections
544        );
545    }
546
547    #[test]
548    fn test_load_core_config_explicit_missing_path_fails_fast() {
549        let missing = PathBuf::from("/nonexistent/velesdb-issue-1549-server.toml");
550        let err = load_core_config(&Some(missing)).expect_err(
551            "test: an explicit but missing config path must error, not silently default",
552        );
553        assert!(
554            err.to_string().contains("config file not found"),
555            "unexpected error: {err}"
556        );
557    }
558
559    #[test]
560    fn test_load_core_config_explicit_invalid_value_fails_fast_typed() {
561        let dir = tempfile::tempdir().expect("test: temp dir");
562        let config_path = dir.path().join("velesdb.toml");
563        // max_collections = 0 is out of range (validate_limits requires >= 1).
564        std::fs::write(&config_path, "[limits]\nmax_collections = 0\n")
565            .expect("test: write config");
566
567        let err = load_core_config(&Some(config_path))
568            .expect_err("test: invalid value must fail fast with a typed ConfigError");
569        assert!(
570            err.to_string().contains("limits.max_collections"),
571            "unexpected error: {err}"
572        );
573    }
574
575    #[test]
576    fn test_load_core_config_explicit_valid_path_applies_sections() {
577        let dir = tempfile::tempdir().expect("test: temp dir");
578        let config_path = dir.path().join("velesdb.toml");
579        std::fs::write(
580            &config_path,
581            "[limits]\nmax_collections = 3\n\n[hnsw]\nm = 24\n",
582        )
583        .expect("test: write config");
584
585        let config = load_core_config(&Some(config_path)).expect("test: load valid config");
586        assert_eq!(config.limits.max_collections, 3);
587        assert_eq!(config.hnsw.m, Some(24));
588    }
589
590    /// Regression test (Fable review finding on issue #1549): a legitimate
591    /// low HTTP bind port in this crate's own `[server]` section used to
592    /// also get parsed into `VelesConfig`'s own (unrelated) `server.port`
593    /// field and rejected by its `>= 1024` validation rule — a spurious
594    /// startup failure for a config file that was otherwise entirely
595    /// valid. `load_core_config` must ignore `[server]` (and any other
596    /// non-engine section) entirely, while still applying the real engine
597    /// sections from the same file.
598    #[test]
599    fn test_load_core_config_ignores_shell_owned_server_section_low_port() {
600        let dir = tempfile::tempdir().expect("test: temp dir");
601        let config_path = dir.path().join("velesdb.toml");
602        std::fs::write(
603            &config_path,
604            "[server]\nport = 443\n\n[limits]\nmax_collections = 5\n",
605        )
606        .expect("test: write config");
607
608        let config = load_core_config(&Some(config_path))
609            .expect("a shell-owned [server] port=443 must not fail core config loading");
610        assert_eq!(config.limits.max_collections, 5);
611    }
612
613    #[test]
614    fn test_binds_publicly() {
615        let mut cfg = ServerConfig::default();
616        // Loopback hosts are private — including case variants, brackets,
617        // whitespace, 127.0.0.0/8, and the IPv4-mapped IPv6 loopback.
618        for host in [
619            "127.0.0.1",
620            "::1",
621            "[::1]",
622            "localhost",
623            "LOCALHOST",
624            " localhost ",
625            "127.0.0.5",
626            "::ffff:127.0.0.1",
627        ] {
628            cfg.host = host.to_string();
629            assert!(!cfg.binds_publicly(), "{host} should be private");
630        }
631        // Wildcard and routable addresses are public.
632        for host in ["0.0.0.0", "::", "192.168.1.10", "10.0.0.1", ""] {
633            cfg.host = host.to_string();
634            assert!(cfg.binds_publicly(), "{host} should be public");
635        }
636    }
637
638    #[test]
639    fn test_defaults() {
640        let cfg = ServerConfig::default();
641        assert_eq!(cfg.host, "127.0.0.1");
642        assert_eq!(cfg.port, 8080);
643        assert_eq!(cfg.data_dir, "./velesdb_data");
644        assert!(cfg.api_keys.is_empty());
645        assert!(cfg.tls.cert.is_none());
646        assert!(cfg.tls.key.is_none());
647        assert_eq!(cfg.shutdown_timeout_secs, 30);
648        assert_eq!(cfg.rate_limit, 100);
649        assert!(!cfg.auth_enabled());
650        assert!(!cfg.tls_enabled());
651        assert!(cfg.rate_limit_enabled());
652        assert!(cfg.cors.is_permissive());
653    }
654
655    #[test]
656    fn test_toml_overrides_defaults() {
657        let toml_content = r#"
658[server]
659host = "0.0.0.0"
660port = 9090
661data_dir = "/var/velesdb"
662shutdown_timeout_secs = 60
663
664[auth]
665api_keys = ["key-alpha", "key-beta"]
666
667[tls]
668cert = "/etc/ssl/cert.pem"
669key = "/etc/ssl/key.pem"
670"#;
671        let file_cfg: FileConfig =
672            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
673        let cli = CliOverrides::default();
674        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
675
676        assert_eq!(cfg.host, "0.0.0.0");
677        assert_eq!(cfg.port, 9090);
678        assert_eq!(cfg.data_dir, "/var/velesdb");
679        assert_eq!(cfg.shutdown_timeout_secs, 60);
680        assert_eq!(cfg.api_keys, vec!["key-alpha", "key-beta"]);
681        assert_eq!(cfg.tls.cert.as_deref(), Some("/etc/ssl/cert.pem"));
682        assert_eq!(cfg.tls.key.as_deref(), Some("/etc/ssl/key.pem"));
683        assert!(cfg.auth_enabled());
684        assert!(cfg.tls_enabled());
685    }
686
687    #[test]
688    fn test_cli_overrides_toml() {
689        let toml_content = r#"
690[server]
691host = "0.0.0.0"
692port = 9090
693"#;
694        let file_cfg: FileConfig =
695            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
696        let cli = CliOverrides {
697            port: Some(3000),
698            host: Some("10.0.0.1".to_string()),
699            ..Default::default()
700        };
701        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
702
703        // CLI wins over TOML
704        assert_eq!(cfg.host, "10.0.0.1");
705        assert_eq!(cfg.port, 3000);
706        // TOML didn't set data_dir, so default applies
707        assert_eq!(cfg.data_dir, "./velesdb_data");
708    }
709
710    #[test]
711    fn test_partial_toml_uses_defaults_for_missing() {
712        let toml_content = r#"
713[server]
714port = 4000
715"#;
716        let file_cfg: FileConfig =
717            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
718        let cli = CliOverrides::default();
719        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
720
721        assert_eq!(cfg.port, 4000);
722        assert_eq!(cfg.host, "127.0.0.1"); // default
723        assert_eq!(cfg.data_dir, "./velesdb_data"); // default
724    }
725
726    #[test]
727    fn test_empty_toml_uses_all_defaults() {
728        let file_cfg: FileConfig = toml::from_str("").expect("test: empty TOML parses to default");
729        let cli = CliOverrides::default();
730        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
731
732        assert_eq!(cfg, ServerConfig::default());
733    }
734
735    #[test]
736    fn test_validate_port_zero_rejected() {
737        let cfg = ServerConfig {
738            port: 0,
739            ..ServerConfig::default()
740        };
741        let err = cfg.validate().unwrap_err();
742        assert!(err.to_string().contains("port"));
743    }
744
745    #[test]
746    fn test_validate_empty_data_dir_rejected() {
747        let cfg = ServerConfig {
748            data_dir: String::new(),
749            ..ServerConfig::default()
750        };
751        let err = cfg.validate().unwrap_err();
752        assert!(err.to_string().contains("data_dir"));
753    }
754
755    #[test]
756    fn test_validate_tls_cert_without_key() {
757        let cfg = ServerConfig {
758            tls: TlsConfig {
759                cert: Some("/tmp/cert.pem".to_string()),
760                key: None,
761            },
762            ..ServerConfig::default()
763        };
764        let err = cfg.validate().unwrap_err();
765        assert!(err.to_string().contains("tls_key is missing"));
766    }
767
768    #[test]
769    fn test_validate_tls_key_without_cert() {
770        let cfg = ServerConfig {
771            tls: TlsConfig {
772                cert: None,
773                key: Some("/tmp/key.pem".to_string()),
774            },
775            ..ServerConfig::default()
776        };
777        let err = cfg.validate().unwrap_err();
778        assert!(err.to_string().contains("tls_cert is missing"));
779    }
780
781    #[test]
782    fn test_validate_tls_missing_cert_file() {
783        let cfg = ServerConfig {
784            tls: TlsConfig {
785                cert: Some("/nonexistent/cert.pem".to_string()),
786                key: Some("/nonexistent/key.pem".to_string()),
787            },
788            ..ServerConfig::default()
789        };
790        let err = cfg.validate().unwrap_err();
791        assert!(err.to_string().contains("cert file not found"));
792    }
793
794    #[test]
795    fn test_validate_tls_valid_files() {
796        let dir = tempfile::tempdir().expect("test: create temp dir");
797        let cert_path = dir.path().join("cert.pem");
798        let key_path = dir.path().join("key.pem");
799        std::fs::File::create(&cert_path)
800            .expect("test: create cert file")
801            .write_all(b"cert")
802            .expect("test: write cert content");
803        std::fs::File::create(&key_path)
804            .expect("test: create key file")
805            .write_all(b"key")
806            .expect("test: write key content");
807
808        let cfg = ServerConfig {
809            tls: TlsConfig {
810                cert: Some(cert_path.to_string_lossy().to_string()),
811                key: Some(key_path.to_string_lossy().to_string()),
812            },
813            ..ServerConfig::default()
814        };
815        cfg.validate().expect("valid TLS config should pass");
816    }
817
818    #[test]
819    fn test_parse_api_keys_env() {
820        // Simulate by directly testing the parsing logic
821        let input = "key1, key2 , key3";
822        let keys: Vec<String> = input
823            .split(',')
824            .map(|s| s.trim().to_string())
825            .filter(|s| !s.is_empty())
826            .collect();
827        assert_eq!(keys, vec!["key1", "key2", "key3"]);
828    }
829
830    #[test]
831    fn test_load_toml_file_not_found_explicit_path() {
832        let result = load_toml_file(&Some(PathBuf::from("/nonexistent/velesdb.toml")));
833        assert!(result.is_err());
834        assert!(result
835            .unwrap_err()
836            .to_string()
837            .contains("config file not found"));
838    }
839
840    #[test]
841    fn test_load_toml_file_no_default_returns_empty() {
842        // When no explicit path and no velesdb.toml in cwd, returns defaults
843        let result = load_toml_file(&None);
844        assert!(result.is_ok());
845    }
846
847    #[test]
848    fn test_full_priority_chain() {
849        // Scenario: default=8080, TOML=9090, CLI=3000 → expect 3000
850        let toml_content = r#"
851[server]
852port = 9090
853host = "0.0.0.0"
854data_dir = "/toml/data"
855"#;
856        let file_cfg: FileConfig =
857            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
858        let cli = CliOverrides {
859            port: Some(3000),
860            // host not set in CLI → TOML should win
861            ..Default::default()
862        };
863        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
864
865        assert_eq!(cfg.port, 3000); // CLI wins
866        assert_eq!(cfg.host, "0.0.0.0"); // TOML wins (no CLI override)
867        assert_eq!(cfg.data_dir, "/toml/data"); // TOML wins (no CLI override)
868    }
869
870    #[test]
871    fn test_rate_limit_from_toml() {
872        let toml_content = r#"
873[server]
874rate_limit = 50
875"#;
876        let file_cfg: FileConfig =
877            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
878        let cli = CliOverrides::default();
879        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
880
881        assert_eq!(cfg.rate_limit, 50);
882        assert!(cfg.rate_limit_enabled());
883    }
884
885    #[test]
886    fn test_rate_limit_disabled_via_toml() {
887        let toml_content = r#"
888[server]
889rate_limit = 0
890"#;
891        let file_cfg: FileConfig =
892            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
893        let cli = CliOverrides::default();
894        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
895
896        assert_eq!(cfg.rate_limit, 0);
897        assert!(!cfg.rate_limit_enabled());
898    }
899
900    #[test]
901    fn test_rate_limit_cli_overrides_toml() {
902        let toml_content = r#"
903[server]
904rate_limit = 50
905"#;
906        let file_cfg: FileConfig =
907            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
908        let cli = CliOverrides {
909            rate_limit: Some(200),
910            ..Default::default()
911        };
912        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
913
914        assert_eq!(cfg.rate_limit, 200);
915    }
916
917    #[test]
918    fn test_rate_limit_cli_disables() {
919        let file_cfg = FileConfig::default();
920        let cli = CliOverrides {
921            rate_limit: Some(0),
922            ..Default::default()
923        };
924        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
925
926        assert_eq!(cfg.rate_limit, 0);
927        assert!(!cfg.rate_limit_enabled());
928    }
929
930    // ====================================================================
931    // CORS configuration tests
932    // ====================================================================
933
934    #[test]
935    fn test_cors_default_is_permissive() {
936        let cors = CorsConfig::default();
937        assert!(cors.is_permissive());
938        assert_eq!(cors.allowed_origins, vec!["*"]);
939        assert_eq!(cors.allowed_headers, vec!["*"]);
940        assert!(!cors.allow_credentials);
941        assert_eq!(cors.max_age_secs, 3600);
942    }
943
944    #[test]
945    fn test_cors_specific_origins_not_permissive() {
946        let cors = CorsConfig {
947            allowed_origins: vec![
948                "https://app.example.com".to_string(),
949                "https://admin.example.com".to_string(),
950            ],
951            ..CorsConfig::default()
952        };
953        assert!(!cors.is_permissive());
954        assert_eq!(cors.allowed_origins.len(), 2);
955    }
956
957    #[test]
958    fn test_cors_from_toml_specific_origins() {
959        let toml_content = r#"
960[cors]
961allowed_origins = ["https://app.example.com", "https://admin.example.com"]
962allowed_methods = ["GET", "POST"]
963allowed_headers = ["Content-Type", "Authorization"]
964allow_credentials = true
965max_age_secs = 7200
966"#;
967        let file_cfg: FileConfig =
968            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
969        let cli = CliOverrides::default();
970        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
971
972        assert!(!cfg.cors.is_permissive());
973        assert_eq!(
974            cfg.cors.allowed_origins,
975            vec!["https://app.example.com", "https://admin.example.com"]
976        );
977        assert_eq!(cfg.cors.allowed_methods, vec!["GET", "POST"]);
978        assert_eq!(
979            cfg.cors.allowed_headers,
980            vec!["Content-Type", "Authorization"]
981        );
982        assert!(cfg.cors.allow_credentials);
983        assert_eq!(cfg.cors.max_age_secs, 7200);
984    }
985
986    #[test]
987    fn test_cors_from_toml_partial_uses_defaults() {
988        let toml_content = r#"
989[cors]
990allowed_origins = ["https://myapp.com"]
991"#;
992        let file_cfg: FileConfig =
993            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
994        let cli = CliOverrides::default();
995        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
996
997        assert!(!cfg.cors.is_permissive());
998        assert_eq!(cfg.cors.allowed_origins, vec!["https://myapp.com"]);
999        // Other fields use defaults
1000        assert_eq!(cfg.cors.allowed_headers, vec!["*"]);
1001        assert!(!cfg.cors.allow_credentials);
1002        assert_eq!(cfg.cors.max_age_secs, 3600);
1003        assert_eq!(cfg.cors.allowed_methods.len(), 6); // default methods
1004    }
1005
1006    #[test]
1007    fn test_cors_absent_from_toml_uses_permissive_default() {
1008        let toml_content = r#"
1009[server]
1010port = 9090
1011"#;
1012        let file_cfg: FileConfig =
1013            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
1014        let cli = CliOverrides::default();
1015        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
1016
1017        assert!(cfg.cors.is_permissive());
1018        assert_eq!(cfg.cors, CorsConfig::default());
1019    }
1020
1021    #[test]
1022    fn test_cors_empty_section_uses_defaults() {
1023        let toml_content = r#"
1024[cors]
1025"#;
1026        let file_cfg: FileConfig =
1027            toml::from_str(toml_content).expect("test: valid FileConfig TOML");
1028        let cli = CliOverrides::default();
1029        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
1030
1031        assert!(cfg.cors.is_permissive());
1032    }
1033
1034    #[test]
1035    fn test_build_cors_layer_permissive() {
1036        let cors = CorsConfig::default();
1037        // Should not panic — produces a valid CorsLayer
1038        let _layer = build_cors_layer(&cors);
1039    }
1040
1041    #[test]
1042    fn test_build_cors_layer_specific_origins() {
1043        let cors = CorsConfig {
1044            allowed_origins: vec![
1045                "https://app.example.com".to_string(),
1046                "http://localhost:3000".to_string(),
1047            ],
1048            allowed_methods: vec!["GET".to_string(), "POST".to_string()],
1049            allowed_headers: vec!["Content-Type".to_string(), "Authorization".to_string()],
1050            allow_credentials: true,
1051            max_age_secs: 600,
1052        };
1053        // Should not panic — produces a valid CorsLayer
1054        let _layer = build_cors_layer(&cors);
1055    }
1056
1057    #[test]
1058    fn test_build_cors_layer_wildcard_headers() {
1059        let cors = CorsConfig {
1060            allowed_origins: vec!["https://myapp.com".to_string()],
1061            allowed_headers: vec!["*".to_string()],
1062            ..CorsConfig::default()
1063        };
1064        let _layer = build_cors_layer(&cors);
1065    }
1066
1067    #[test]
1068    fn test_build_cors_layer_invalid_origin_skipped() {
1069        let cors = CorsConfig {
1070            allowed_origins: vec![
1071                "https://valid.com".to_string(),
1072                "not a valid \x00 origin".to_string(),
1073            ],
1074            ..CorsConfig::default()
1075        };
1076        // Invalid origins are silently filtered via filter_map
1077        let _layer = build_cors_layer(&cors);
1078    }
1079
1080    #[test]
1081    fn test_server_config_default_includes_cors() {
1082        let cfg = ServerConfig::default();
1083        assert!(cfg.cors.is_permissive());
1084    }
1085}