use parking_lot::RwLock;
use std::fs;
use std::io::Write;
use std::sync::Arc;
use tempfile::NamedTempFile;
use synapse_pingora::access::AccessListManager;
use synapse_pingora::config::{
ConfigError, ConfigFile, ConfigLoader, GlobalConfig, RateLimitConfig,
};
use synapse_pingora::config_manager::{
AccessListRequest, ConfigManager, CreateSiteRequest, RateLimitRequest, SiteWafRequest,
};
use synapse_pingora::ratelimit::RateLimitManager;
use synapse_pingora::site_waf::SiteWafManager;
use synapse_pingora::vhost::VhostMatcher;
fn create_temp_config(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
file
}
fn create_test_config_manager() -> ConfigManager {
let config = Arc::new(RwLock::new(ConfigFile {
server: GlobalConfig::default(),
sites: Vec::new(),
rate_limit: RateLimitConfig::default(),
profiler: Default::default(),
}));
let sites = Arc::new(RwLock::new(Vec::new()));
let vhost = Arc::new(RwLock::new(VhostMatcher::new(vec![]).unwrap()));
let waf = Arc::new(RwLock::new(SiteWafManager::new()));
let rate_limiter = Arc::new(RwLock::new(RateLimitManager::new()));
let access_lists = Arc::new(RwLock::new(AccessListManager::new()));
ConfigManager::new(config, sites, vhost, waf, rate_limiter, access_lists)
}
fn create_minimal_site_request(hostname: &str) -> CreateSiteRequest {
CreateSiteRequest {
hostname: hostname.to_string(),
upstreams: vec!["example.com:8080".to_string()],
waf: None,
rate_limit: None,
access_list: None,
}
}
#[test]
fn test_site_creation_atomicity_all_managers_updated() {
let mgr = create_test_config_manager();
let req = CreateSiteRequest {
hostname: "api.example.com".to_string(),
upstreams: vec![
"example.com:8080".to_string(),
"api.example.com:8080".to_string(),
],
waf: Some(SiteWafRequest {
enabled: true,
threshold: Some(0.75),
rule_overrides: None,
}),
rate_limit: Some(RateLimitRequest {
requests_per_second: 1000,
burst: 2000,
}),
access_list: Some(AccessListRequest {
allow: vec!["10.0.0.0/8".to_string()],
deny: vec!["10.0.0.5/32".to_string()],
}),
};
let result = mgr.create_site(req).unwrap();
assert!(result.applied, "Site creation should be marked as applied");
assert!(
result.rebuild_required,
"VhostMatcher rebuild should be required"
);
let sites = mgr.list_sites();
assert_eq!(sites.len(), 1);
assert_eq!(sites[0], "api.example.com");
let detail = mgr.get_site("api.example.com").unwrap();
assert_eq!(detail.hostname, "api.example.com");
assert_eq!(detail.upstreams.len(), 2);
assert!(detail.waf.is_some());
assert!(detail.waf.unwrap().enabled);
}
#[test]
fn test_site_creation_all_or_nothing() {
let mgr = create_test_config_manager();
let req1 = create_minimal_site_request("first.example.com");
let result = mgr.create_site(req1);
assert!(
result.is_ok(),
"First site creation should succeed: {:?}",
result.err()
);
let initial_count = mgr.list_sites().len();
assert_eq!(initial_count, 1);
let req2 = create_minimal_site_request("first.example.com");
let result = mgr.create_site(req2);
assert!(result.is_err());
let final_count = mgr.list_sites().len();
assert_eq!(final_count, initial_count);
}
#[test]
fn test_site_creation_multiple_sites_coordination() {
let mgr = create_test_config_manager();
let sites_to_create = vec![
("api.example.com", vec!["api.example.com:8080".to_string()]),
("web.example.com", vec!["web.example.com:8080".to_string()]),
("db.example.com", vec!["db.example.com:5432".to_string()]),
];
for (hostname, upstreams) in sites_to_create {
let req = CreateSiteRequest {
hostname: hostname.to_string(),
upstreams,
waf: Some(SiteWafRequest {
enabled: true,
threshold: Some(0.70),
rule_overrides: None,
}),
rate_limit: Some(RateLimitRequest {
requests_per_second: 100,
burst: 200,
}),
access_list: None,
};
mgr.create_site(req).unwrap();
}
let sites = mgr.list_sites();
assert_eq!(sites.len(), 3);
for site in &sites {
let detail = mgr.get_site(site).unwrap();
assert_eq!(detail.hostname, *site);
assert!(detail.waf.is_some());
assert!(detail.waf.unwrap().enabled);
}
}
#[test]
fn test_path_traversal_literal_dotdot_rejected() {
let yaml = r#"
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
tls:
cert_path: "../../etc/passwd"
key_path: "/tmp/key.pem"
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
matches!(result, Err(ConfigError::PathTraversal { .. })),
"Path traversal with '../' should be rejected: {:?}",
result
);
}
#[test]
fn test_path_traversal_url_encoded_rejected() {
let yaml = r#"
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
tls:
cert_path: "%2e%2e%2fetc%2fpasswd"
key_path: "/tmp/key.pem"
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
matches!(result, Err(ConfigError::PathTraversal { .. })),
"URL-encoded path traversal should be rejected: {:?}",
result
);
}
#[test]
fn test_path_traversal_url_encoded_uppercase_rejected() {
let yaml = r#"
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
tls:
cert_path: "%2E%2E/etc/passwd"
key_path: "/tmp/key.pem"
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
matches!(result, Err(ConfigError::PathTraversal { .. })),
"Case-insensitive URL-encoded path traversal should be rejected: {:?}",
result
);
}
#[test]
fn test_path_traversal_double_encoded_rejected() {
let yaml = r#"
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
tls:
cert_path: "%252e%252e/etc/passwd"
key_path: "/tmp/key.pem"
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
matches!(result, Err(ConfigError::PathTraversal { .. })),
"Double-encoded path traversal should be rejected: {:?}",
result
);
}
#[test]
fn test_path_traversal_null_byte_rejected_yaml_level() {
let yaml = "sites:
- hostname: example.com
upstreams:
- host: example.com
port: 8080
tls:
cert_path: \"/tmp/cert.pem\0/etc/passwd\"
key_path: \"/tmp/key.pem\"
";
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
result.is_err(),
"Null byte injection should be rejected: {:?}",
result
);
}
#[test]
fn test_path_traversal_url_encoded_null_byte_rejected() {
let yaml = r#"
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
tls:
cert_path: "/tmp/cert.pem%00/etc/passwd"
key_path: "/tmp/key.pem"
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
matches!(result, Err(ConfigError::PathTraversal { .. })),
"URL-encoded null byte should be rejected: {:?}",
result
);
}
#[test]
fn test_path_traversal_mixed_encoded_rejected() {
let yaml = r#"
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
tls:
cert_path: "/path/%2e./etc/passwd"
key_path: "/tmp/key.pem"
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
matches!(result, Err(ConfigError::PathTraversal { .. })),
"Mixed URL-encoded path traversal should be rejected: {:?}",
result
);
}
#[test]
fn test_path_traversal_single_dot_allowed() {
let yaml = r#"
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(result.is_ok(), "Valid config should load successfully");
}
#[test]
fn test_path_traversal_absolute_path_allowed() {
let mut cert_file = NamedTempFile::new().unwrap();
cert_file
.write_all(b"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
.unwrap();
let mut key_file = NamedTempFile::new().unwrap();
key_file
.write_all(b"-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----")
.unwrap();
let yaml = format!(
r#"
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
tls:
cert_path: "{}"
key_path: "{}"
"#,
cert_file.path().display(),
key_file.path().display()
);
let file = create_temp_config(&yaml);
let result = ConfigLoader::load(file.path());
assert!(
!matches!(result, Err(ConfigError::PathTraversal { .. })),
"Absolute paths should not trigger path traversal detection"
);
}
#[test]
fn test_waf_regex_timeout_zero_ms_rejected() {
let yaml = r#"
server:
waf_regex_timeout_ms: 0
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
match result {
Ok(_) => {
let config = ConfigLoader::load(file.path()).unwrap();
assert_eq!(config.server.waf_regex_timeout_ms, 0);
}
Err(_) => {
}
}
}
#[test]
fn test_waf_regex_timeout_one_ms_accepted() {
let yaml = r#"
server:
waf_regex_timeout_ms: 1
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path()).unwrap();
assert_eq!(result.server.waf_regex_timeout_ms, 1);
}
#[test]
fn test_waf_regex_timeout_default_100ms_accepted() {
let yaml = r#"
server:
waf_regex_timeout_ms: 100
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path()).unwrap();
assert_eq!(result.server.waf_regex_timeout_ms, 100);
}
#[test]
fn test_waf_regex_timeout_500ms_accepted() {
let yaml = r#"
server:
waf_regex_timeout_ms: 500
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path()).unwrap();
assert_eq!(result.server.waf_regex_timeout_ms, 500);
}
#[test]
fn test_waf_regex_timeout_501ms_rejected() {
let yaml = r#"
server:
waf_regex_timeout_ms: 501
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
matches!(result, Err(ConfigError::ValidationError(_))),
"Timeout > 500ms should be rejected: {:?}",
result
);
}
#[test]
fn test_waf_regex_timeout_10000ms_rejected() {
let yaml = r#"
server:
waf_regex_timeout_ms: 10000
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
matches!(result, Err(ConfigError::ValidationError(_))),
"Timeout of 10000ms should be rejected: {:?}",
result
);
}
#[test]
fn test_waf_regex_timeout_default_when_unspecified() {
let yaml = r#"
sites:
- hostname: example.com
upstreams:
- host: 127.0.0.1
port: 8080
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path()).unwrap();
assert_eq!(
result.server.waf_regex_timeout_ms, 100,
"Default should be 100ms"
);
}
#[test]
fn test_config_file_10mb_accepted() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"sites:\n - hostname: example.com\n upstreams:\n - host: 127.0.0.1\n port: 8080\n")
.unwrap();
let size_needed = (10 * 1024 * 1024) - 95; let chunk = "# This is a long comment to fill space\n".as_bytes();
let mut bytes_written = 0;
while bytes_written < size_needed {
let to_write = std::cmp::min(chunk.len(), size_needed - bytes_written);
file.write_all(&chunk[..to_write]).unwrap();
bytes_written += to_write;
}
file.flush().unwrap();
let metadata = fs::metadata(file.path()).unwrap();
assert!(
metadata.len() <= 10 * 1024 * 1024,
"File should be <= 10MB, got {} bytes",
metadata.len()
);
let result = ConfigLoader::load(file.path());
assert!(
result.is_ok(),
"10MB config should load: {:?}",
result.err()
);
}
#[test]
fn test_config_file_11mb_rejected() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"sites:\n - hostname: example.com\n upstreams:\n - host: 127.0.0.1\n port: 8080\n")
.unwrap();
let size_needed = (11 * 1024 * 1024) - 95; let chunk = "# This is a long comment to fill space\n".as_bytes();
let mut bytes_written = 0;
while bytes_written < size_needed {
let to_write = std::cmp::min(chunk.len(), size_needed - bytes_written);
file.write_all(&chunk[..to_write]).unwrap();
bytes_written += to_write;
}
file.flush().unwrap();
let metadata = fs::metadata(file.path()).unwrap();
assert!(
metadata.len() > 10 * 1024 * 1024,
"File should be > 10MB, got {} bytes",
metadata.len()
);
let result = ConfigLoader::load(file.path());
assert!(
matches!(result, Err(ConfigError::FileTooLarge { .. })),
"11MB config should be rejected as FileTooLarge: {:?}",
result
);
}
#[test]
fn test_config_file_size_validation_before_parsing() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"invalid: [yaml: {that: would}}\n").unwrap();
let size_needed = (11 * 1024 * 1024) - 30;
let chunk = "# Comment\n".as_bytes();
let mut bytes_written = 0;
while bytes_written < size_needed {
let to_write = std::cmp::min(chunk.len(), size_needed - bytes_written);
file.write_all(&chunk[..to_write]).unwrap();
bytes_written += to_write;
}
file.flush().unwrap();
let result = ConfigLoader::load(file.path());
assert!(
matches!(result, Err(ConfigError::FileTooLarge { .. })),
"Should reject large file before parsing: {:?}",
result
);
}
#[test]
fn test_validation_before_state_mutation() {
let mgr = create_test_config_manager();
let req1 = create_minimal_site_request("valid.example.com");
let result = mgr.create_site(req1);
assert!(
result.is_ok(),
"Valid site creation should succeed: {:?}",
result.err()
);
let initial_sites = mgr.list_sites();
assert_eq!(initial_sites.len(), 1);
let req_invalid = CreateSiteRequest {
hostname: "invalid.example.com".to_string(),
upstreams: vec!["127.0.0.1:8080".to_string()], waf: None,
rate_limit: None,
access_list: None,
};
let result = mgr.create_site(req_invalid);
assert!(result.is_err(), "Should reject private/loopback upstream");
let final_sites = mgr.list_sites();
assert_eq!(
final_sites.len(),
initial_sites.len(),
"Site list should not change on validation error"
);
assert_eq!(final_sites[0], "valid.example.com");
}
#[test]
fn test_config_validation_invalid_yaml_syntax() {
let yaml = "sites:\n - hostname: example.com\n invalid yaml: [unclosed";
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
result.is_err(),
"Invalid YAML should be rejected: {:?}",
result
);
if let Err(ConfigError::ParseError(_)) = result {
} else {
panic!("Expected ParseError for invalid YAML");
}
}
#[test]
fn test_validation_chain_complex_config() {
let yaml = r#"
server:
http_addr: "0.0.0.0:80"
https_addr: "0.0.0.0:443"
workers: 4
waf_threshold: 70
waf_regex_timeout_ms: 200
rate_limit:
rps: 5000
enabled: true
sites:
- hostname: api.example.com
upstreams:
- host: api-backend.example.com
port: 8080
waf:
enabled: true
threshold: 60
rate_limit:
rps: 1000
enabled: true
"#;
let file = create_temp_config(yaml);
let result = ConfigLoader::load(file.path());
assert!(
result.is_ok(),
"Valid complex config should load: {:?}",
result.err()
);
let config = result.unwrap();
assert_eq!(config.server.workers, 4);
assert_eq!(config.server.waf_regex_timeout_ms, 200);
assert_eq!(config.sites.len(), 1);
}