1use serde::Deserialize;
7use std::path::{Path, PathBuf};
8
9pub 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#[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#[derive(Debug, Clone, PartialEq, Default)]
131pub struct TlsConfig {
132 pub cert: Option<String>,
134 pub key: Option<String>,
136}
137
138impl TlsConfig {
139 pub fn is_enabled(&self) -> bool {
141 self.cert.is_some() && self.key.is_some()
142 }
143}
144
145#[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 pub tls: TlsConfig,
154 pub shutdown_timeout_secs: u64,
155 pub rate_limit: u32,
157 pub cors: CorsConfig,
159}
160
161#[derive(Debug, Clone, PartialEq)]
169pub struct CorsConfig {
170 pub allowed_origins: Vec<String>,
172 pub allowed_methods: Vec<String>,
174 pub allowed_headers: Vec<String>,
177 pub allow_credentials: bool,
179 pub max_age_secs: u64,
181}
182
183const DEFAULT_RATE_LIMIT: u32 = 100;
185
186const 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 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
230impl ServerConfig {
235 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 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 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 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 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 pub fn auth_enabled(&self) -> bool {
323 !self.api_keys.is_empty()
324 }
325
326 pub fn tls_enabled(&self) -> bool {
328 self.tls.is_enabled()
329 }
330
331 pub fn rate_limit_enabled(&self) -> bool {
333 self.rate_limit > 0
334 }
335
336 pub fn binds_publicly(&self) -> bool {
346 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 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#[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
384fn 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
414fn 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
431pub 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
468fn 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
504pub 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#[cfg(test)]
528mod tests {
529 use super::*;
530 use std::io::Write;
531
532 #[test]
537 fn test_load_core_config_none_returns_defaults_when_no_default_file() {
538 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 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 #[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 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 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 assert_eq!(cfg.host, "10.0.0.1");
705 assert_eq!(cfg.port, 3000);
706 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"); assert_eq!(cfg.data_dir, "./velesdb_data"); }
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 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 let result = load_toml_file(&None);
844 assert!(result.is_ok());
845 }
846
847 #[test]
848 fn test_full_priority_chain() {
849 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 ..Default::default()
862 };
863 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
864
865 assert_eq!(cfg.port, 3000); assert_eq!(cfg.host, "0.0.0.0"); assert_eq!(cfg.data_dir, "/toml/data"); }
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 #[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 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); }
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 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 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 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}