1use serde::Deserialize;
7use std::path::{Path, PathBuf};
8
9#[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#[derive(Debug, Clone, PartialEq, Default)]
60pub struct TlsConfig {
61 pub cert: Option<String>,
63 pub key: Option<String>,
65}
66
67impl TlsConfig {
68 pub fn is_enabled(&self) -> bool {
70 self.cert.is_some() && self.key.is_some()
71 }
72}
73
74#[derive(Debug, Clone, PartialEq)]
76pub struct ServerConfig {
77 pub host: String,
78 pub port: u16,
79 pub data_dir: String,
80 pub api_keys: Vec<String>,
81 pub tls: TlsConfig,
83 pub shutdown_timeout_secs: u64,
84 pub rate_limit: u32,
86 pub cors: CorsConfig,
88}
89
90#[derive(Debug, Clone, PartialEq)]
98pub struct CorsConfig {
99 pub allowed_origins: Vec<String>,
101 pub allowed_methods: Vec<String>,
103 pub allowed_headers: Vec<String>,
106 pub allow_credentials: bool,
108 pub max_age_secs: u64,
110}
111
112const DEFAULT_RATE_LIMIT: u32 = 100;
114
115const DEFAULT_CORS_MAX_AGE_SECS: u64 = 3600;
117
118impl Default for CorsConfig {
119 fn default() -> Self {
120 Self {
121 allowed_origins: vec!["*".to_string()],
122 allowed_methods: vec![
123 "GET".to_string(),
124 "POST".to_string(),
125 "PUT".to_string(),
126 "DELETE".to_string(),
127 "PATCH".to_string(),
128 "OPTIONS".to_string(),
129 ],
130 allowed_headers: vec!["*".to_string()],
131 allow_credentials: false,
132 max_age_secs: DEFAULT_CORS_MAX_AGE_SECS,
133 }
134 }
135}
136
137impl CorsConfig {
138 pub fn is_permissive(&self) -> bool {
140 self.allowed_origins.iter().any(|o| o == "*")
141 }
142}
143
144impl Default for ServerConfig {
145 fn default() -> Self {
146 Self {
147 host: "127.0.0.1".to_string(),
148 port: 8080,
149 data_dir: "./velesdb_data".to_string(),
150 api_keys: Vec::new(),
151 tls: TlsConfig::default(),
152 shutdown_timeout_secs: 30,
153 rate_limit: DEFAULT_RATE_LIMIT,
154 cors: CorsConfig::default(),
155 }
156 }
157}
158
159impl ServerConfig {
164 pub fn load(cli: CliOverrides) -> anyhow::Result<Self> {
170 let defaults = Self::default();
171 let file_cfg = load_toml_file(&cli.config_path)?;
172 Ok(Self::merge(defaults, file_cfg, cli))
173 }
174
175 fn merge(defaults: Self, file: FileConfig, cli: CliOverrides) -> Self {
176 let server = file.server.unwrap_or_default();
177 let auth = file.auth.unwrap_or_default();
178 let tls = file.tls.unwrap_or_default();
179 let cors_section = file.cors.unwrap_or_default();
180
181 let host = server.host.unwrap_or(defaults.host);
183 let port = server.port.unwrap_or(defaults.port);
184 let data_dir = server.data_dir.unwrap_or(defaults.data_dir);
185 let shutdown_timeout_secs = server
186 .shutdown_timeout_secs
187 .unwrap_or(defaults.shutdown_timeout_secs);
188 let rate_limit = server.rate_limit.unwrap_or(defaults.rate_limit);
189 let api_keys = auth.api_keys.unwrap_or(defaults.api_keys);
190 let tls = TlsConfig {
191 cert: tls.cert.or(defaults.tls.cert),
192 key: tls.key.or(defaults.tls.key),
193 };
194 let cors = resolve_cors(defaults.cors, cors_section);
195
196 let host = cli.host.unwrap_or(host);
198 let port = cli.port.unwrap_or(port);
199 let data_dir = cli.data_dir.unwrap_or(data_dir);
200 let api_keys = cli.api_keys.unwrap_or(api_keys);
201 let tls = TlsConfig {
202 cert: cli.tls_cert.or(tls.cert),
203 key: cli.tls_key.or(tls.key),
204 };
205 let rate_limit = cli.rate_limit.unwrap_or(rate_limit);
206
207 Self {
208 host,
209 port,
210 data_dir,
211 api_keys,
212 tls,
213 shutdown_timeout_secs,
214 rate_limit,
215 cors,
216 }
217 }
218
219 pub fn validate(&self) -> anyhow::Result<()> {
221 if self.port == 0 {
222 anyhow::bail!("invalid port: 0 is not allowed");
223 }
224 if self.data_dir.is_empty() {
225 anyhow::bail!("data_dir must not be empty");
226 }
227
228 match (&self.tls.cert, &self.tls.key) {
230 (Some(_), None) => {
231 anyhow::bail!("tls_cert is set but tls_key is missing");
232 }
233 (None, Some(_)) => {
234 anyhow::bail!("tls_key is set but tls_cert is missing");
235 }
236 (Some(cert), Some(key)) => {
237 if !Path::new(cert).exists() {
238 anyhow::bail!("TLS cert file not found: {cert}");
239 }
240 if !Path::new(key).exists() {
241 anyhow::bail!("TLS key file not found: {key}");
242 }
243 }
244 (None, None) => {}
245 }
246
247 Ok(())
248 }
249
250 pub fn auth_enabled(&self) -> bool {
252 !self.api_keys.is_empty()
253 }
254
255 pub fn tls_enabled(&self) -> bool {
257 self.tls.is_enabled()
258 }
259
260 pub fn rate_limit_enabled(&self) -> bool {
262 self.rate_limit > 0
263 }
264
265 pub fn binds_publicly(&self) -> bool {
275 let host = self
277 .host
278 .trim()
279 .trim_start_matches('[')
280 .trim_end_matches(']')
281 .to_ascii_lowercase();
282 if matches!(host.as_str(), "localhost" | "::1") || host.starts_with("127.") {
283 return false;
284 }
285 if let Some(v4) = host.strip_prefix("::ffff:") {
287 if v4.starts_with("127.") {
288 return false;
289 }
290 }
291 true
292 }
293}
294
295#[derive(Debug, Default)]
302pub struct CliOverrides {
303 pub config_path: Option<PathBuf>,
304 pub host: Option<String>,
305 pub port: Option<u16>,
306 pub data_dir: Option<String>,
307 pub api_keys: Option<Vec<String>>,
308 pub tls_cert: Option<String>,
309 pub tls_key: Option<String>,
310 pub rate_limit: Option<u32>,
311}
312
313fn load_toml_file(path: &Option<PathBuf>) -> anyhow::Result<FileConfig> {
318 let candidate = match path {
319 Some(p) => {
320 if !p.exists() {
321 anyhow::bail!("config file not found: {}", p.display());
322 }
323 p.clone()
324 }
325 None => {
326 let default_path = PathBuf::from("velesdb.toml");
327 if !default_path.exists() {
328 return Ok(FileConfig::default());
329 }
330 default_path
331 }
332 };
333
334 let contents = std::fs::read_to_string(&candidate)
335 .map_err(|e| anyhow::anyhow!("failed to read config file {}: {e}", candidate.display()))?;
336
337 let cfg: FileConfig = toml::from_str(&contents)
338 .map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", candidate.display()))?;
339
340 Ok(cfg)
341}
342
343fn resolve_cors(defaults: CorsConfig, section: CorsSection) -> CorsConfig {
349 CorsConfig {
350 allowed_origins: section.allowed_origins.unwrap_or(defaults.allowed_origins),
351 allowed_methods: section.allowed_methods.unwrap_or(defaults.allowed_methods),
352 allowed_headers: section.allowed_headers.unwrap_or(defaults.allowed_headers),
353 allow_credentials: section
354 .allow_credentials
355 .unwrap_or(defaults.allow_credentials),
356 max_age_secs: section.max_age_secs.unwrap_or(defaults.max_age_secs),
357 }
358}
359
360pub fn build_cors_layer(cors: &CorsConfig) -> tower_http::cors::CorsLayer {
366 use tower_http::cors::{AllowOrigin, CorsLayer};
367
368 if cors.is_permissive() {
369 return CorsLayer::permissive();
370 }
371
372 let origins: Vec<axum::http::HeaderValue> = cors
373 .allowed_origins
374 .iter()
375 .filter_map(|o| o.parse().ok())
376 .collect();
377 let methods: Vec<axum::http::Method> = cors
378 .allowed_methods
379 .iter()
380 .filter_map(|m| m.parse().ok())
381 .collect();
382
383 let layer = CorsLayer::new()
384 .allow_origin(AllowOrigin::list(origins))
385 .allow_methods(methods)
386 .max_age(std::time::Duration::from_secs(cors.max_age_secs));
387
388 let layer = apply_cors_headers_policy(layer, cors);
389
390 if cors.allow_credentials {
391 layer.allow_credentials(true)
392 } else {
393 layer
394 }
395}
396
397fn apply_cors_headers_policy(
401 layer: tower_http::cors::CorsLayer,
402 cors: &CorsConfig,
403) -> tower_http::cors::CorsLayer {
404 use tower_http::cors::Any;
405
406 let has_wildcard = cors.allowed_headers.iter().any(|h| h == "*");
407 if has_wildcard && !cors.allow_credentials {
408 return layer.allow_headers(Any);
409 }
410 if has_wildcard && cors.allow_credentials {
411 tracing::warn!(
412 "CORS: allow_credentials=true is incompatible with wildcard \
413 headers per CORS spec. Falling back to default headers \
414 (Content-Type, Authorization)."
415 );
416 }
417 let headers: Vec<axum::http::HeaderName> = cors
418 .allowed_headers
419 .iter()
420 .filter(|h| h.as_str() != "*")
421 .filter_map(|h| h.parse().ok())
422 .collect();
423 if headers.is_empty() && cors.allow_credentials {
424 layer.allow_headers([
425 axum::http::header::CONTENT_TYPE,
426 axum::http::header::AUTHORIZATION,
427 ])
428 } else {
429 layer.allow_headers(headers)
430 }
431}
432
433pub fn parse_api_keys_env() -> Option<Vec<String>> {
439 let val = std::env::var("VELESDB_API_KEYS").ok()?;
440 let keys: Vec<String> = val
441 .split(',')
442 .map(|s| s.trim().to_string())
443 .filter(|s| !s.is_empty())
444 .collect();
445 if keys.is_empty() {
446 None
447 } else {
448 Some(keys)
449 }
450}
451
452#[cfg(test)]
457mod tests {
458 use super::*;
459 use std::io::Write;
460
461 #[test]
462 fn test_binds_publicly() {
463 let mut cfg = ServerConfig::default();
464 for host in [
467 "127.0.0.1",
468 "::1",
469 "[::1]",
470 "localhost",
471 "LOCALHOST",
472 " localhost ",
473 "127.0.0.5",
474 "::ffff:127.0.0.1",
475 ] {
476 cfg.host = host.to_string();
477 assert!(!cfg.binds_publicly(), "{host} should be private");
478 }
479 for host in ["0.0.0.0", "::", "192.168.1.10", "10.0.0.1", ""] {
481 cfg.host = host.to_string();
482 assert!(cfg.binds_publicly(), "{host} should be public");
483 }
484 }
485
486 #[test]
487 fn test_defaults() {
488 let cfg = ServerConfig::default();
489 assert_eq!(cfg.host, "127.0.0.1");
490 assert_eq!(cfg.port, 8080);
491 assert_eq!(cfg.data_dir, "./velesdb_data");
492 assert!(cfg.api_keys.is_empty());
493 assert!(cfg.tls.cert.is_none());
494 assert!(cfg.tls.key.is_none());
495 assert_eq!(cfg.shutdown_timeout_secs, 30);
496 assert_eq!(cfg.rate_limit, 100);
497 assert!(!cfg.auth_enabled());
498 assert!(!cfg.tls_enabled());
499 assert!(cfg.rate_limit_enabled());
500 assert!(cfg.cors.is_permissive());
501 }
502
503 #[test]
504 fn test_toml_overrides_defaults() {
505 let toml_content = r#"
506[server]
507host = "0.0.0.0"
508port = 9090
509data_dir = "/var/velesdb"
510shutdown_timeout_secs = 60
511
512[auth]
513api_keys = ["key-alpha", "key-beta"]
514
515[tls]
516cert = "/etc/ssl/cert.pem"
517key = "/etc/ssl/key.pem"
518"#;
519 let file_cfg: FileConfig =
520 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
521 let cli = CliOverrides::default();
522 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
523
524 assert_eq!(cfg.host, "0.0.0.0");
525 assert_eq!(cfg.port, 9090);
526 assert_eq!(cfg.data_dir, "/var/velesdb");
527 assert_eq!(cfg.shutdown_timeout_secs, 60);
528 assert_eq!(cfg.api_keys, vec!["key-alpha", "key-beta"]);
529 assert_eq!(cfg.tls.cert.as_deref(), Some("/etc/ssl/cert.pem"));
530 assert_eq!(cfg.tls.key.as_deref(), Some("/etc/ssl/key.pem"));
531 assert!(cfg.auth_enabled());
532 assert!(cfg.tls_enabled());
533 }
534
535 #[test]
536 fn test_cli_overrides_toml() {
537 let toml_content = r#"
538[server]
539host = "0.0.0.0"
540port = 9090
541"#;
542 let file_cfg: FileConfig =
543 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
544 let cli = CliOverrides {
545 port: Some(3000),
546 host: Some("10.0.0.1".to_string()),
547 ..Default::default()
548 };
549 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
550
551 assert_eq!(cfg.host, "10.0.0.1");
553 assert_eq!(cfg.port, 3000);
554 assert_eq!(cfg.data_dir, "./velesdb_data");
556 }
557
558 #[test]
559 fn test_partial_toml_uses_defaults_for_missing() {
560 let toml_content = r#"
561[server]
562port = 4000
563"#;
564 let file_cfg: FileConfig =
565 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
566 let cli = CliOverrides::default();
567 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
568
569 assert_eq!(cfg.port, 4000);
570 assert_eq!(cfg.host, "127.0.0.1"); assert_eq!(cfg.data_dir, "./velesdb_data"); }
573
574 #[test]
575 fn test_empty_toml_uses_all_defaults() {
576 let file_cfg: FileConfig = toml::from_str("").expect("test: empty TOML parses to default");
577 let cli = CliOverrides::default();
578 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
579
580 assert_eq!(cfg, ServerConfig::default());
581 }
582
583 #[test]
584 fn test_validate_port_zero_rejected() {
585 let cfg = ServerConfig {
586 port: 0,
587 ..ServerConfig::default()
588 };
589 let err = cfg.validate().unwrap_err();
590 assert!(err.to_string().contains("port"));
591 }
592
593 #[test]
594 fn test_validate_empty_data_dir_rejected() {
595 let cfg = ServerConfig {
596 data_dir: String::new(),
597 ..ServerConfig::default()
598 };
599 let err = cfg.validate().unwrap_err();
600 assert!(err.to_string().contains("data_dir"));
601 }
602
603 #[test]
604 fn test_validate_tls_cert_without_key() {
605 let cfg = ServerConfig {
606 tls: TlsConfig {
607 cert: Some("/tmp/cert.pem".to_string()),
608 key: None,
609 },
610 ..ServerConfig::default()
611 };
612 let err = cfg.validate().unwrap_err();
613 assert!(err.to_string().contains("tls_key is missing"));
614 }
615
616 #[test]
617 fn test_validate_tls_key_without_cert() {
618 let cfg = ServerConfig {
619 tls: TlsConfig {
620 cert: None,
621 key: Some("/tmp/key.pem".to_string()),
622 },
623 ..ServerConfig::default()
624 };
625 let err = cfg.validate().unwrap_err();
626 assert!(err.to_string().contains("tls_cert is missing"));
627 }
628
629 #[test]
630 fn test_validate_tls_missing_cert_file() {
631 let cfg = ServerConfig {
632 tls: TlsConfig {
633 cert: Some("/nonexistent/cert.pem".to_string()),
634 key: Some("/nonexistent/key.pem".to_string()),
635 },
636 ..ServerConfig::default()
637 };
638 let err = cfg.validate().unwrap_err();
639 assert!(err.to_string().contains("cert file not found"));
640 }
641
642 #[test]
643 fn test_validate_tls_valid_files() {
644 let dir = tempfile::tempdir().expect("test: create temp dir");
645 let cert_path = dir.path().join("cert.pem");
646 let key_path = dir.path().join("key.pem");
647 std::fs::File::create(&cert_path)
648 .expect("test: create cert file")
649 .write_all(b"cert")
650 .expect("test: write cert content");
651 std::fs::File::create(&key_path)
652 .expect("test: create key file")
653 .write_all(b"key")
654 .expect("test: write key content");
655
656 let cfg = ServerConfig {
657 tls: TlsConfig {
658 cert: Some(cert_path.to_string_lossy().to_string()),
659 key: Some(key_path.to_string_lossy().to_string()),
660 },
661 ..ServerConfig::default()
662 };
663 cfg.validate().expect("valid TLS config should pass");
664 }
665
666 #[test]
667 fn test_parse_api_keys_env() {
668 let input = "key1, key2 , key3";
670 let keys: Vec<String> = input
671 .split(',')
672 .map(|s| s.trim().to_string())
673 .filter(|s| !s.is_empty())
674 .collect();
675 assert_eq!(keys, vec!["key1", "key2", "key3"]);
676 }
677
678 #[test]
679 fn test_load_toml_file_not_found_explicit_path() {
680 let result = load_toml_file(&Some(PathBuf::from("/nonexistent/velesdb.toml")));
681 assert!(result.is_err());
682 assert!(result
683 .unwrap_err()
684 .to_string()
685 .contains("config file not found"));
686 }
687
688 #[test]
689 fn test_load_toml_file_no_default_returns_empty() {
690 let result = load_toml_file(&None);
692 assert!(result.is_ok());
693 }
694
695 #[test]
696 fn test_full_priority_chain() {
697 let toml_content = r#"
699[server]
700port = 9090
701host = "0.0.0.0"
702data_dir = "/toml/data"
703"#;
704 let file_cfg: FileConfig =
705 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
706 let cli = CliOverrides {
707 port: Some(3000),
708 ..Default::default()
710 };
711 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
712
713 assert_eq!(cfg.port, 3000); assert_eq!(cfg.host, "0.0.0.0"); assert_eq!(cfg.data_dir, "/toml/data"); }
717
718 #[test]
719 fn test_rate_limit_from_toml() {
720 let toml_content = r#"
721[server]
722rate_limit = 50
723"#;
724 let file_cfg: FileConfig =
725 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
726 let cli = CliOverrides::default();
727 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
728
729 assert_eq!(cfg.rate_limit, 50);
730 assert!(cfg.rate_limit_enabled());
731 }
732
733 #[test]
734 fn test_rate_limit_disabled_via_toml() {
735 let toml_content = r#"
736[server]
737rate_limit = 0
738"#;
739 let file_cfg: FileConfig =
740 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
741 let cli = CliOverrides::default();
742 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
743
744 assert_eq!(cfg.rate_limit, 0);
745 assert!(!cfg.rate_limit_enabled());
746 }
747
748 #[test]
749 fn test_rate_limit_cli_overrides_toml() {
750 let toml_content = r#"
751[server]
752rate_limit = 50
753"#;
754 let file_cfg: FileConfig =
755 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
756 let cli = CliOverrides {
757 rate_limit: Some(200),
758 ..Default::default()
759 };
760 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
761
762 assert_eq!(cfg.rate_limit, 200);
763 }
764
765 #[test]
766 fn test_rate_limit_cli_disables() {
767 let file_cfg = FileConfig::default();
768 let cli = CliOverrides {
769 rate_limit: Some(0),
770 ..Default::default()
771 };
772 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
773
774 assert_eq!(cfg.rate_limit, 0);
775 assert!(!cfg.rate_limit_enabled());
776 }
777
778 #[test]
783 fn test_cors_default_is_permissive() {
784 let cors = CorsConfig::default();
785 assert!(cors.is_permissive());
786 assert_eq!(cors.allowed_origins, vec!["*"]);
787 assert_eq!(cors.allowed_headers, vec!["*"]);
788 assert!(!cors.allow_credentials);
789 assert_eq!(cors.max_age_secs, 3600);
790 }
791
792 #[test]
793 fn test_cors_specific_origins_not_permissive() {
794 let cors = CorsConfig {
795 allowed_origins: vec![
796 "https://app.example.com".to_string(),
797 "https://admin.example.com".to_string(),
798 ],
799 ..CorsConfig::default()
800 };
801 assert!(!cors.is_permissive());
802 assert_eq!(cors.allowed_origins.len(), 2);
803 }
804
805 #[test]
806 fn test_cors_from_toml_specific_origins() {
807 let toml_content = r#"
808[cors]
809allowed_origins = ["https://app.example.com", "https://admin.example.com"]
810allowed_methods = ["GET", "POST"]
811allowed_headers = ["Content-Type", "Authorization"]
812allow_credentials = true
813max_age_secs = 7200
814"#;
815 let file_cfg: FileConfig =
816 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
817 let cli = CliOverrides::default();
818 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
819
820 assert!(!cfg.cors.is_permissive());
821 assert_eq!(
822 cfg.cors.allowed_origins,
823 vec!["https://app.example.com", "https://admin.example.com"]
824 );
825 assert_eq!(cfg.cors.allowed_methods, vec!["GET", "POST"]);
826 assert_eq!(
827 cfg.cors.allowed_headers,
828 vec!["Content-Type", "Authorization"]
829 );
830 assert!(cfg.cors.allow_credentials);
831 assert_eq!(cfg.cors.max_age_secs, 7200);
832 }
833
834 #[test]
835 fn test_cors_from_toml_partial_uses_defaults() {
836 let toml_content = r#"
837[cors]
838allowed_origins = ["https://myapp.com"]
839"#;
840 let file_cfg: FileConfig =
841 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
842 let cli = CliOverrides::default();
843 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
844
845 assert!(!cfg.cors.is_permissive());
846 assert_eq!(cfg.cors.allowed_origins, vec!["https://myapp.com"]);
847 assert_eq!(cfg.cors.allowed_headers, vec!["*"]);
849 assert!(!cfg.cors.allow_credentials);
850 assert_eq!(cfg.cors.max_age_secs, 3600);
851 assert_eq!(cfg.cors.allowed_methods.len(), 6); }
853
854 #[test]
855 fn test_cors_absent_from_toml_uses_permissive_default() {
856 let toml_content = r#"
857[server]
858port = 9090
859"#;
860 let file_cfg: FileConfig =
861 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
862 let cli = CliOverrides::default();
863 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
864
865 assert!(cfg.cors.is_permissive());
866 assert_eq!(cfg.cors, CorsConfig::default());
867 }
868
869 #[test]
870 fn test_cors_empty_section_uses_defaults() {
871 let toml_content = r#"
872[cors]
873"#;
874 let file_cfg: FileConfig =
875 toml::from_str(toml_content).expect("test: valid FileConfig TOML");
876 let cli = CliOverrides::default();
877 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
878
879 assert!(cfg.cors.is_permissive());
880 }
881
882 #[test]
883 fn test_build_cors_layer_permissive() {
884 let cors = CorsConfig::default();
885 let _layer = build_cors_layer(&cors);
887 }
888
889 #[test]
890 fn test_build_cors_layer_specific_origins() {
891 let cors = CorsConfig {
892 allowed_origins: vec![
893 "https://app.example.com".to_string(),
894 "http://localhost:3000".to_string(),
895 ],
896 allowed_methods: vec!["GET".to_string(), "POST".to_string()],
897 allowed_headers: vec!["Content-Type".to_string(), "Authorization".to_string()],
898 allow_credentials: true,
899 max_age_secs: 600,
900 };
901 let _layer = build_cors_layer(&cors);
903 }
904
905 #[test]
906 fn test_build_cors_layer_wildcard_headers() {
907 let cors = CorsConfig {
908 allowed_origins: vec!["https://myapp.com".to_string()],
909 allowed_headers: vec!["*".to_string()],
910 ..CorsConfig::default()
911 };
912 let _layer = build_cors_layer(&cors);
913 }
914
915 #[test]
916 fn test_build_cors_layer_invalid_origin_skipped() {
917 let cors = CorsConfig {
918 allowed_origins: vec![
919 "https://valid.com".to_string(),
920 "not a valid \x00 origin".to_string(),
921 ],
922 ..CorsConfig::default()
923 };
924 let _layer = build_cors_layer(&cors);
926 }
927
928 #[test]
929 fn test_server_config_default_includes_cors() {
930 let cfg = ServerConfig::default();
931 assert!(cfg.cors.is_permissive());
932 }
933}