Files
RustProxy/src/config/tests.rs
T
2026-10-17 20:34:13 +02:00

957 lines
37 KiB
Rust

mod tests {
use super::*;
fn policy() -> ValidationPolicy {
ValidationPolicy {
tls_addr: None,
cert_dir: None,
data_dir: None,
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
}
}
#[test]
fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes() {
let snapshot = compile(demo_config("127.0.0.1:9000".parse().unwrap()), 1, &policy()).unwrap();
let local = "127.0.0.1:8080".parse().unwrap();
let first = snapshot.select_on_arc("localhost", "/", local, false).unwrap();
let second = snapshot.select_on_arc("localhost", "/child", local, false).unwrap();
// Both selectors point into one immutable compiled snapshot.
assert!(std::sync::Arc::ptr_eq(first, second));
let legacy = snapshot.select_on("localhost", "/", local, false).unwrap();
assert!(std::ptr::eq(first.as_ref(), legacy));
assert!(snapshot.select_on_arc("not-this-host", "/", local, false).is_none());
}
#[test]
fn missing_http2_settings_use_release_defaults() {
let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(config.http2, crate::settings::Http2Connections::default());
config.http2.validate().unwrap();
}
#[test]
fn normalized_unicode_prefix_survives_serialization_and_recompile() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].routes[0].path_prefix = "/caf%C3%A9".into();
let first = compile(config, 0, &policy()).unwrap();
let saved = serde_json::to_string(&first.envelope.config).unwrap();
let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap();
assert!(second.select("localhost", "/caf\u{e9}/hello").is_some());
}
#[test]
fn web_server_main_source_needs_no_backend_pool() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "site".into(),
name: "Site".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.html".into()],
directory_listing: false,
..Default::default()
});
config.vhosts[0].source = crate::vhost::Source::WebServer {
web_service_id: "site".into(),
};
config.vhosts[0].backends.clear();
config.vhosts[0].routes.clear();
let snapshot = compile(config, 1, &policy()).unwrap();
let route = snapshot.select("localhost", "/").unwrap();
assert!(matches!(&route.source, CompiledSource::WebServer(_)));
assert!(route.pool.is_none());
assert!(route.backend.is_none());
assert!(snapshot.pools.is_empty());
}
#[test]
fn web_server_and_reverse_proxy_can_share_one_vhost() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "site".into(),
name: "Site".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.html".into()],
directory_listing: false,
..Default::default()
});
let host = &mut config.vhosts[0];
host.source = crate::vhost::Source::WebServer {
web_service_id: "site".into(),
};
host.routes = vec![crate::vhost::PathRoute {
id: "api".into(),
path_prefix: "/api".into(),
source: crate::vhost::Source::ReverseProxy,
..Default::default()
}];
let snapshot = compile(config, 1, &policy()).unwrap();
let root = snapshot.select("localhost", "/").unwrap();
let api = snapshot.select("localhost", "/api/test").unwrap();
assert!(matches!(&root.source, CompiledSource::WebServer(_)));
assert!(matches!(&api.source, CompiledSource::ReverseProxy));
assert!(api.pool.is_some());
assert!(api.backend.is_some());
}
#[test]
fn one_web_service_can_be_reused_by_multiple_vhosts() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.html"), b"shared").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "shared-site".into(),
name: "Shared site".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.html".into()],
directory_listing: false,
..Default::default()
});
let first = &mut config.vhosts[0];
first.source = crate::vhost::Source::WebServer {
web_service_id: "shared-site".into(),
};
first.backends.clear();
first.routes.clear();
let mut second = first.clone();
second.id = "shared-second".into();
second.frontend.hosts = vec!["second.localhost".into()];
config.vhosts.push(second);
let snapshot = compile(config, 1, &policy()).unwrap();
let first_route = snapshot.select("localhost", "/").unwrap();
let second_route = snapshot.select("second.localhost", "/").unwrap();
assert!(
matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
);
assert!(
matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
);
}
#[test]
fn unknown_web_service_reference_is_rejected() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].source = crate::vhost::Source::WebServer {
web_service_id: "missing".into(),
};
let error = compile(config, 1, &policy()).unwrap_err().to_string();
assert!(error.contains("unknown Web Service"), "{error}");
}
#[test]
fn missing_source_fields_remain_reverse_proxy_for_old_configs() {
let source: crate::vhost::Source =
serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap();
assert_eq!(source, crate::vhost::Source::ReverseProxy);
let route: crate::vhost::PathRoute =
serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap();
assert_eq!(route.source, crate::vhost::Source::ReverseProxy);
}
#[test]
fn host_normalization() {
assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com");
assert_eq!(request_host("example.com:65535").unwrap(), "example.com");
assert_eq!(request_host("[::1]").unwrap(), "::1");
assert_eq!(request_host("[::1]:8080").unwrap(), "::1");
assert_eq!(request_host("a:").unwrap(), "a");
assert_eq!(request_host("[::1]:").unwrap(), "::1");
for invalid in [
"",
"a..b",
"*.test",
"a/b",
"user@host",
"-bad.test",
"a:99999",
"a:0",
"a:abc",
"[::1]:99999",
"[::1]:0",
"[::1]suffix",
"[::1]suffix:80",
] {
assert!(request_host(invalid).is_err(), "{invalid}");
}
}
#[test]
fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() {
assert_eq!(
normalize_host_pattern("*.Example.TEST.").unwrap(),
"*.example.test"
);
for invalid in ["*", "foo.*.test", "*.com", "*.*.example.test"] {
assert!(normalize_host_pattern(invalid).is_err(), "{invalid}");
}
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].id = "wildcard".into();
c.vhosts[0].frontend.hosts = vec!["*.example.test".into()];
let mut exact = c.vhosts[0].clone();
exact.id = "exact".into();
exact.frontend.hosts = vec!["api.example.test".into()];
c.vhosts.push(exact);
let snapshot = compile(c, 1, &policy()).unwrap();
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
assert_eq!(
snapshot
.select_on("api.example.test", "/", local, false)
.unwrap()
.id,
"exact/main"
);
assert_eq!(
snapshot
.select_on("www.example.test", "/", local, false)
.unwrap()
.id,
"wildcard/main"
);
}
#[test]
fn logging_vhost_resolution_reuses_router_host_precedence() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].id = "wildcard".into();
config.vhosts[0].frontend.hosts = vec!["*.example.test".into()];
let mut exact = config.vhosts[0].clone();
exact.id = "exact".into();
exact.frontend.hosts = vec!["api.example.test".into()];
config.vhosts.push(exact);
let snapshot = compile(config, 1, &policy()).unwrap();
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let routed = snapshot
.resolve_logging_vhost(
local,
false,
Some("wildcard"),
Some("api.example.test"),
None,
None,
)
.unwrap();
assert_eq!(routed.vhost, "wildcard");
assert_eq!(routed.matched_by, "router");
let exact = snapshot
.resolve_logging_vhost(local, false, None, Some("API.EXAMPLE.TEST:8080"), None, None)
.unwrap();
assert_eq!(exact.vhost, "exact");
assert_eq!(exact.matched_by, "authority");
let wildcard = snapshot
.resolve_logging_vhost(local, false, None, None, Some("www.example.test:8080"), None)
.unwrap();
assert_eq!(wildcard.vhost, "wildcard");
assert_eq!(wildcard.matched_by, "host");
let sni = snapshot
.resolve_logging_vhost(local, false, None, None, None, Some("api.example.test"))
.unwrap();
assert_eq!(sni.vhost, "exact");
assert_eq!(sni.matched_by, "sni");
assert!(snapshot
.resolve_logging_vhost(local, false, None, None, None, Some("unknown.test"))
.is_none());
assert!(snapshot
.resolve_logging_vhost(local, false, None, None, None, None)
.is_none());
}
#[test]
fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.acl_profiles = serde_json::from_value(serde_json::json!([{
"id":"office", "name":"Office",
"acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]},
"country_access":{"mode":"block","countries":["RU"]}
}]))
.unwrap();
c.vhosts[0].acl_profile_id = Some("office".into());
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let frontend = snapshot.frontend("localhost", local, false).unwrap();
assert_eq!(frontend.acl_profile_id.as_deref(), Some("office"));
assert!(frontend
.profile_acl
.as_ref()
.unwrap()
.allows("10.2.3.4".parse().unwrap()));
assert!(!frontend
.profile_acl
.as_ref()
.unwrap()
.allows("192.0.2.1".parse().unwrap()));
assert!(!frontend.profile_country_access.allows(Some("RU")));
c.vhosts[0].acl_profile_id = Some("missing".into());
assert!(compile(c, 2, &policy()).is_err());
}
#[test]
fn rfc8441_is_advertised_only_on_opted_in_h2_capable_listener() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].websockets = true;
c.vhosts[0].websocket_rfc8441 = true;
let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string();
assert!(error.contains("HTTP/2-capable listener"), "{error}");
c.listeners.endpoints[0].binding.h2c = true;
let snapshot = compile(c.clone(), 2, &policy()).unwrap();
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
assert!(snapshot.websocket_rfc8441_on(local, false));
assert!(!snapshot.websocket_rfc8441_on(local, true));
c.vhosts[0].websocket_rfc8441 = false;
let snapshot = compile(c, 3, &policy()).unwrap();
assert!(!snapshot.websocket_rfc8441_on(local, false));
}
#[test]
fn force_ssl_requires_plain_http_and_chooses_https_target() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].force_ssl = true;
let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string();
assert!(
error.contains("at least one selected TLS listener"),
"{error}"
);
c.listeners.endpoints.push(crate::settings::NamedListener {
id: "https-a".into(),
name: "HTTPS A".into(),
scope: crate::settings::ListenerScope::Internal,
enabled: true,
binding: crate::vhost::Listener {
address: "127.0.0.1:8443".parse().unwrap(),
tls: true,
http2: true,
http2_settings: Some(crate::settings::Http2Connections::default()),
h2c: false,
http3: None,
},
additional_addresses: vec![],
});
c.listeners.endpoints.push(crate::settings::NamedListener {
id: "https-b".into(),
name: "HTTPS B".into(),
scope: crate::settings::ListenerScope::Internal,
enabled: true,
binding: crate::vhost::Listener {
address: "127.0.0.1:9443".parse().unwrap(),
tls: true,
http2: true,
http2_settings: Some(crate::settings::Http2Connections::default()),
h2c: false,
http3: None,
},
additional_addresses: vec![],
});
c.vhosts[0].frontend.listener_ids = vec!["http".into(), "https-a".into(), "https-b".into()];
let error = compile(c.clone(), 2, &policy()).unwrap_err().to_string();
assert!(error.contains("cannot choose an HTTPS target"), "{error}");
c.listeners
.endpoints
.iter_mut()
.find(|item| item.id == "https-b")
.unwrap()
.binding
.address = "127.0.0.1:443".parse().unwrap();
let snapshot = compile(c.clone(), 3, &policy()).unwrap();
let frontend = snapshot
.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false)
.unwrap();
assert_eq!(frontend.https_redirect_port, Some(443));
c.vhosts[0].frontend.listener_ids = vec!["https-a".into(), "https-b".into()];
let error = compile(c, 4, &policy()).unwrap_err().to_string();
assert!(error.contains("plain HTTP listener"), "{error}");
}
#[test]
fn preserve_host_is_valid_with_every_upstream_protocol() {
for protocol in [
UpstreamProtocol::Http1,
UpstreamProtocol::Auto,
UpstreamProtocol::Http2,
UpstreamProtocol::H2,
] {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let member = &mut config.vhosts[0].backends[0];
member.url = "https://10.87.20.2:443".into();
member.allow_private = true;
member.preserve_host = true;
member.protocol = protocol;
let snapshot = compile(config, 1, &policy()).unwrap();
assert!(snapshot.pools[0].members[0].backend.preserve_host);
}
}
#[test]
fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
c.vhosts[0].backends[0].allow_private = true;
c.vhosts[0].backends[0].tls_server_name = Some("Backend.Unitra.Internal.".into());
let s = compile(c, 1, &policy()).unwrap();
let backend = &s.pools[0].members[0].backend;
assert_eq!(backend.origin, "https://10.87.20.2:443");
assert_eq!(
backend.request_origin,
"https://backend.unitra.internal:443"
);
}
#[test]
fn tls_server_name_is_https_dns_only() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].backends[0].tls_server_name = Some("backend.internal".into());
assert!(compile(c.clone(), 1, &policy()).is_err());
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
c.vhosts[0].backends[0].tls_server_name = Some("10.87.20.2".into());
assert!(compile(c, 1, &policy()).is_err());
}
#[test]
fn backend_tls_verification_defaults_off_and_can_be_enabled_explicitly() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
assert!(!c.vhosts[0].backends[0].tls_verify);
assert!(compile(c.clone(), 1, &policy()).is_ok());
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
c.vhosts[0].backends[0].allow_private = true;
c.vhosts[0].backends[0].tls_ca_pem = Some("not a certificate".into());
assert!(compile(c.clone(), 1, &policy()).is_ok());
c.vhosts[0].backends[0].tls_verify = true;
assert!(compile(c, 1, &policy()).is_err());
}
#[test]
fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.redirects = serde_json::from_value(serde_json::json!([{
"id":"legacy","name":"Legacy","frontend":{"hosts":["old.localhost"],"listener_ids":["http"]},
"rules":[{"id":"root","target":"https://new.localhost/","status_code":308}]
}])).unwrap();
let snapshot = compile(c, 1, &policy()).unwrap();
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let (_host, rule) = snapshot
.redirect_on("old.localhost", "/a", local, false)
.unwrap();
assert_eq!(rule.id, "root");
assert!(snapshot
.select_on("old.localhost", "/a", local, false)
.is_none());
}
#[test]
fn custom_locations_override_but_main_vhost_remains_fallback() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
// The demo document still contains the pre-0.16 synthetic root route.
// Validation must normalize it away and keep the main backend internally.
let mut custom = c.vhosts[0].routes[0].clone();
custom.id = "test".into();
custom.path_prefix = "/test".into();
custom.response = Some(crate::vhost::LocationResponse {
status: 200,
content_type: "text/plain".into(),
body: "ok".into(),
});
c.vhosts[0].routes.push(custom);
let snapshot = compile(c, 1, &policy()).unwrap();
assert_eq!(
snapshot.select("localhost", "/test").unwrap().id,
"demo/test"
);
assert_eq!(
snapshot.select("localhost", "/test/a").unwrap().id,
"demo/test"
);
assert_eq!(snapshot.select("localhost", "/").unwrap().id, "demo/main");
assert_eq!(
snapshot.select("localhost", "/abc").unwrap().id,
"demo/main"
);
assert_eq!(
snapshot.select("localhost", "/static/a.js").unwrap().id,
"demo/main"
);
assert!(snapshot.envelope.config.vhosts[0]
.routes
.iter()
.all(|route| !route.is_legacy_main_fallback()));
}
#[test]
fn deliberate_root_custom_location_can_override_main_fallback() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
let root = &mut c.vhosts[0].routes[0];
root.response = Some(crate::vhost::LocationResponse {
status: 204,
content_type: "text/plain".into(),
body: String::new(),
});
let snapshot = compile(c, 1, &policy()).unwrap();
assert_eq!(
snapshot.select("localhost", "/anything").unwrap().id,
"demo/root"
);
}
#[test]
fn direct_admin_listener_backend_is_marked_for_guarded_control_plane_use() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.administration.listen = Some("0.0.0.0:9090".parse().unwrap());
c.administration.tls = true;
c.vhosts[0].backends[0].url = "https://127.0.0.1:9090".into();
c.vhosts[0].backends[0].allow_private = true;
c.vhosts[0].backends[0].accept_local_listener_risk = true;
let snapshot = compile(c, 1, &policy()).unwrap();
assert!(snapshot.pools[0].members[0].backend.targets_admin_listener);
}
#[test]
fn backend_diagnostic_flags_compile_to_frontend() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].backend_header = true;
c.vhosts[0].backend_timing_header = true;
let snapshot = compile(c, 1, &policy()).unwrap();
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let frontend = snapshot.frontend("localhost", local, false).unwrap();
assert!(frontend.backend_header);
assert!(frontend.backend_timing_header);
}
#[test]
fn path_boundaries() {
assert!(prefix_matches("/api", "/api"));
assert!(prefix_matches("/api", "/api/users"));
assert!(!prefix_matches("/api", "/apix"));
assert!(prefix_matches("/", "/anything"));
}
#[test]
fn invalid_per_backend_health_policy_rejected_without_global_override() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck {
mode: crate::balance::HealthMode::Http,
interval_ms: 300,
timeout_ms: 400,
slow_response_ms: 0,
..Default::default()
});
assert!(compile(c.clone(), 1, &policy()).is_err());
c.vhosts[0].backends[0].health_check.as_mut().unwrap().timeout_ms = 200;
assert!(compile(c, 1, &policy()).is_ok());
}
#[test]
fn private_requires_opt_in_and_metadata_always_denied() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
assert!(compile(c.clone(), 1, &policy()).is_ok());
c.vhosts[0].backends[0].allow_private = false;
assert!(compile(c.clone(), 1, &policy()).is_err());
c.vhosts[0].backends[0].allow_private = true;
for target in [
"http://169.254.169.254",
"http://100.100.100.200",
"http://0.0.0.0",
"http://127.0.0.1:9090",
"http://[::ffff:169.254.169.254]",
"http://[fe80::1]",
"http://user:pass@127.0.0.1:9000",
] {
c.vhosts[0].backends[0].url = target.into();
assert!(compile(c.clone(), 1, &policy()).is_err(), "{target}");
}
c.vhosts[0].backends[0].url = "http://127.0.0.1:9090".into();
c.vhosts[0].backends[0].accept_local_listener_risk = true;
assert!(compile(c.clone(), 1, &policy()).is_ok());
c.vhosts[0].backends[0].allow_private = false;
assert!(compile(c, 1, &policy()).is_err());
}
#[test]
fn longest_prefix_and_aliases() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
let mut specific = c.vhosts[0].routes[0].clone();
specific.id = "api".into();
specific.path_prefix = "/api".into();
c.vhosts[0].routes.push(specific);
let s = compile(c, 7, &policy()).unwrap();
assert_eq!(s.select("localhost", "/api/x").unwrap().id, "demo/api");
assert_eq!(s.select("demo.localhost", "/apix").unwrap().id, "demo/main");
assert!(s.select("unknown", "/").is_none());
assert_eq!(s.envelope.revision, 7);
}
#[test]
fn nginx_style_location_precedence_exact_priority_regex_prefix() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].routes[0].id = "root".into();
let mut prefix = c.vhosts[0].routes[0].clone();
prefix.id = "api-prefix".into();
prefix.path_prefix = "/api".into();
let mut regex = c.vhosts[0].routes[0].clone();
regex.id = "api-regex".into();
regex.match_type = crate::vhost::PathMatch::Regex;
regex.path_prefix = r"^/api/v[0-9]+/".into();
let mut exact = c.vhosts[0].routes[0].clone();
exact.id = "api-exact".into();
exact.match_type = crate::vhost::PathMatch::Exact;
exact.path_prefix = "/api/v1/fixed".into();
let mut trailing_exact = c.vhosts[0].routes[0].clone();
trailing_exact.id = "trailing-exact".into();
trailing_exact.match_type = crate::vhost::PathMatch::Exact;
trailing_exact.path_prefix = "/trailing/".into();
c.vhosts[0]
.routes
.extend([prefix.clone(), regex, exact, trailing_exact]);
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
assert_eq!(
snapshot.select("localhost", "/api/v1/fixed").unwrap().id,
"demo/api-exact"
);
assert_eq!(
snapshot.select("localhost", "/api/v2/users").unwrap().id,
"demo/api-regex"
);
assert_eq!(
snapshot.select("localhost", "/api/plain").unwrap().id,
"demo/api-prefix"
);
assert_eq!(
snapshot.select("localhost", "/trailing/").unwrap().id,
"demo/trailing-exact"
);
assert_eq!(
snapshot.select("localhost", "/trailing").unwrap().id,
"demo/main"
);
c.vhosts[0].routes.retain(|route| route.id != "api-prefix");
prefix.match_type = crate::vhost::PathMatch::PrefixPriority;
prefix.id = "api-priority".into();
c.vhosts[0].routes.push(prefix);
let snapshot = compile(c, 2, &policy()).unwrap();
assert_eq!(
snapshot.select("localhost", "/api/v2/users").unwrap().id,
"demo/api-priority"
);
}
#[test]
fn location_rewrites_are_normalized_and_regex_captures_expand() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
let route = &mut c.vhosts[0].routes[0];
route.match_type = crate::vhost::PathMatch::Regex;
route.path_prefix = r"^/api/v([0-9]+)(/.*)$".into();
route.rewrite = crate::vhost::PathRewrite {
mode: crate::vhost::RewriteMode::Regex,
value: "/service/${1}${2}".into(),
};
let snapshot = compile(c, 1, &policy()).unwrap();
let selected = snapshot.select("localhost", "/api/v42/items").unwrap();
assert_eq!(
selected
.rewritten_path("/api/v42/items")
.unwrap()
.as_deref(),
Some("/service/42/items")
);
assert_eq!(
selected
.rewritten_path("/api/v42/Ubuntu 14.04 LTS")
.unwrap()
.as_deref(),
Some("/service/42/Ubuntu 14.04 LTS")
);
}
#[test]
fn custom_location_response_is_validated() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].routes[0].response = Some(crate::vhost::LocationResponse {
status: 200,
content_type: "application/json".into(),
body: r#"{"ok":true}"#.into(),
});
assert!(compile(c.clone(), 1, &policy()).is_ok());
c.vhosts[0].routes[0].response.as_mut().unwrap().status = 199;
assert!(compile(c.clone(), 1, &policy()).is_err());
c.vhosts[0].routes[0].response.as_mut().unwrap().status = 200;
c.vhosts[0].routes[0]
.response
.as_mut()
.unwrap()
.content_type = "text/plain\r\nx-bad: 1".into();
assert!(compile(c, 1, &policy()).is_err());
}
#[test]
fn local_response_hosts_reserve_frontends_and_do_not_require_backends() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.not_found_hosts = serde_json::from_value(serde_json::json!([{
"id":"missing",
"name":"Missing site",
"frontend":{"hosts":["missing.localhost"],"listener_ids":["http"]},
"page":{"kind":"html","status":404,"html":"<h1>Not found</h1>"}
}]))
.unwrap();
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let host = snapshot
.not_found_host_on("missing.localhost", local, false)
.unwrap();
assert_eq!(host.id, "missing");
assert_eq!(host.page.status, Some(404));
c.not_found_hosts[0].frontend.hosts = vec!["localhost".into()];
assert!(compile(c, 2, &policy()).is_err());
}
#[test]
fn conflicts_are_rejected() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
// Keep both entries as real Custom Locations; the untouched legacy root
// is intentionally normalized away before duplicate-matcher validation.
c.vhosts[0].routes[0].path_prefix = "/api".into();
let mut duplicate = c.vhosts[0].routes[0].clone();
duplicate.id = "other".into();
c.vhosts[0].routes.push(duplicate);
assert!(compile(c, 0, &policy()).is_err());
}
#[test]
fn tls12_only_vhost_override_is_valid_without_http3_listener() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].tls_policy = Some(crate::tls::ProtocolPolicy {
tls12: true,
tls13: false,
cipher_suites: vec![],
});
assert!(compile(c, 1, &policy()).is_ok());
}
#[test]
fn persisted_remote_administration_requires_tls() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.administration.listen = Some("192.0.2.10:9090".parse().unwrap());
assert!(compile(c.clone(), 1, &policy()).is_err());
c.administration.tls = true;
assert!(compile(c, 1, &policy()).is_ok());
}
#[test]
fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() {
let mut p = policy();
p.admin_addr = "0.0.0.0:9090".parse().unwrap();
let c = demo_config("127.0.0.1:9000".parse().unwrap());
assert!(c.administration.listen.is_none());
assert!(compile(c, 1, &p).is_ok());
}
#[test]
fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["example.test".into()];
c.vhosts[0].www_redirect = true;
let snapshot = compile(c, 1, &policy()).unwrap();
assert!(snapshot.select("www.example.test", "/path").is_some());
let frontend = snapshot
.frontend("www.example.test", "127.0.0.1:8080".parse().unwrap(), false)
.unwrap();
assert_eq!(
frontend.www_redirect.as_ref(),
Some(&("www.example.test".into(), "example.test".into()))
);
}
#[test]
fn multiple_domains_share_vhost_and_selected_canonical_redirect() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec![
"suba.local".into(), "subb.local".into(), "site.test".into(),
];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("site.test".into());
let snapshot = compile(c, 1, &policy()).unwrap();
for hostname in ["suba.local", "subb.local", "site.test", "www.site.test"] {
assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}");
}
for hostname in ["suba.local", "subb.local", "www.site.test"] {
let frontend = snapshot.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.www_redirect.as_ref(),
Some(&("www.site.test".into(), "site.test".into())));
}
}
#[test]
fn implicit_www_target_is_claimed_and_other_domain_stays_independent() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["suba.local".into(), "subb.local".into()];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("www.subb.local".into());
let snapshot = compile(c, 1, &policy()).unwrap();
for host in ["suba.local", "subb.local", "www.subb.local"] {
assert!(snapshot.select(host, "/path").is_some(), "{host}");
}
assert!(snapshot.select("www.suba.local", "/path").is_none());
let frontend = snapshot.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.www_redirect.as_ref(),
Some(&(String::from("subb.local"), String::from("www.subb.local"))));
}
#[test]
fn selected_www_redirect_target_must_belong_to_vhost() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["suba.local".into()];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("elsewhere.local".into());
assert!(compile(c, 1, &policy()).is_err());
}
#[test]
fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
c.vhosts[0].load_balancing.health_check.host = None;
let snapshot = compile(c, 1, &policy()).unwrap();
assert_eq!(
snapshot.pools[0].settings.health_check.host.as_deref(),
Some("app.example.test")
);
}
#[test]
fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
// demo_config explicitly disables probes; this case exercises TCP fallback.
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Tcp;
assert_eq!(c.vhosts[0].load_balancing.health_check.mode, crate::balance::HealthMode::Tcp);
c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck {
mode: crate::balance::HealthMode::Http, path: "/healthz".into(), ..Default::default()
});
let snapshot = compile(c, 1, &policy()).unwrap();
let pool = &snapshot.pools[0];
assert_eq!(pool.settings.health_for(&pool.members[0].definition).host.as_deref(), Some("app.example.test"));
}
#[test]
fn explicit_http_health_check_host_override_wins_over_vhost_hostname() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
c.vhosts[0].load_balancing.health_check.host = Some("health.example.test".into());
let snapshot = compile(c, 1, &policy()).unwrap();
assert_eq!(
snapshot.pools[0].settings.health_check.host.as_deref(),
Some("health.example.test")
);
}
#[test]
fn custom_location_redirect_supports_separate_capture_regex_and_query() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].routes = vec![crate::vhost::PathRoute {
id: "archives".into(),
path_prefix: r"\.(?:tar\.gz|zip?)$".into(),
match_type: crate::vhost::PathMatch::RegexInsensitive,
response: None,
redirect: Some(crate::vhost::LocationRedirect {
status: 301,
target: "https://storage.example.test/o/$1".into(),
rewrite_pattern: Some(r"^/(.*)$".into()),
preserve_query: true,
}),
..Default::default()
}];
let snapshot = compile(c, 1, &policy()).unwrap();
let route = snapshot.select("localhost", "/repo/file.tar.gz").unwrap();
let uri: http::Uri = "/repo/file.tar.gz?download=1".parse().unwrap();
let location = route
.redirect_location("/repo/file.tar.gz", &uri)
.unwrap()
.unwrap();
assert_eq!(
location,
"https://storage.example.test/o/repo/file.tar.gz?download=1"
);
}
#[test]
fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'ok';").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.php_fpm_profiles.push(crate::web_server::PhpFpmProfile {
id: "php-default".into(),
name: "Default PHP pool".into(),
php: crate::web_server::Php {
enabled: true,
endpoint: "tcp:127.0.0.1:9000".into(),
..Default::default()
},
});
let service = crate::web_server::Service {
id: "site-one".into(),
name: "Site One".into(),
document_root: tmp.path().to_path_buf(),
php_profile_id: Some("php-default".into()),
// Standalone settings remain distinct and disabled.
php: crate::web_server::Php::default(),
..Default::default()
};
let mut second = service.clone();
second.id = "site-two".into();
second.name = "Site Two".into();
config.web_services.extend([service, second]);
config.vhosts[0].source = crate::vhost::Source::WebServer {
web_service_id: "site-one".into(),
};
config.vhosts[0].routes.clear();
config.vhosts[0].backends.clear();
let snapshot = compile(config, 1, &policy()).unwrap();
for id in ["site-one", "site-two"] {
let compiled = snapshot.web_php_services.get(id).unwrap();
assert!(compiled.php.is_some(), "{id} must use the shared FPM profile");
}
assert_eq!(snapshot.envelope.config.php_fpm_profiles.len(), 1);
assert!(!snapshot.envelope.config.web_services[0].php.enabled);
assert!(!snapshot.envelope.config.web_services[1].php.enabled);
let serialized = serde_json::to_string(&snapshot.envelope.config).unwrap();
let mut detached: Config = serde_json::from_str(&serialized).unwrap();
detached.web_services[0].php_profile_id = None;
let snapshot = compile(detached, 2, &policy()).unwrap();
assert!(snapshot.web_php_services.get("site-one").unwrap().php.is_none());
assert!(snapshot.web_php_services.get("site-two").unwrap().php.is_some());
}
#[test]
fn shared_php_fpm_profile_reference_and_duplicate_ids_are_rejected() {
let tmp = tempfile::TempDir::new().unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "site".into(),
name: "Site".into(),
document_root: tmp.path().to_path_buf(),
php_profile_id: Some("missing".into()),
..Default::default()
});
let err = compile(config.clone(), 1, &policy()).unwrap_err().to_string();
assert!(err.contains("unknown PHP-FPM profile"), "{err}");
config.web_services[0].php_profile_id = None;
let profile = crate::web_server::PhpFpmProfile {
id: "duplicate".into(),
name: "Duplicate".into(),
php: Default::default(),
};
config.php_fpm_profiles = vec![profile.clone(), profile];
let err = compile(config, 1, &policy()).unwrap_err().to_string();
assert!(err.contains("duplicate PHP-FPM profile"), "{err}");
}
}