From ac30d890e9a7926aac2fe9c9561cb868f8dea4fd Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 11 Jun 2026 11:44:15 +0000 Subject: [PATCH] Add Clone derive and TOML deserialization tests for static config structs Add Clone derive to StaticConfig, ListenerConfig, TlsConfig, and LoggingConfig to support immutable-after-startup pattern. Add unit tests verifying TOML deserialization for multi-config (dedicated-IP) and shared-IP (SAN certificate) deployment formats, default value application, logging config, and site defaults. --- src/config/static_config.rs | 319 +++++++++++++++++++++++++++++++++++- 1 file changed, 315 insertions(+), 4 deletions(-) diff --git a/src/config/static_config.rs b/src/config/static_config.rs index 4f26c95..500dc66 100644 --- a/src/config/static_config.rs +++ b/src/config/static_config.rs @@ -1,7 +1,7 @@ use serde::Deserialize; #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct StaticConfig { pub listeners: Vec, #[serde(default)] @@ -32,7 +32,7 @@ fn default_shutdown_timeout_secs() -> u64 { } #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct ListenerConfig { pub bind_addr: String, #[serde(default = "default_http_port")] @@ -55,7 +55,7 @@ fn default_https_port() -> u16 { } #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct TlsConfig { pub mode: String, #[serde(default)] @@ -76,7 +76,7 @@ fn default_acme_directory() -> String { } #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct LoggingConfig { #[serde(default = "default_log_level")] pub level: String, @@ -105,3 +105,314 @@ impl Default for LoggingConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn multi_config_toml() -> &'static str { + r#" +health_check_port = 9900 +admin_socket_path = "/run/reverse-proxy/admin.sock" + +[logging] +level = "info" +format = "text" + +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "203.0.113.10" +http_port = 80 +https_port = 443 + +[listeners.tls] +mode = "acme" +acme_domains = ["git.alk.dev"] +acme_cache_dir = "/var/lib/reverse-proxy/acme-cache-git" +acme_directory = "production" + +[[listeners.sites]] +host = "git.alk.dev" +upstream = "127.0.0.1:3000" +upstream_scheme = "http" + +[[listeners]] +bind_addr = "203.0.113.11" +http_port = 80 +https_port = 443 + +[listeners.tls] +mode = "manual" +cert_path = "/etc/ssl/alk.dev/fullchain.pem" +key_path = "/etc/ssl/alk.dev/privkey.pem" + +[[listeners.sites]] +host = "alk.dev" +upstream = "127.0.0.1:8080" +upstream_scheme = "http" +"# + } + + fn shared_ip_san_toml() -> &'static str { + r#" +health_check_port = 9900 +admin_socket_path = "/run/reverse-proxy/admin.sock" + +[logging] +level = "info" +format = "text" + +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "203.0.113.10" +http_port = 80 +https_port = 443 + +[listeners.tls] +mode = "acme" +acme_domains = ["git.alk.dev", "alk.dev"] +acme_cache_dir = "/var/lib/reverse-proxy/acme-cache" +acme_directory = "production" + +[[listeners.sites]] +host = "git.alk.dev" +upstream = "127.0.0.1:3000" + +[[listeners.sites]] +host = "alk.dev" +upstream = "127.0.0.1:8080" +"# + } + + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct FullConfig { + #[serde(default)] + listeners: Vec, + #[serde(default)] + allow_wildcard_bind: bool, + #[serde(default = "default_health_check_port")] + health_check_port: u16, + #[serde(default = "default_admin_socket_path")] + admin_socket_path: String, + #[serde(default = "default_shutdown_timeout_secs")] + shutdown_timeout_secs: u64, + #[serde(default)] + logging: LoggingConfig, + rate_limit: crate::config::dynamic_config::RateLimitConfig, + body: crate::config::dynamic_config::BodyConfig, + } + + #[test] + fn deserialize_multi_config() { + let config: FullConfig = + toml::from_str(multi_config_toml()).expect("multi-config TOML should parse"); + + assert_eq!(config.listeners.len(), 2); + assert!(!config.allow_wildcard_bind); + assert_eq!(config.health_check_port, 9900); + assert_eq!(config.admin_socket_path, "/run/reverse-proxy/admin.sock"); + assert_eq!(config.shutdown_timeout_secs, 30); + assert_eq!(config.logging.level, "info"); + assert_eq!(config.logging.format, "text"); + assert!(config.logging.log_file_path.is_none()); + + let listener1 = &config.listeners[0]; + assert_eq!(listener1.bind_addr, "203.0.113.10"); + assert_eq!(listener1.http_port, 80); + assert_eq!(listener1.https_port, 443); + assert_eq!(listener1.tls.mode, "acme"); + assert_eq!(listener1.tls.acme_domains, vec!["git.alk.dev"]); + assert_eq!( + listener1.tls.acme_cache_dir, + "/var/lib/reverse-proxy/acme-cache-git" + ); + assert_eq!(listener1.tls.acme_directory, "production"); + assert_eq!(listener1.sites.len(), 1); + assert_eq!(listener1.sites[0].host, "git.alk.dev"); + assert_eq!(listener1.sites[0].upstream, "127.0.0.1:3000"); + assert_eq!(listener1.sites[0].upstream_scheme, "http"); + + let listener2 = &config.listeners[1]; + assert_eq!(listener2.bind_addr, "203.0.113.11"); + assert_eq!(listener2.tls.mode, "manual"); + assert_eq!(listener2.tls.cert_path, "/etc/ssl/alk.dev/fullchain.pem"); + assert_eq!(listener2.tls.key_path, "/etc/ssl/alk.dev/privkey.pem"); + assert_eq!(listener2.sites.len(), 1); + assert_eq!(listener2.sites[0].host, "alk.dev"); + } + + #[test] + fn deserialize_shared_ip_san() { + let config: FullConfig = + toml::from_str(shared_ip_san_toml()).expect("shared-IP SAN TOML should parse"); + + assert_eq!(config.listeners.len(), 1); + let listener = &config.listeners[0]; + assert_eq!(listener.bind_addr, "203.0.113.10"); + assert_eq!(listener.tls.mode, "acme"); + assert_eq!(listener.tls.acme_domains, vec!["git.alk.dev", "alk.dev"]); + assert_eq!( + listener.tls.acme_cache_dir, + "/var/lib/reverse-proxy/acme-cache" + ); + assert_eq!(listener.sites.len(), 2); + assert_eq!(listener.sites[0].host, "git.alk.dev"); + assert_eq!(listener.sites[1].host, "alk.dev"); + } + + #[test] + fn defaults_applied_when_omitted() { + let minimal = r#" +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "192.168.1.1" + +[listeners.tls] +mode = "acme" +acme_domains = ["example.com"] +acme_cache_dir = "/tmp/cache" +"#; + + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MinimalConfig { + #[serde(default)] + listeners: Vec, + #[serde(default)] + allow_wildcard_bind: bool, + #[serde(default = "default_health_check_port")] + health_check_port: u16, + #[serde(default = "default_admin_socket_path")] + admin_socket_path: String, + #[serde(default = "default_shutdown_timeout_secs")] + shutdown_timeout_secs: u64, + #[serde(default)] + logging: LoggingConfig, + rate_limit: crate::config::dynamic_config::RateLimitConfig, + body: crate::config::dynamic_config::BodyConfig, + } + + let config: MinimalConfig = toml::from_str(minimal).expect("minimal config should parse"); + + assert!(!config.allow_wildcard_bind); + assert_eq!(config.health_check_port, 9900); + assert_eq!(config.admin_socket_path, "/run/reverse-proxy/admin.sock"); + assert_eq!(config.shutdown_timeout_secs, 30); + assert_eq!(config.logging.level, "info"); + assert_eq!(config.logging.format, "text"); + assert!(config.logging.log_file_path.is_none()); + + let listener = &config.listeners[0]; + assert_eq!(listener.http_port, 80); + assert_eq!(listener.https_port, 443); + assert_eq!(listener.tls.acme_directory, "production"); + assert!(listener.sites.is_empty()); + } + + #[test] + fn logging_with_file_path() { + let toml = r#" +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[logging] +level = "debug" +format = "json" +log_file_path = "/var/log/reverse-proxy/access.log" + +[[listeners]] +bind_addr = "203.0.113.10" + +[listeners.tls] +mode = "manual" +cert_path = "/etc/ssl/cert.pem" +key_path = "/etc/ssl/key.pem" +"#; + + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct TestConfig { + #[serde(default)] + listeners: Vec, + #[serde(default)] + logging: LoggingConfig, + rate_limit: crate::config::dynamic_config::RateLimitConfig, + body: crate::config::dynamic_config::BodyConfig, + } + + let config: TestConfig = + toml::from_str(toml).expect("config with log_file_path should parse"); + + assert_eq!(config.logging.level, "debug"); + assert_eq!(config.logging.format, "json"); + assert_eq!( + config.logging.log_file_path, + Some("/var/log/reverse-proxy/access.log".to_string()) + ); + } + + #[test] + fn site_defaults() { + let toml = r#" +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "203.0.113.10" + +[listeners.tls] +mode = "acme" +acme_domains = ["test.example"] +acme_cache_dir = "/tmp/acme" + +[[listeners.sites]] +host = "test.example" +upstream = "127.0.0.1:8080" +"#; + + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct TestConfig { + #[serde(default)] + listeners: Vec, + rate_limit: crate::config::dynamic_config::RateLimitConfig, + body: crate::config::dynamic_config::BodyConfig, + } + + let config: TestConfig = toml::from_str(toml).expect("config should parse"); + + let site = &config.listeners[0].sites[0]; + assert_eq!(site.host, "test.example"); + assert_eq!(site.upstream, "127.0.0.1:8080"); + assert_eq!(site.upstream_scheme, "http"); + assert_eq!(site.upstream_connect_timeout_secs, 5); + assert_eq!(site.upstream_request_timeout_secs, 60); + } +}