v0.31.8
This commit is contained in:
1 parent
427d23cb6e
commit
68ada6680d
28 files changed
-8669
No files matched your search
@@ -1,429 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
config::{self, Config, ValidationPolicy},
|
||||
db::Store,
|
||||
};
|
||||
use axum::body::Body;
|
||||
use http::Request;
|
||||
use tower::ServiceExt;
|
||||
async fn fixture() -> (Router, Store, tempfile::TempDir, String, String) {
|
||||
fixture_with_config(Config::default()).await
|
||||
}
|
||||
async fn fixture_with_config(
|
||||
config: Config,
|
||||
) -> (Router, Store, tempfile::TempDir, String, String) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let policy = 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(),
|
||||
};
|
||||
let store = Store::open(&dir.path().join("db"), &config).await.unwrap();
|
||||
store
|
||||
.initialize_admin("owner", "unused-test-hash", "test")
|
||||
.await
|
||||
.unwrap();
|
||||
let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf())
|
||||
.await
|
||||
.unwrap();
|
||||
let issued = store
|
||||
.create_session(1, "browser", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let snapshot = config::compile(config, 0, &policy).unwrap();
|
||||
let state = App::new(
|
||||
store.clone(),
|
||||
snapshot,
|
||||
policy,
|
||||
auth,
|
||||
10,
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(300),
|
||||
);
|
||||
(
|
||||
router(state),
|
||||
store,
|
||||
dir,
|
||||
issued.secret,
|
||||
issued.session.csrf,
|
||||
)
|
||||
}
|
||||
async fn fixture_with_certificate_storage(
|
||||
config: Config,
|
||||
) -> (Router, Store, tempfile::TempDir, String, String) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let certificate_directory = dir.path().join("certs");
|
||||
crate::tls::initialize_default(&certificate_directory).unwrap();
|
||||
let policy = ValidationPolicy {
|
||||
tls_addr: None,
|
||||
cert_dir: Some(certificate_directory),
|
||||
data_dir: None,
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
};
|
||||
let store = Store::open(&dir.path().join("db"), &config).await.unwrap();
|
||||
store
|
||||
.initialize_admin("owner", "unused-test-hash", "test")
|
||||
.await
|
||||
.unwrap();
|
||||
let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf())
|
||||
.await
|
||||
.unwrap();
|
||||
let issued = store
|
||||
.create_session(1, "browser", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let snapshot = config::compile(config, 0, &policy).unwrap();
|
||||
let state = App::new(
|
||||
store.clone(),
|
||||
snapshot,
|
||||
policy,
|
||||
auth,
|
||||
10,
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(300),
|
||||
);
|
||||
(
|
||||
router(state),
|
||||
store,
|
||||
dir,
|
||||
issued.secret,
|
||||
issued.session.csrf,
|
||||
)
|
||||
}
|
||||
fn request(path: &str) -> http::request::Builder {
|
||||
Request::builder()
|
||||
.uri(path)
|
||||
.header("host", "127.0.0.1:9090")
|
||||
}
|
||||
#[test]
|
||||
fn administration_authority_uses_http2_pseudo_authority_without_legacy_host() {
|
||||
let request = Request::builder()
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://192.0.2.10:9090/healthz")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
admin_request_authority(&request, true).unwrap(),
|
||||
"192.0.2.10:9090"
|
||||
);
|
||||
|
||||
let conflicting = Request::builder()
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://192.0.2.10:9090/healthz")
|
||||
.header(HOST, "panel.example.test:9090")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert!(admin_request_authority(&conflicting, true).is_err());
|
||||
|
||||
let h1_absolute = Request::builder()
|
||||
.uri("https://192.0.2.10:9090/healthz")
|
||||
.header(HOST, "192.0.2.10:9090")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert!(admin_request_authority(&h1_absolute, true).is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn administration_https_health_accepts_http2_authority_without_host_header() {
|
||||
let (router, _store, _dir, _secret, _csrf) = fixture().await;
|
||||
let request = Request::builder()
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://127.0.0.1:9090/healthz")
|
||||
.extension(Transport { tls: true })
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.oneshot(request).await.unwrap().status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tls_admin_accepts_dns_without_allowlist_but_keeps_explicit_lists_strict() {
|
||||
let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap();
|
||||
assert!(admin_host_allowed("127.0.0.1:9090", wildcard, true, &[]));
|
||||
assert!(admin_host_allowed("192.0.2.10:9090", wildcard, true, &[]));
|
||||
assert!(admin_host_allowed(
|
||||
"example.test:9090",
|
||||
wildcard,
|
||||
true,
|
||||
&["example.test".into()]
|
||||
));
|
||||
assert!(admin_host_allowed("example.test:9090", wildcard, true, &[]));
|
||||
assert!(!admin_host_allowed(
|
||||
"example.test:9090",
|
||||
wildcard,
|
||||
false,
|
||||
&[]
|
||||
));
|
||||
assert!(!admin_host_allowed("0.0.0.0:9091", wildcard, true, &[]));
|
||||
assert!(!admin_host_allowed("0.0.0.0:9090", wildcard, true, &[]));
|
||||
assert!(admin_host_allowed("localhost:9090", wildcard, true, &[]));
|
||||
|
||||
let concrete: std::net::SocketAddr = "192.0.2.10:9090".parse().unwrap();
|
||||
assert!(admin_host_allowed("localhost:9090", concrete, true, &[]));
|
||||
assert!(admin_host_allowed(
|
||||
"localhost:9090",
|
||||
concrete,
|
||||
true,
|
||||
&["localhost".into()]
|
||||
));
|
||||
assert!(!admin_host_allowed(
|
||||
"other.test:9090",
|
||||
concrete,
|
||||
true,
|
||||
&["localhost".into()]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_proxy_dns_allowlist_uses_external_port_and_keeps_direct_port_available() {
|
||||
let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap();
|
||||
let allowed = vec!["panel.example.test".to_owned()];
|
||||
assert!(admin_host_allowed(
|
||||
"panel.example.test",
|
||||
wildcard,
|
||||
false,
|
||||
&allowed
|
||||
));
|
||||
assert!(admin_host_allowed(
|
||||
"panel.example.test:443",
|
||||
wildcard,
|
||||
true,
|
||||
&allowed
|
||||
));
|
||||
assert!(admin_host_allowed(
|
||||
"panel.example.test:8443",
|
||||
wildcard,
|
||||
true,
|
||||
&allowed
|
||||
));
|
||||
assert!(admin_host_allowed(
|
||||
"192.0.2.10:9090",
|
||||
wildcard,
|
||||
false,
|
||||
&allowed
|
||||
));
|
||||
assert!(admin_host_allowed(
|
||||
"localhost:9090",
|
||||
wildcard,
|
||||
false,
|
||||
&allowed
|
||||
));
|
||||
assert!(!admin_host_allowed(
|
||||
"192.0.2.10:443",
|
||||
wildcard,
|
||||
true,
|
||||
&allowed
|
||||
));
|
||||
assert!(!admin_host_allowed(
|
||||
"other.example.test:443",
|
||||
wildcard,
|
||||
true,
|
||||
&allowed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_matching_normalizes_default_ports_without_weakening_host_checks() {
|
||||
assert!(admin_origin_matches(
|
||||
"https://panel.example.test",
|
||||
"https",
|
||||
"panel.example.test:443"
|
||||
));
|
||||
assert!(admin_origin_matches(
|
||||
"https://PANEL.EXAMPLE.TEST:443/",
|
||||
"https",
|
||||
"panel.example.test"
|
||||
));
|
||||
assert!(!admin_origin_matches(
|
||||
"https://panel.example.test:8443",
|
||||
"https",
|
||||
"panel.example.test"
|
||||
));
|
||||
assert!(!admin_origin_matches(
|
||||
"http://panel.example.test",
|
||||
"https",
|
||||
"panel.example.test"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_origin_diagnostics_redact_non_origin_values() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert_eq!(diagnostic_admin_origin(&headers), "(absent)");
|
||||
headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test"));
|
||||
assert_eq!(diagnostic_admin_origin(&headers), "https://panel.example.test");
|
||||
headers.insert(ORIGIN, HeaderValue::from_static("https://user:password@panel.example.test"));
|
||||
assert_eq!(diagnostic_admin_origin(&headers), "(invalid)");
|
||||
headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test/?token=secret"));
|
||||
assert_eq!(diagnostic_admin_origin(&headers), "(invalid)");
|
||||
headers.insert("x-forwarded-host", HeaderValue::from_static("other.example.test"));
|
||||
assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", false), "(untrusted)");
|
||||
assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", true), "other.example.test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_browser_origins_remain_rejected_for_session_checks() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
for (origin, fetch_site) in [
|
||||
(Some("https://attacker.example"), None),
|
||||
(Some("null"), None),
|
||||
(None, Some("cross-site")),
|
||||
] {
|
||||
let mut request = Request::builder()
|
||||
.uri("/api/auth/session")
|
||||
.header("host", "127.0.0.1:9090")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME));
|
||||
if let Some(origin) = origin { request = request.header(ORIGIN, origin); }
|
||||
if let Some(site) = fetch_site { request = request.header("sec-fetch-site", site); }
|
||||
let response = router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
// Origin on an ordinary same-origin GET may be absent, or may match.
|
||||
for origin in [None, Some("http://127.0.0.1:9090")] {
|
||||
let mut request = Request::builder()
|
||||
.uri("/api/auth/session")
|
||||
.header("host", "127.0.0.1:9090")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME));
|
||||
if let Some(origin) = origin { request = request.header(ORIGIN, origin); }
|
||||
assert_eq!(router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap().status(), StatusCode::OK);
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_origin_on_document_is_visible_but_still_forbidden() {
|
||||
let (router, store, _dir, _secret, _) = fixture().await;
|
||||
let response = router.oneshot(
|
||||
Request::builder()
|
||||
.uri("/overview")
|
||||
.header("host", "127.0.0.1:9090")
|
||||
.header("origin", "null")
|
||||
.header("accept", "text/html,application/xhtml+xml")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert!(response.headers().get(http::header::CONTENT_TYPE).unwrap().to_str().unwrap().starts_with("text/html"));
|
||||
let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(&body).contains("Could not open the panel"));
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_https_reverse_proxy_accepts_external_origin_and_refreshes_secure_cookie() {
|
||||
let mut config = Config::default();
|
||||
config.administration.allowed_hosts = vec!["panel.example.test".into()];
|
||||
let (router, store, _dir, secret, _) = fixture_with_config(config).await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/status")
|
||||
.header("host", "127.0.0.1:9090")
|
||||
.header("x-forwarded-host", "panel.example.test")
|
||||
.header("x-forwarded-proto", "https")
|
||||
.header("origin", "https://panel.example.test")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let refreshed = response
|
||||
.headers()
|
||||
.get_all(SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find(|value| value.starts_with(auth::COOKIE_NAME))
|
||||
.expect("session cookie refreshed");
|
||||
assert!(refreshed.contains("; Secure"));
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_redirect_router_preserves_authority_path_and_query() {
|
||||
let request = Request::builder()
|
||||
.uri("/api/status?view=compact")
|
||||
.header("host", "panel.example.test:9090")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = https_redirect_router().oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
|
||||
assert_eq!(
|
||||
response.headers().get(http::header::LOCATION).unwrap(),
|
||||
"https://panel.example.test:9090/api/status?view=compact"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get(http::header::CACHE_CONTROL).unwrap(),
|
||||
"no-store"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_exposes_runtime_memory_accounting_and_cold_limits() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/status")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = json_body(response).await;
|
||||
assert_eq!(body["transform_resources"]["memory_budget_bytes"], 128 * 1024 * 1024);
|
||||
assert_eq!(body["transform_resources"]["max_inflight"], 32);
|
||||
assert_eq!(body["memory_accounting"]["transform_budget_bytes"], 128 * 1024 * 1024);
|
||||
assert_eq!(body["memory_accounting"]["cache_hot_limit_bytes"], 256 * 1024 * 1024);
|
||||
assert_eq!(body["cache"]["cold_limit_bytes"], 4_u64 * 1024 * 1024 * 1024);
|
||||
assert_eq!(body["cache"]["cold_limit_entries"], 50_000);
|
||||
assert_eq!(body["memory_accounting"]["non_cache_rss_includes_transform"], true);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_legacy_token_rebinding_and_cross_origin() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let old = request("/api/status")
|
||||
.header("authorization", format!("Bearer {}", "a".repeat(64)))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(old).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let evil = Request::builder()
|
||||
.uri("/healthz")
|
||||
.header("host", "evil.test:9090")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(evil).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let req = request("/api/status")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.header("origin", "http://evil.test")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let req = request("/api/status")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(router.oneshot(req).await.unwrap().status(), StatusCode::OK);
|
||||
store.close().await;
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn login_bundle_is_public_but_cache_mutations_require_session_and_csrf() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let public = request("/assets/login-langs.json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(public).await.unwrap().status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let anonymous = request("/api/cache").body(Body::empty()).unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(anonymous).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let anonymous = request("/api/traffic").body(Body::empty()).unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(anonymous).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let anonymous = request("/api/transform").body(Body::empty()).unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(anonymous).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for valid in [false, true] {
|
||||
let req = request("/api/cache/purge")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header(
|
||||
"x-csrf-token",
|
||||
if valid { csrf.as_str() } else { "invalid" },
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let expected = if valid {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::FORBIDDEN
|
||||
};
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let cleanup = request("/api/cache/cold/cleanup")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", "invalid")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"oldest":1,"hits_zero":false}"#))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(cleanup).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let cleanup_status = request("/api/cache/cold/cleanup/1")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(cleanup_status).await.unwrap().status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn selective_cache_invalidation_api_manages_bans_and_rejects_wide_rules_without_confirmation() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let mutate = |path: &str, method: &str, body: Body| {
|
||||
request(path)
|
||||
.method(method)
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(body)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let create = router
|
||||
.clone()
|
||||
.oneshot(mutate(
|
||||
"/api/cache/invalidate",
|
||||
"POST",
|
||||
Body::from(
|
||||
r#"{"operation":"ban","selector":"exact","value":"/asset.js?v=1","host":"example.test","ttl_seconds":60}"#,
|
||||
),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create.status(), StatusCode::OK);
|
||||
let created = json_body(create).await;
|
||||
let ban_id = created["result"]["ban_id"].as_u64().expect("ban id");
|
||||
assert_eq!(created["result"]["status"], "active");
|
||||
assert_eq!(created["bans"].as_array().unwrap().len(), 1);
|
||||
|
||||
let list = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/cache/bans")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(list).await["bans"].as_array().unwrap().len(), 1);
|
||||
|
||||
let wide = router
|
||||
.clone()
|
||||
.oneshot(mutate(
|
||||
"/api/cache/invalidate",
|
||||
"POST",
|
||||
Body::from(r#"{"operation":"purge","selector":"regex","value":".*"}"#),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wide.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let remove = router
|
||||
.clone()
|
||||
.oneshot(mutate(
|
||||
&format!("/api/cache/bans/{ban_id}"),
|
||||
"DELETE",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remove.status(), StatusCode::OK);
|
||||
assert!(json_body(remove).await["bans"].as_array().unwrap().is_empty());
|
||||
|
||||
let missing = router
|
||||
.clone()
|
||||
.oneshot(mutate(
|
||||
"/api/cache/bans/999999",
|
||||
"DELETE",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let logging = Config::default().logging;
|
||||
let body = serde_json::to_vec(&json!({
|
||||
"expected_revision": 0,
|
||||
"value": logging,
|
||||
}))
|
||||
.unwrap();
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/logging")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await["revision"], 1);
|
||||
|
||||
let http2 = crate::settings::Http2Connections {
|
||||
idle_timeout_seconds: 120,
|
||||
max_connection_lifetime_seconds: 7_200,
|
||||
drain_timeout_seconds: 15,
|
||||
};
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/http2")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&json!({"expected_revision":1,"value":http2})).unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let updated = json_body(response).await;
|
||||
assert_eq!(updated["revision"], 2);
|
||||
assert_eq!(updated["config"]["http2"]["max_connection_lifetime_seconds"], 7_200);
|
||||
|
||||
let stale = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/logging")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(
|
||||
&json!({"expected_revision":0,"value":Config::default().logging}),
|
||||
)
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stale.status(), StatusCode::CONFLICT);
|
||||
|
||||
let unknown = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/not-a-section")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(r#"{"expected_revision":2,"value":{}}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logging_section_persists_error_log_settings_atomically() {
|
||||
let (router, store, dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let logging = Config::default().logging;
|
||||
let mut error_log = Config::default().error_log;
|
||||
error_log.enabled = true;
|
||||
error_log.path = "/tmp/rustproxy-error.log".into();
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/logging")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&json!({
|
||||
"expected_revision": 0,
|
||||
"value": {
|
||||
"logging": logging,
|
||||
"error_log": error_log,
|
||||
},
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let updated = json_body(response).await;
|
||||
assert_eq!(updated["revision"], 1);
|
||||
assert_eq!(updated["config"]["error_log"]["enabled"], true);
|
||||
assert_eq!(updated["config"]["error_log"]["path"], "/tmp/rustproxy-error.log");
|
||||
|
||||
let configuration = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let persisted = json_body(configuration).await;
|
||||
assert_eq!(persisted["config"]["error_log"]["enabled"], true);
|
||||
assert_eq!(persisted["config"]["error_log"]["path"], "/tmp/rustproxy-error.log");
|
||||
drop(router);
|
||||
store.close().await;
|
||||
let reopened = Store::open(&dir.path().join("db"), &Config::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let persisted = reopened.load().await.unwrap();
|
||||
assert_eq!(persisted.revision, 1);
|
||||
assert!(persisted.config.error_log.enabled);
|
||||
assert_eq!(persisted.config.error_log.path, "/tmp/rustproxy-error.log");
|
||||
reopened.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_log_is_not_a_standalone_configuration_section() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/config/error_log")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(r#"{"expected_revision":0,"value":{}}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_pages_section_accepts_the_standard_value_payload() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let pages = Config::default().pages;
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/pages")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&json!({
|
||||
"expected_revision": 0,
|
||||
"value": pages,
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await["revision"], 1);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn panel_subpage_routes_survive_browser_refresh() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for path in [
|
||||
"/overview",
|
||||
"/vhosts",
|
||||
"/web-server",
|
||||
"/docker-vhosts",
|
||||
"/redirects",
|
||||
"/404-hosts",
|
||||
"/streams",
|
||||
"/acl-profiles",
|
||||
"/configuration",
|
||||
"/tls",
|
||||
"/traffic",
|
||||
"/cache",
|
||||
"/transform",
|
||||
"/logging",
|
||||
"/logs",
|
||||
"/settings",
|
||||
] {
|
||||
let public = router
|
||||
.clone()
|
||||
.oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(public.status(), StatusCode::OK, "anonymous {path}");
|
||||
let public_body = axum::body::to_bytes(public.into_body(), 100_000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(std::str::from_utf8(&public_body)
|
||||
.unwrap()
|
||||
.contains(&format!("/assets/login_{}.js", super::asset_build_id())));
|
||||
|
||||
let private = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(path)
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(private.status(), StatusCode::OK, "authenticated {path}");
|
||||
let private_body = axum::body::to_bytes(private.into_body(), 2 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(std::str::from_utf8(&private_body)
|
||||
.unwrap()
|
||||
.contains(&format!("/assets/app_{}.js", super::asset_build_id())));
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn csrf_required_and_logout_revokes_cookie() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let req = request("/api/auth/logout")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let req = request("/api/auth/logout")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", csrf)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let req = request("/api/status")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.oneshot(req).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn upload_and_scoped_queries_keep_control_plane_guards() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for (path, body) in [
|
||||
(
|
||||
"/api/certificates/upload",
|
||||
r#"{"cert_pem":"test","key_pem":"test","hosts":["app.localhost"]}"#,
|
||||
),
|
||||
(
|
||||
"/api/certificates/deploy",
|
||||
r#"{"certificate_id":"shared","cert_pem":"test","key_pem":"test"}"#,
|
||||
),
|
||||
] {
|
||||
for (authenticated, valid_csrf, expected) in
|
||||
[(false, false, 401), (true, false, 403), (true, true, 409)]
|
||||
{
|
||||
let mut req = request(path)
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1");
|
||||
if authenticated {
|
||||
req = req.header("cookie", &cookie);
|
||||
}
|
||||
if valid_csrf {
|
||||
req = req.header("x-csrf-token", &csrf);
|
||||
}
|
||||
assert_eq!(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(req.body(Body::from(body)).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
for (path, expected) in [
|
||||
("/api/logs?vhost=demo&limit=8", 200),
|
||||
("/api/logs?cache=BAD", 400),
|
||||
("/api/file-logs", 200),
|
||||
("/api/file-logs/query?source=all&limit=8", 200),
|
||||
("/api/file-logs/query?limit=0", 400),
|
||||
("/api/traffic", 200),
|
||||
("/api/cache", 200),
|
||||
("/api/transform", 200),
|
||||
] {
|
||||
let req = request(path)
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status().as_u16(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let req = request("/api/cache/purge?vhost=missing")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.oneshot(req).await.unwrap().status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_reload_keeps_configuration_revision_stable() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/reload")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload = json_body(response).await;
|
||||
assert_eq!(payload["reloaded"], true);
|
||||
assert_eq!(payload["revision"], 0);
|
||||
|
||||
let configuration = router
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(json_body(configuration).await["revision"], 0);
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn manual_certificate_can_be_reloaded_by_id_without_revision_change() {
|
||||
let mut config = Config::default();
|
||||
config.tls.certificates.push(crate::tls::Certificate {
|
||||
issuer: None,
|
||||
id: "manual-file".into(),
|
||||
covered_hosts: Vec::new(),
|
||||
hosts: Vec::new(),
|
||||
cert_file: "default.pem".into(),
|
||||
key_file: "default.key".into(),
|
||||
});
|
||||
let (router, store, _dir, secret, csrf) = fixture_with_certificate_storage(config).await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/manual-file/reload")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload = json_body(response).await;
|
||||
assert_eq!(payload["certificate_id"], "manual-file");
|
||||
assert_eq!(payload["revision"], 0);
|
||||
assert_eq!(payload["reloaded"], true);
|
||||
|
||||
let api_token = "c".repeat(64);
|
||||
store
|
||||
.upsert_certificate_api_credential(
|
||||
"file-renewer",
|
||||
&crate::auth::secret_hash(&api_token),
|
||||
"external file renewal hook",
|
||||
false,
|
||||
&["manual-file".to_owned()],
|
||||
"owner",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let external = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/manual-file/reload")
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {api_token}"))
|
||||
.header("x-rustproxy-certificate-key", "file-renewer")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(external.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(external).await["certificate_id"], "manual-file");
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_api_tokens_are_scoped_commented_and_individually_revocable() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let token = "a".repeat(64);
|
||||
let token_two = "b".repeat(64);
|
||||
let invalid_key = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificate-api")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(json!({"key_id":"x","token":token,"certificate_ids":["shared-cert"]}).to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(invalid_key.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
for (key_id, value, comment) in [
|
||||
("certbot-main", token.as_str(), "production certbot"),
|
||||
("deploy-hook", token_two.as_str(), "secondary deploy hook"),
|
||||
] {
|
||||
let rotated = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificate-api")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"key_id":key_id,
|
||||
"token":value,
|
||||
"comment":comment,
|
||||
"certificate_ids":[if key_id == "certbot-main" { "shared-cert" } else { "deploy-cert" }],
|
||||
"allow_deploy": key_id == "deploy-hook"
|
||||
}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rotated.status(), StatusCode::OK);
|
||||
let rotated = json_body(rotated).await;
|
||||
assert_eq!(rotated["configured"], true);
|
||||
assert_eq!(rotated["credential"]["key_id"], key_id);
|
||||
assert_eq!(rotated["credential"]["comment"], comment);
|
||||
assert_eq!(rotated["credential"]["allow_deploy"], key_id == "deploy-hook");
|
||||
assert_eq!(rotated["credential"]["certificate_ids"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(rotated["token"], value);
|
||||
}
|
||||
|
||||
let status = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificate-api")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = json_body(status).await;
|
||||
assert_eq!(status["configured"], true);
|
||||
assert_eq!(status["credentials"].as_array().unwrap().len(), 2);
|
||||
assert!(status.get("token").is_none());
|
||||
|
||||
let external = |path: &'static str, key_id: &'static str, value: String| {
|
||||
request(path)
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {value}"))
|
||||
.header("x-rustproxy-certificate-key", key_id)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap()
|
||||
};
|
||||
// Certificate API credentials cannot trigger an all-certificate reload.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("/api/certificates/reload", "certbot-main", token.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
// The allowlisted ID passes the auth gate and reaches the handler; this
|
||||
// fixture intentionally has no such configured certificate, so it is 404.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("/api/certificates/shared-cert/reload", "certbot-main", token.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
// A reload-only token cannot use the deploy endpoint at all.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/deploy")
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-rustproxy-certificate-key", "certbot-main")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
// A different ID is rejected by the credential grant before the handler.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("/api/certificates/deploy-cert/reload", "certbot-main", token.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/deploy")
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {token_two}"))
|
||||
.header("x-rustproxy-certificate-key", "deploy-hook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({"certificate_id":"shared-cert","cert_pem":"bad","key_pem":"bad"}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let missing_key = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/reload")
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing_key.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let forbidden_scope = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-rustproxy-certificate-key", "certbot-main")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(forbidden_scope.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let revoked = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificate-api/certbot-main")
|
||||
.method("DELETE")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(revoked.status(), StatusCode::OK);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("/api/certificates/shared-cert/reload", "certbot-main", token.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let revoked_all = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificate-api")
|
||||
.method("DELETE")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(revoked_all.status(), StatusCode::OK);
|
||||
let response = router
|
||||
.oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
store.close().await;
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn admin_preferences_are_account_scoped_and_csrf_guarded() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let initial = json_body(response).await;
|
||||
assert_eq!(initial["live_log_columns"].as_array().unwrap().len(), LIVE_LOG_COLUMNS.len());
|
||||
assert_eq!(initial["refresh"], json!({
|
||||
"live_enabled":true, "overview_seconds":2, "traffic_seconds":5,
|
||||
"cache_seconds":5, "system_info_seconds":60
|
||||
}));
|
||||
assert_eq!(initial["theme"], "dark");
|
||||
assert_eq!(initial["language"], "auto");
|
||||
assert_eq!(initial["ui_scale"], 100);
|
||||
assert_eq!(initial["time_format"], "24h");
|
||||
assert_eq!(initial["chart_style"], "default");
|
||||
assert_eq!(initial["button_style"], "normal");
|
||||
assert_eq!(
|
||||
initial["file_log_columns"].as_array().unwrap().len(),
|
||||
default_file_log_columns().len()
|
||||
);
|
||||
assert_eq!(
|
||||
initial["backend_table_columns"].as_array().unwrap().len(),
|
||||
BACKEND_TABLE_COLUMNS.len()
|
||||
);
|
||||
assert_eq!(initial["backend_table_sort"], "vhost");
|
||||
assert_eq!(initial["backend_table_descending"], false);
|
||||
|
||||
assert!(store
|
||||
.save_admin_preferences("owner", r#"{"live_log_columns":["time","status"]}"#)
|
||||
.await
|
||||
.unwrap());
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let legacy = json_body(response).await;
|
||||
assert_eq!(legacy["live_log_columns"], json!(["time", "status"]));
|
||||
assert_eq!(legacy["refresh"], initial["refresh"]);
|
||||
assert_eq!(legacy["theme"], "dark");
|
||||
assert_eq!(legacy["language"], "auto");
|
||||
assert_eq!(legacy["ui_scale"], 100);
|
||||
assert_eq!(legacy["time_format"], "24h");
|
||||
assert_eq!(legacy["chart_style"], "default");
|
||||
assert_eq!(legacy["button_style"], "normal");
|
||||
assert_eq!(legacy["file_log_columns"], json!(default_file_log_columns()));
|
||||
assert_eq!(
|
||||
legacy["backend_table_columns"].as_array().unwrap().len(),
|
||||
BACKEND_TABLE_COLUMNS.len()
|
||||
);
|
||||
assert_eq!(legacy["backend_table_sort"], "vhost");
|
||||
assert_eq!(legacy["backend_table_descending"], false);
|
||||
|
||||
let selected = json!({
|
||||
"refresh":{"live_enabled":false,"overview_seconds":10,"traffic_seconds":0,"cache_seconds":3,"system_info_seconds":300},
|
||||
"live_log_columns":["time","request","status","upstream_error"],
|
||||
"theme":"light",
|
||||
"language":"pl_pl",
|
||||
"ui_scale":70,
|
||||
"time_format":"12h",
|
||||
"chart_style":"neon",
|
||||
"button_style":"compact",
|
||||
"table_density":"ultra",
|
||||
"dashboard_density":"ultra",
|
||||
"dashboard_widgets":["errors","requests","active","bandwidth","cache","memory","websocket","http2","http3","tls","http_protocol","websocket_protocol"],
|
||||
"dashboard_hidden_widgets":["http3","tls"],
|
||||
"file_log_columns":["time","event","client_ip","record"],
|
||||
"backend_table_columns":["vhost","backend","status","requests","latency"],
|
||||
"backend_table_sort":"requests",
|
||||
"backend_table_descending":true
|
||||
});
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(selected.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await, selected);
|
||||
|
||||
let saved: Value =
|
||||
serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(saved, selected);
|
||||
// Preferences must also survive a subsequent authenticated read.
|
||||
let persisted = router.clone().oneshot(request("/api/preferences")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(json_body(persisted).await, selected);
|
||||
// Wrong allowlist value is rejected even if the rest is valid.
|
||||
let mut invalid = selected.clone();
|
||||
invalid["refresh"]["traffic_seconds"] = json!(4);
|
||||
let rejected = router.clone().oneshot(request("/api/preferences")
|
||||
.method("PUT").header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1").header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(invalid.to_string())).unwrap()).await.unwrap();
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(selected.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"live_log_columns":[]}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"live_log_columns":["time"],"theme":"sepia"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"live_log_columns":["time"],"ui_scale":150}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"live_log_columns":["time"],"backend_table_columns":["unknown"]}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acme_and_cache_inspection_require_existing_admin_guards() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for path in ["/api/acme", "/api/cache?offset=0&limit=2"] {
|
||||
let req = request(path).body(Body::empty()).unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let req = request(path)
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
for (authenticated, valid_csrf, expected) in
|
||||
[(false, false, 401), (true, false, 403), (true, true, 409)]
|
||||
{
|
||||
let mut req = request("/api/acme/issue")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1");
|
||||
if authenticated {
|
||||
req = req.header("cookie", &cookie);
|
||||
}
|
||||
if valid_csrf {
|
||||
req = req.header("x-csrf-token", &csrf);
|
||||
}
|
||||
// This fixture has no certificate directory: valid auth reaches the
|
||||
// 409 precondition, never a child process, DNS update or CA request.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(req.body(Body::from("{}")).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status().as_u16(), expected);
|
||||
}
|
||||
let req = request("/api/cache?vhost=missing")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.oneshot(req).await.unwrap().status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
async fn json_body(response: Response) -> Value {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 2 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn external_certificate_deploy_creates_updates_and_noops() {
|
||||
let (router, store, dir, secret, csrf) =
|
||||
fixture_with_certificate_storage(Config::default()).await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let first = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap();
|
||||
let first_body = json!({
|
||||
"certificate_id": "opnsense-cert",
|
||||
"cert_pem": first.cert.pem(),
|
||||
"key_pem": first.signing_key.serialize_pem(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let deploy = |body: String| {
|
||||
request("/api/certificates/deploy")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("cookie", &cookie)
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let response = router.clone().oneshot(deploy(first_body.clone())).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let created = json_body(response).await;
|
||||
assert_eq!(created["operation"], "created");
|
||||
assert_eq!(created["created"], true);
|
||||
assert_eq!(created["changed"], true);
|
||||
let first_revision = created["revision"].as_u64().unwrap();
|
||||
let first_cert_file = created["cert_file"].as_str().unwrap().to_owned();
|
||||
let first_key_file = created["key_file"].as_str().unwrap().to_owned();
|
||||
assert!(dir.path().join("certs").join(&first_cert_file).is_file());
|
||||
assert!(dir.path().join("certs").join(&first_key_file).is_file());
|
||||
|
||||
let response = router.clone().oneshot(deploy(first_body)).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let unchanged = json_body(response).await;
|
||||
assert_eq!(unchanged["operation"], "unchanged");
|
||||
assert_eq!(unchanged["created"], false);
|
||||
assert_eq!(unchanged["changed"], false);
|
||||
assert_eq!(unchanged["revision"].as_u64(), Some(first_revision));
|
||||
assert_eq!(unchanged["cert_file"].as_str(), Some(first_cert_file.as_str()));
|
||||
assert_eq!(unchanged["key_file"].as_str(), Some(first_key_file.as_str()));
|
||||
|
||||
let second = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap();
|
||||
let second_body = json!({
|
||||
"certificate_id": "opnsense-cert",
|
||||
"cert_pem": second.cert.pem(),
|
||||
"key_pem": second.signing_key.serialize_pem(),
|
||||
})
|
||||
.to_string();
|
||||
let response = router.oneshot(deploy(second_body)).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let updated = json_body(response).await;
|
||||
assert_eq!(updated["operation"], "updated");
|
||||
assert_eq!(updated["created"], false);
|
||||
assert_eq!(updated["changed"], true);
|
||||
assert!(updated["revision"].as_u64().unwrap() > first_revision);
|
||||
assert_ne!(updated["cert_file"].as_str(), Some(first_cert_file.as_str()));
|
||||
assert_ne!(updated["key_file"].as_str(), Some(first_key_file.as_str()));
|
||||
assert!(!dir.path().join("certs").join(&first_cert_file).exists());
|
||||
assert!(!dir.path().join("certs").join(&first_key_file).exists());
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn anonymous_operational_paths_fail_closed_before_extractors() {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
for path in [
|
||||
"/api/status",
|
||||
"/api/system-info",
|
||||
"/api/config",
|
||||
"/api/config/history",
|
||||
"/api/backends",
|
||||
"/api/docker",
|
||||
"/api/traffic",
|
||||
"/api/cache?limit=invalid",
|
||||
"/api/logs?limit=invalid",
|
||||
"/api/live-logs",
|
||||
"/api/file-logs",
|
||||
"/api/file-logs/query?limit=invalid",
|
||||
"/api/certificate-api",
|
||||
"/api/preferences",
|
||||
"/api/audit",
|
||||
"/api/events",
|
||||
"/api/certificates",
|
||||
"/api/acme",
|
||||
"/api/auth/session",
|
||||
"/api/metrics",
|
||||
"/metrics",
|
||||
"/docs/openapi.json",
|
||||
"/docs/content.json",
|
||||
"/docs/init.js",
|
||||
"/docs/style.css",
|
||||
"/docs/swagger-ui-bundle.js",
|
||||
"/docs/swagger-ui.css",
|
||||
"/docs/licenses.txt",
|
||||
"/assets/langs.json",
|
||||
"/assets/app.js",
|
||||
"/assets/management.js",
|
||||
"/assets/drafts.js",
|
||||
"/assets/integrations.js",
|
||||
"/assets/traffic-panel.js",
|
||||
"/assets/cache-panel.js",
|
||||
"/assets/transform-panel.js",
|
||||
"/assets/vhosts.js",
|
||||
"/assets/docker.js",
|
||||
"/assets/streams.js",
|
||||
"/assets/redirects.js",
|
||||
"/assets/host404.js",
|
||||
"/assets/settings.js",
|
||||
"/assets/file-logs.js",
|
||||
"/assets/acme.js",
|
||||
"/api/not-a-route",
|
||||
"/not-a-route",
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}");
|
||||
assert_eq!(response.headers()["cache-control"], "no-store");
|
||||
assert!(!response
|
||||
.headers()
|
||||
.contains_key("access-control-allow-origin"));
|
||||
assert!(!response.headers().contains_key("server"));
|
||||
assert_eq!(
|
||||
json_body(response).await,
|
||||
json!({"error":"auth.required"}),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
for (path, method) in [
|
||||
("/api/config", "PUT"),
|
||||
("/api/config/cache", "PUT"),
|
||||
("/api/config/history/restore", "POST"),
|
||||
("/api/acme/issue", "POST"),
|
||||
("/api/acme/renew", "POST"),
|
||||
("/api/docker/refresh", "POST"),
|
||||
("/api/geoip/update", "POST"),
|
||||
("/api/cache/purge", "POST"),
|
||||
("/api/cache/invalidate", "POST"),
|
||||
("/api/cache/bans", "GET"),
|
||||
("/api/cache/bans/1", "DELETE"),
|
||||
("/api/tunnels", "GET"),
|
||||
("/api/tunnels/1/kill", "POST"),
|
||||
("/api/cache/cold/cleanup", "POST"),
|
||||
("/api/cache/cold/cleanup/1", "GET"),
|
||||
("/api/certificates/upload", "POST"),
|
||||
("/api/certificates/deploy", "POST"),
|
||||
("/api/certificates/reload", "POST"),
|
||||
("/api/certificates/manual-file/reload", "POST"),
|
||||
("/api/certificate-api", "PUT"),
|
||||
("/api/certificate-api/certbot-main", "DELETE"),
|
||||
("/api/certificate-api", "DELETE"),
|
||||
("/api/basic-auth/hash", "POST"),
|
||||
("/api/auth/password", "POST"),
|
||||
("/api/auth/logout", "POST"),
|
||||
("/api/auth/logout-all", "POST"),
|
||||
("/api/config", "OPTIONS"),
|
||||
("/api/config", "DELETE"),
|
||||
("/api/auth/login", "GET"),
|
||||
("/api/auth/setup", "GET"),
|
||||
("/healthz", "POST"),
|
||||
("/docs/openapi.json", "HEAD"),
|
||||
("/docs/content.json", "HEAD"),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(path)
|
||||
.method(method)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("malformed secret payload"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{method} {path}"
|
||||
);
|
||||
if method != "HEAD" {
|
||||
assert_eq!(json_body(response).await, json!({"error":"auth.required"}));
|
||||
}
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn docs_require_the_same_session_and_do_not_redirect_schema_fetches() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
for path in ["/docs", "/docs/"] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers()["location"], "/?next=docs");
|
||||
}
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for path in [
|
||||
"/",
|
||||
"/docs",
|
||||
"/docs/",
|
||||
"/docs/licenses.txt",
|
||||
"/docs/content.json",
|
||||
&format!("/assets/app_{}.js", super::asset_build_id()),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(path)
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{path}");
|
||||
assert_eq!(response.headers()["x-frame-options"], "DENY");
|
||||
assert_eq!(response.headers()["cache-control"], "no-store");
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/docs/openapi.json")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let spec = json_body(response).await;
|
||||
assert_eq!(spec["openapi"], "3.0.3");
|
||||
assert_eq!(spec["info"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(
|
||||
spec["components"]["securitySchemes"]["AdminSession"]["in"],
|
||||
"cookie"
|
||||
);
|
||||
assert!(spec["paths"].get("/api/config").is_some());
|
||||
assert!(spec["paths"].get("/api/config/history/restore").is_some());
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/docs/content.json")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let docs = json_body(response).await;
|
||||
let documents = docs["documents"].as_array().expect("documentation bundle");
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/README.md"));
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/WEB_SERVER.md"));
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/PHP_FPM.md"));
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/SECURITY_AUDIT_0.31.0.md"));
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/TEST_REPORT.md"));
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn credentials_in_query_or_duplicate_cookies_cannot_authorize() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
for cookie in [
|
||||
String::new(),
|
||||
format!("{}=invalid", auth::COOKIE_NAME),
|
||||
format!(
|
||||
"{}={secret}; {}={secret}",
|
||||
auth::COOKIE_NAME,
|
||||
auth::COOKIE_NAME
|
||||
),
|
||||
] {
|
||||
let path = format!("/api/config?token={secret}&session={secret}");
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(&path)
|
||||
.header("cookie", cookie)
|
||||
.header("authorization", format!("Bearer {secret}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn duplicate_csrf_or_marker_is_rejected_and_session_survives() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for duplicate in ["x-csrf-token", "x-rustproxy-request"] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/auth/logout")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header(
|
||||
duplicate,
|
||||
if duplicate == "x-csrf-token" {
|
||||
csrf.as_str()
|
||||
} else {
|
||||
"1"
|
||||
},
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/auth/session")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn malformed_anonymous_json_never_reflects_submitted_values() {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
for path in ["/api/auth/login", "/api/auth/setup"] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(path)
|
||||
.method("POST")
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"username":{"untrusted":"DO_NOT_REFLECT_ME"}}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
json_body(response).await,
|
||||
json!({"error":"auth.invalid_request"})
|
||||
);
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_configuration_revision_can_be_restored_as_a_new_revision() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
|
||||
let mut first = Config::default();
|
||||
first.server_header.value = "first".into();
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
json!({"expected_revision":0,"config":first}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await["revision"], 1);
|
||||
|
||||
let mut second = Config::default();
|
||||
second.server_header.value = "second".into();
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
json!({"expected_revision":1,"config":second}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await["revision"], 2);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/history/restore")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
json!({"revision":1,"expected_revision":2}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let restored = json_body(response).await;
|
||||
assert_eq!(restored["revision"], 3);
|
||||
assert_eq!(restored["config"]["server_header"]["value"], "first");
|
||||
|
||||
let audit = store.recent_audit().await.unwrap();
|
||||
assert_eq!(audit[0].actor, "owner");
|
||||
assert_eq!(audit[0].action, "config.restore");
|
||||
assert_eq!(audit[0].detail, "source_revision=1 revision=3");
|
||||
let detail = store.configuration_revision(3).await.unwrap().unwrap();
|
||||
assert_eq!(detail.config.server_header.value, "first");
|
||||
assert_eq!(detail.previous_config.unwrap().server_header.value, "second");
|
||||
store.close().await;
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
#[test]
|
||||
fn stored_log_inventory_keeps_existing_files_visible_when_writer_is_disabled() {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("access.jsonl");
|
||||
let mut file = std::fs::File::create(&path).unwrap();
|
||||
writeln!(file, r#"{{"event":"request","status":200}}"#).unwrap();
|
||||
|
||||
let mut config = Config::default();
|
||||
config.logging.file.enabled = false;
|
||||
config.logging.file.per_vhost = false;
|
||||
config.logging.file.path = path.to_string_lossy().into_owned();
|
||||
let sources = configured_file_log_sources(&config);
|
||||
let access = sources.iter().find(|source| source.id == "access").unwrap();
|
||||
assert!(!access.enabled);
|
||||
assert!(access.exists);
|
||||
assert!(access.size > 0);
|
||||
assert_eq!(access.path, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_tail_enforces_line_budget() {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("access.jsonl");
|
||||
let mut file = std::fs::File::create(&path).unwrap();
|
||||
for id in 0..10 {
|
||||
writeln!(file, r#"{{"time_ms":{id},"event":"request"}}"#).unwrap();
|
||||
}
|
||||
drop(file);
|
||||
|
||||
let source = source_from_path(
|
||||
"access".into(),
|
||||
"Access log".into(),
|
||||
"access".into(),
|
||||
"access",
|
||||
path,
|
||||
false,
|
||||
"JSONL",
|
||||
);
|
||||
let (lines, _bytes, truncated) = read_tail(&source, 1024 * 1024, 3).unwrap();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(lines[0].contains(r#""time_ms":9"#));
|
||||
assert!(lines[2].contains(r#""time_ms":7"#));
|
||||
assert!(truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_query_rejects_oversized_source_identifier() {
|
||||
let query = FileLogQuery {
|
||||
source: Some("x".repeat(193)),
|
||||
..FileLogQuery::default()
|
||||
};
|
||||
assert!(validate_file_log_query(&query).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_normalizes_mapped_client_ip_and_searches_diagnostics() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = source_from_path(
|
||||
"vhost:app".into(),
|
||||
"app.jsonl".into(),
|
||||
"vhost:app".into(),
|
||||
"access",
|
||||
dir.path().join("app.jsonl"),
|
||||
true,
|
||||
"JSONL",
|
||||
);
|
||||
let mut format = crate::logging::JsonFormat::default();
|
||||
format.fields.remove("client_ip");
|
||||
format
|
||||
.fields
|
||||
.insert("remote_addr".into(), "client_ip".into());
|
||||
let record = normalized_record(
|
||||
&source,
|
||||
r#"{"event":"protocol_error","remote_addr":"192.0.2.10","reason":"tls_handshake_error","error":"HTTP parser error","sni":"app.example.test","matched_by":"sni","vhost":"app"}"#,
|
||||
&format,
|
||||
);
|
||||
assert_eq!(record["client_ip"], "192.0.2.10");
|
||||
assert_eq!(record["sni"], "app.example.test");
|
||||
|
||||
let query = FileLogQuery::default();
|
||||
assert!(record_matches(
|
||||
&record,
|
||||
&query,
|
||||
&parse_file_log_search("error").unwrap()
|
||||
));
|
||||
assert!(record_matches(
|
||||
&record,
|
||||
&query,
|
||||
&parse_file_log_search(r#"error:"HTTP parser""#).unwrap()
|
||||
));
|
||||
assert!(record_matches(
|
||||
&record,
|
||||
&query,
|
||||
&parse_file_log_search("sni:app.example.test AND !country:Poland").unwrap()
|
||||
));
|
||||
assert!(!record_matches(
|
||||
&record,
|
||||
&query,
|
||||
&parse_file_log_search("sni:missing").unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_search_supports_field_aliases_negation_and_numeric_filters() {
|
||||
let record = json!({
|
||||
"method":"GET",
|
||||
"country":"Germany",
|
||||
"status":503,
|
||||
"path":"/api/users"
|
||||
});
|
||||
assert!(record_matches_search(
|
||||
&record,
|
||||
&parse_file_log_search("req: GET AND !country: Poland AND status:>=500").unwrap()
|
||||
));
|
||||
assert!(record_matches_search(
|
||||
&record,
|
||||
&parse_file_log_search("status:5xx path:/api/*").unwrap()
|
||||
));
|
||||
assert!(!record_matches_search(
|
||||
&record,
|
||||
&parse_file_log_search("country:Poland").unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_raw_error_parser_extracts_basic_fields() {
|
||||
let record = parse_raw_error_record(
|
||||
"2026-10-05T19:00:00.000Z ERROR rust_proxy::http upstream failed client_ip=192.0.2.10 status=502 error=connection reset",
|
||||
);
|
||||
assert_eq!(record["timestamp"], "2026-10-05T19:00:00.000Z");
|
||||
assert_eq!(record["level"], "ERROR");
|
||||
assert_eq!(record["target"], "rust_proxy::http");
|
||||
assert_eq!(record["message"], "upstream failed");
|
||||
assert_eq!(record["client_ip"], "192.0.2.10");
|
||||
assert_eq!(record["status"], "502");
|
||||
assert_eq!(record["error"], "connection reset");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stored_log_reader_refuses_symlink_targets() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("real.jsonl");
|
||||
let link = dir.path().join("link.jsonl");
|
||||
std::fs::write(&real, b"{}\n").unwrap();
|
||||
symlink(&real, &link).unwrap();
|
||||
assert!(open_file_log(&link).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_tail_reads_rotated_gzip_and_inventory_discovers_it() {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let active = dir.path().join("access.jsonl");
|
||||
std::fs::write(&active, b"{\"event\":\"request\",\"time_ms\":3}\n").unwrap();
|
||||
let rotated = dir.path().join("access.jsonl.1.gz");
|
||||
let file = std::fs::File::create(&rotated).unwrap();
|
||||
let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
writeln!(encoder, r#"{{"event":"request","time_ms":1}}"#).unwrap();
|
||||
writeln!(encoder, r#"{{"event":"request","time_ms":2}}"#).unwrap();
|
||||
encoder.finish().unwrap();
|
||||
|
||||
let source = source_from_path(
|
||||
"access:gz:access.jsonl.1.gz".into(),
|
||||
"Access log · access.jsonl.1.gz".into(),
|
||||
"access".into(),
|
||||
"access",
|
||||
rotated.clone(),
|
||||
true,
|
||||
"JSONL",
|
||||
);
|
||||
assert!(source.compressed);
|
||||
let (lines, bytes, truncated) = read_tail(&source, FILE_LOG_SCAN_BYTES_TOTAL, 10).unwrap();
|
||||
assert_eq!(bytes, std::fs::metadata(&rotated).unwrap().len());
|
||||
assert!(!truncated);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].contains(r#""time_ms":2"#));
|
||||
|
||||
let mut config = Config::default();
|
||||
config.logging.file.per_vhost = false;
|
||||
config.logging.file.path = active.to_string_lossy().into_owned();
|
||||
let sources = configured_file_log_sources(&config);
|
||||
assert!(sources.iter().any(|item| item.path == rotated && item.compressed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_per_vhost_inventory_groups_active_and_rotated_files() {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let active = dir.path().join("app.jsonl");
|
||||
std::fs::write(&active, b"{\"event\":\"request\"}\n").unwrap();
|
||||
let rotated = dir.path().join("app.jsonl.1.gz");
|
||||
let file = std::fs::File::create(&rotated).unwrap();
|
||||
let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
writeln!(encoder, r#"{{"event":"request"}}"#).unwrap();
|
||||
encoder.finish().unwrap();
|
||||
|
||||
let mut config = Config::default();
|
||||
config.logging.file.per_vhost = true;
|
||||
config.logging.file.directory = dir.path().to_string_lossy().into_owned();
|
||||
let sources = configured_file_log_sources(&config);
|
||||
let app = sources.iter().filter(|source| source.group_id == "vhost:app").collect::<Vec<_>>();
|
||||
assert_eq!(app.len(), 2);
|
||||
assert!(app.iter().any(|source| source.id == "vhost:app"));
|
||||
assert!(app.iter().any(|source| source.compressed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_aggregate_values_support_scalars_and_bounded_arrays() {
|
||||
let value = json!(["BadBot/1", "BadBot/1", 503, true, {"ignored":"object"}]);
|
||||
let mut values = Vec::new();
|
||||
file_log_aggregate_values(&value, &mut values);
|
||||
assert_eq!(values, vec!["BadBot/1".to_string(), "BadBot/1".to_string(), "503".to_string(), "true".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_query_validates_aggregation_controls() {
|
||||
assert!(validate_file_log_query(&FileLogQuery {
|
||||
aggregate_field: Some("user_agent".into()),
|
||||
aggregate_min_count: Some(2),
|
||||
aggregate_limit: Some(50),
|
||||
..FileLogQuery::default()
|
||||
}).is_ok());
|
||||
assert!(validate_file_log_query(&FileLogQuery {
|
||||
aggregate_field: Some("bad field".into()),
|
||||
..FileLogQuery::default()
|
||||
}).is_err());
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn only_current_build_fingerprinted_javascript_and_css_are_served() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let id = super::asset_build_id();
|
||||
for (name, suffix) in [
|
||||
("/assets/app", "js"),
|
||||
("/assets/host404", "js"),
|
||||
("/assets/style", "css"),
|
||||
("/assets/login", "js"),
|
||||
("/docs/init", "js"),
|
||||
("/docs/style", "css"),
|
||||
("/docs/swagger-ui-bundle", "js"),
|
||||
("/docs/swagger-ui", "css"),
|
||||
] {
|
||||
let legacy = format!("{name}.{suffix}");
|
||||
let wrong = format!("{name}_0000000000000000.{suffix}");
|
||||
let current = format!("{name}_{id}.{suffix}");
|
||||
for path in [legacy, wrong] {
|
||||
let response = router.clone().oneshot(request(&path)
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap())
|
||||
.await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
|
||||
}
|
||||
let response = router.clone().oneshot(request(¤t)
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap())
|
||||
.await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{current}");
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn public_document_and_discovery_contain_only_login_information() {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
for (path, expected) in [
|
||||
("/healthz", json!({"status":"ok"})),
|
||||
("/api/auth/status", json!({"setup_required":false})),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await, expected);
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 100_000)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(text.contains(&format!("/assets/login_{}.js", super::asset_build_id())));
|
||||
for absent in [
|
||||
"/assets/app.js",
|
||||
"id=\"workspace\"",
|
||||
"loginLead",
|
||||
"config-editor",
|
||||
"version",
|
||||
] {
|
||||
assert!(!text.contains(absent), "unexpected public content {absent}");
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/assets/login-langs.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bundle = json_body(response).await;
|
||||
let keys: Vec<String> = serde_json::from_str(include_str!("../web/login-keys.json")).unwrap();
|
||||
for language in bundle["languages"].as_array().unwrap() {
|
||||
assert!(language["translations"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.all(|key| keys.contains(key)));
|
||||
assert!(language["translations"].get("cacheTitle").is_none());
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#[test]
|
||||
fn directory_inventory_retains_newest_with_deterministic_ties() {
|
||||
let candidates = (0..10_000).map(|index| {
|
||||
let name = format!("{index:05}.jsonl.gz");
|
||||
(name.clone(), std::path::PathBuf::from(name), (index / 10) as u64)
|
||||
});
|
||||
let files = newest_log_files(candidates, 5);
|
||||
assert_eq!(files.len(), 5);
|
||||
let names: Vec<_> = files.iter().map(|entry| entry.0.as_str()).collect();
|
||||
assert_eq!(names, ["09990.jsonl.gz", "09991.jsonl.gz", "09992.jsonl.gz", "09993.jsonl.gz", "09994.jsonl.gz"]);
|
||||
assert!(newest_log_files(std::iter::once(("x".into(), "x".into(), 1)), 0).is_empty());
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn system_info_and_compact_live_status_reuse_existing_admin_session() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let info = router.clone().oneshot(request("/api/system-info")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(info.status(), StatusCode::OK);
|
||||
let info = json_body(info).await;
|
||||
assert!(info.get("system").is_some());
|
||||
assert!(info.get("runtime").is_some());
|
||||
assert!(info.get("memory_accounting").is_some());
|
||||
assert!(info.get("config").is_none());
|
||||
let live = router.clone().oneshot(request("/api/status?view=live")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(live.status(), StatusCode::OK);
|
||||
let live = json_body(live).await;
|
||||
assert!(live.get("metrics").is_some());
|
||||
assert!(live.get("backend_pools").is_some());
|
||||
assert!(live["listeners"].get("active_endpoints").is_some());
|
||||
assert!(live.get("system").is_none());
|
||||
assert!(live.get("memory_accounting").is_none());
|
||||
assert!(live.get("administration").is_none());
|
||||
let complete = router.clone().oneshot(request("/api/status")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(complete.status(), StatusCode::OK);
|
||||
assert!(json_body(complete).await.get("system").is_some());
|
||||
let invalid = router.clone().oneshot(request("/api/status?view=unknown")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
|
||||
let bad_sse = router.oneshot(request("/api/events?interval=4")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn defaults_and_partial_legacy_refresh_documents() {
|
||||
let previous = serde_json::to_value(AdminPreferences::default()).unwrap();
|
||||
let mut legacy = previous.as_object().unwrap().clone();
|
||||
legacy.remove("refresh");
|
||||
let loaded: AdminPreferences = serde_json::from_value(Value::Object(legacy)).unwrap();
|
||||
assert_eq!(loaded.refresh, RefreshPreferences::default());
|
||||
let partial: RefreshPreferences = serde_json::from_str(r#"{"traffic_seconds":0}"#).unwrap();
|
||||
assert_eq!(partial.traffic_seconds, 0);
|
||||
assert_eq!(partial.system_info_seconds, 60);
|
||||
assert!(validate_admin_preferences(&loaded).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_values_are_allowlisted() {
|
||||
let mut value = RefreshPreferences::default();
|
||||
for seconds in [0, 4, 11, 65000] {
|
||||
value.overview_seconds = seconds;
|
||||
assert!(validate_refresh_preferences(&value).is_err());
|
||||
}
|
||||
value.overview_seconds = 1;
|
||||
for seconds in [1, 2, 4, 31] {
|
||||
value.traffic_seconds = seconds;
|
||||
assert!(validate_refresh_preferences(&value).is_err());
|
||||
}
|
||||
value.traffic_seconds = 0;
|
||||
value.cache_seconds = 0;
|
||||
value.system_info_seconds = 0;
|
||||
assert!(validate_refresh_preferences(&value).is_ok());
|
||||
value.system_info_seconds = 16;
|
||||
assert!(validate_refresh_preferences(&value).is_err());
|
||||
assert!(serde_json::from_str::<RefreshPreferences>(r#"{"unexpected":1}"#).is_err());
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn cache_system_events_and_static_private_assets_reject_anonymous_requests() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
for path in [
|
||||
"/api/status", "/api/status?view=live", "/api/system-info", "/api/config",
|
||||
"/api/preferences", "/api/cache", "/api/cache/bans", "/api/audit",
|
||||
"/api/logs", "/api/traffic", "/api/events", "/api/live-logs",
|
||||
"/api/file-logs", "/api/certificate-api", "/docs/openapi.json",
|
||||
"/assets/app.js", "/assets/cache-panel.js", "/assets/langs.json",
|
||||
] {
|
||||
let response = router.clone().oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "anonymous GET {path}");
|
||||
assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY");
|
||||
assert!(response.headers().get("content-security-policy").is_some());
|
||||
}
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for (method, path) in [
|
||||
("POST", "/api/cache/purge"),
|
||||
("POST", "/api/cache/invalidate"),
|
||||
("DELETE", "/api/cache/bans/1"),
|
||||
("PUT", "/api/config/cache"),
|
||||
("PUT", "/api/preferences"),
|
||||
("POST", "/api/auth/logout-all"),
|
||||
] {
|
||||
let no_session = request(path).method(method).body(Body::empty()).unwrap();
|
||||
let response = router.clone().oneshot(no_session).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "anonymous {method} {path}");
|
||||
let without_csrf = request(path).method(method)
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.body(Body::empty()).unwrap();
|
||||
let response = router.clone().oneshot(without_csrf).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN, "CSRF {method} {path}");
|
||||
}
|
||||
// The valid session and CSRF continue to work for a benign mutation,
|
||||
// proving the failed requests above did not revoke the cookie.
|
||||
let response = router.oneshot(request("/api/auth/logout")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_origin_host_and_fetch_metadata_are_checked_even_with_valid_cookie() {
|
||||
let (router, store, _dir, secret, _csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let evil_origin = request("/api/status").header("cookie", &cookie)
|
||||
.header("origin", "https://another.example")
|
||||
.body(Body::empty()).unwrap();
|
||||
let response = router.clone().oneshot(evil_origin).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let mut repeated_host = request("/api/status").header("cookie", &cookie)
|
||||
.body(Body::empty()).unwrap();
|
||||
repeated_host.headers_mut().append("host", "other.example".parse().unwrap());
|
||||
let response = router.clone().oneshot(repeated_host).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let cross_site = request("/api/status").header("cookie", &cookie)
|
||||
.header("sec-fetch-site", "cross-site").body(Body::empty()).unwrap();
|
||||
let response = router.clone().oneshot(cross_site).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
store.close().await;
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
#[test]
|
||||
fn pinned_admin_proxy_proto_is_bounded_and_requires_external_https() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert!(pinned_admin_proto_compatible(&headers));
|
||||
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
|
||||
assert!(pinned_admin_proto_compatible(&headers));
|
||||
headers.insert("x-forwarded-proto", HeaderValue::from_static("https, http"));
|
||||
assert!(pinned_admin_proto_compatible(&headers));
|
||||
headers.append("x-forwarded-proto", HeaderValue::from_static("http"));
|
||||
assert!(pinned_admin_proto_compatible(&headers));
|
||||
for bad in ["http", "http, https", "https, ftp", "https,", ",https", "HTTPS", "https, http, http, http, http, http, http, http, http"] {
|
||||
headers.remove("x-forwarded-proto");
|
||||
headers.insert("x-forwarded-proto", HeaderValue::from_str(bad).unwrap());
|
||||
assert!(!pinned_admin_proto_compatible(&headers), "must reject {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pinned_panel_origin_survives_multi_hop_proxy_without_relaxing_csrf() {
|
||||
let mut config = Config::default();
|
||||
config.administration.public_origin = Some("https://panel.example.test".into());
|
||||
let (router, store, _dir, secret, csrf) = fixture_with_config(config).await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
// Without an explicit origin pin, 0.31.3 rejected this session GET as
|
||||
// external_scheme although the browser address remained correct.
|
||||
let result = router.clone().oneshot(request("/api/auth/session")
|
||||
.header("cookie", &cookie)
|
||||
.header("x-forwarded-host", "panel.example.test")
|
||||
.header("x-forwarded-proto", "https, http")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(result.status(), StatusCode::OK);
|
||||
let session_cookie = result.headers().get_all(SET_COOKIE).iter()
|
||||
.filter_map(|v| v.to_str().ok()).find(|v| v.starts_with(auth::COOKIE_NAME)).unwrap();
|
||||
assert!(session_cookie.contains("; Secure"));
|
||||
|
||||
// The incoming admin Host may itself be the pinned public DNS name.
|
||||
// There is no separate allowed_hosts entry in the saved configuration.
|
||||
let pinned_host = Request::builder().uri("/api/auth/session")
|
||||
.header("host", "panel.example.test")
|
||||
.header("cookie", &cookie)
|
||||
.header("x-forwarded-host", "panel.example.test")
|
||||
.header("x-forwarded-proto", "https, http")
|
||||
.extension(PeerAddr("127.0.0.1:45003".parse().unwrap()))
|
||||
.body(Body::empty()).unwrap();
|
||||
assert_eq!(router.clone().oneshot(pinned_host).await.unwrap().status(), StatusCode::OK);
|
||||
|
||||
// Explicit Origin, CSRF token and app request marker remain mandatory
|
||||
// for a state-changing request even when XFP contains multiple hops.
|
||||
let unauthorized = router.clone().oneshot(request("/api/auth/logout")
|
||||
.method("POST").header("cookie", &cookie)
|
||||
.header("origin", "https://panel.example.test")
|
||||
.header("x-forwarded-host", "panel.example.test")
|
||||
.header("x-forwarded-proto", "https, http")
|
||||
.extension(PeerAddr("127.0.0.1:45001".parse().unwrap()))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN);
|
||||
let authorized = router.oneshot(request("/api/auth/logout")
|
||||
.method("POST").header("cookie", &cookie)
|
||||
.header("origin", "https://panel.example.test")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", csrf)
|
||||
.header("x-forwarded-host", "panel.example.test")
|
||||
.header("x-forwarded-proto", "https, http")
|
||||
.extension(PeerAddr("127.0.0.1:45002".parse().unwrap()))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(authorized.status(), StatusCode::OK);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pinned_panel_origin_rejects_untrusted_proxy_host_and_origin() {
|
||||
let mut config = Config::default();
|
||||
config.administration.public_origin = Some("https://panel.example.test".into());
|
||||
let (router, store, _dir, secret, _) = fixture_with_config(config).await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for (xfh, xfp, origin, fetch_site) in [
|
||||
("evil.example.test", "https, http", "https://panel.example.test", "same-origin"),
|
||||
("other@panel.example.test", "https, http", "https://panel.example.test", "same-origin"),
|
||||
("panel.example.test", "http", "https://panel.example.test", "same-origin"),
|
||||
("panel.example.test", "http, https", "https://panel.example.test", "same-origin"),
|
||||
("panel.example.test", "https, http", "https://evil.example.test", "same-origin"),
|
||||
("panel.example.test", "https, http", "null", "same-origin"),
|
||||
("panel.example.test", "https, http", "https://panel.example.test", "cross-site"),
|
||||
] {
|
||||
let result = router.clone().oneshot(request("/api/auth/session")
|
||||
.header("cookie", &cookie)
|
||||
.header("x-forwarded-host", xfh)
|
||||
.header("x-forwarded-proto", xfp)
|
||||
.header("origin", origin)
|
||||
.header("sec-fetch-site", fetch_site)
|
||||
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(result.status(), StatusCode::FORBIDDEN,
|
||||
"must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}");
|
||||
}
|
||||
// Credential-bearing certificate automation does not inherit the
|
||||
// browser-origin override, even through the same trusted admin socket.
|
||||
let automation = router.clone().oneshot(request("/api/certificates/deploy")
|
||||
.method("POST")
|
||||
.header(CERTIFICATE_API_KEY_HEADER, "svc-key")
|
||||
.header("x-forwarded-host", "panel.example.test")
|
||||
.header("x-forwarded-proto", "https, http")
|
||||
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(automation.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let untrusted = router.clone().oneshot(request("/api/auth/session")
|
||||
.header("cookie", &cookie)
|
||||
.header("x-forwarded-host", "panel.example.test")
|
||||
.header("x-forwarded-proto", "https, http")
|
||||
.header("origin", "https://panel.example.test")
|
||||
.extension(PeerAddr("192.0.2.42:44000".parse().unwrap()))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(untrusted.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let mut duplicated = request("/api/auth/session")
|
||||
.header("cookie", &cookie)
|
||||
.header("x-forwarded-host", "panel.example.test")
|
||||
.header("x-forwarded-proto", "https, http")
|
||||
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
||||
.body(Body::empty()).unwrap();
|
||||
duplicated.headers_mut().append("x-forwarded-host", "panel.example.test".parse().unwrap());
|
||||
assert_eq!(router.clone().oneshot(duplicated).await.unwrap().status(), StatusCode::FORBIDDEN);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_public_origin_ambiguous_forwarded_proto_stays_forbidden() {
|
||||
let mut config = Config::default();
|
||||
config.administration.allowed_hosts = vec!["panel.example.test".into()];
|
||||
let (router, store, _dir, secret, _) = fixture_with_config(config).await;
|
||||
let result = router.oneshot(request("/api/auth/session")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.header("x-forwarded-host", "panel.example.test")
|
||||
.header("x-forwarded-proto", "https, http")
|
||||
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(result.status(), StatusCode::FORBIDDEN);
|
||||
store.close().await;
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
use super::*;
|
||||
fn settings(mode: HealthMode) -> HealthCheck {
|
||||
HealthCheck {
|
||||
mode,
|
||||
fall: 2,
|
||||
rise: 2,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn pool(algorithm: Algorithm, sticky: StickyMode) -> Arc<Pool> {
|
||||
let cfg = serde_json::from_value(serde_json::json!({
|
||||
"listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]},
|
||||
"vhosts":[{
|
||||
"id":"app","frontend":{"hosts":["app.localhost"],"listener_ids":["http"]},
|
||||
"backends":[{"id":"a","url":"http://127.0.0.1:9000","allow_private":true,"weight":3},
|
||||
{"id":"b","url":"http://127.0.0.1:9001","allow_private":true,"weight":1}],
|
||||
"load_balancing":{"algorithm":algorithm,"sticky":{"mode":sticky},"slow_start_seconds":0,"health_check":{"mode":"off"}}
|
||||
}]})).unwrap();
|
||||
let policy = crate::config::ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
};
|
||||
crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone()
|
||||
}
|
||||
fn capacity_pool(limit: u32, queue_timeout_ms: u64) -> Arc<Pool> {
|
||||
let cfg = serde_json::from_value(serde_json::json!({
|
||||
"listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]},
|
||||
"vhosts":[{
|
||||
"id":"limited","frontend":{"hosts":["limited.localhost"],"listener_ids":["http"]},
|
||||
"backends":[{
|
||||
"id":"only","url":"http://127.0.0.1:9000","allow_private":true,
|
||||
"max_concurrent_requests":limit,"queue_timeout_ms":queue_timeout_ms
|
||||
}],
|
||||
"load_balancing":{"health_check":{"mode":"off"}}
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
let policy = crate::config::ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
};
|
||||
crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone()
|
||||
}
|
||||
fn select(pool: &Pool) -> Selection {
|
||||
pool.select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), false)
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn per_backend_monitoring_can_be_disabled_or_overridden() {
|
||||
let mut defaults: crate::vhost::PoolMember = serde_json::from_value(serde_json::json!({
|
||||
"id": "error_404", "url": "http://127.0.0.1:8282", "pool_eligible": false
|
||||
})).unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.passive_health.enabled = true;
|
||||
assert!(defaults.health_check_enabled);
|
||||
assert_eq!(settings.health_for(&defaults).mode, HealthMode::Tcp);
|
||||
assert!(settings.passive_for(&defaults).enabled);
|
||||
defaults.health_check_enabled = false;
|
||||
assert_eq!(settings.health_for(&defaults).mode, HealthMode::Off);
|
||||
assert!(!settings.passive_for(&defaults).enabled);
|
||||
assert!(!defaults.pool_eligible); // routing intent is independent
|
||||
defaults.health_check_enabled = true;
|
||||
defaults.health_check = Some(HealthCheck { mode: HealthMode::Http, path: "/healthz".into(), ..Default::default() });
|
||||
let effective = settings.health_for(&defaults);
|
||||
assert_eq!(effective.mode, HealthMode::Http);
|
||||
assert_eq!(effective.path, "/healthz");
|
||||
assert_eq!(effective.host, settings.health_check.host);
|
||||
let restored: crate::vhost::PoolMember = serde_json::from_str(&serde_json::to_string(&defaults).unwrap()).unwrap();
|
||||
assert_eq!(settings.health_for(&restored).path, "/healthz");
|
||||
}
|
||||
#[test]
|
||||
fn default_health_check_uses_tcp() {
|
||||
assert_eq!(HealthMode::default(), HealthMode::Tcp);
|
||||
assert_eq!(HealthCheck::default().mode, HealthMode::Tcp);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_settings_without_passive_health_default_to_off() {
|
||||
let parsed: Settings = serde_json::from_value(serde_json::json!({
|
||||
"health_check": { "mode": "off" }
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!parsed.passive_health.enabled);
|
||||
assert!(parsed.passive_health.count_connect_errors);
|
||||
assert!(parsed.passive_health.count_timeouts);
|
||||
assert!(parsed.passive_health.count_io_errors);
|
||||
assert!(parsed.passive_health.count_http_5xx);
|
||||
assert!(!parsed.passive_health.count_slow_responses);
|
||||
assert_eq!(parsed.retry.max_retries, 1);
|
||||
assert!(parsed.retry.retry_connect_errors);
|
||||
assert!(parsed.retry.retry_timeouts);
|
||||
assert_eq!(parsed.retry.retry_statuses, vec![502, 503, 504]);
|
||||
assert_eq!(parsed.keepalive.pool_recycle_requests, 25_000);
|
||||
assert_eq!(parsed.keepalive.pool_recycle_seconds, 3_600);
|
||||
assert_eq!(parsed.keepalive.http1_max_buffer_bytes, 131_072);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_pool_recycle_limits_are_bounded_and_can_be_disabled() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(settings.validate().is_ok());
|
||||
|
||||
settings.keepalive.pool_recycle_requests = 0;
|
||||
settings.keepalive.pool_recycle_seconds = 0;
|
||||
assert!(settings.validate().is_ok());
|
||||
|
||||
settings.keepalive.pool_recycle_requests = 999;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.keepalive.pool_recycle_requests = 1_000;
|
||||
settings.keepalive.pool_recycle_seconds = 59;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.keepalive.pool_recycle_seconds = 60;
|
||||
settings.keepalive.http1_max_buffer_bytes = 8_191;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.keepalive.http1_max_buffer_bytes = 8_192;
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_pool_rebuild_reuses_upstream_clients() {
|
||||
let previous = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let mut rebuilt = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let rebuilt_mut = Arc::get_mut(&mut rebuilt).expect("fresh pool must be uniquely owned");
|
||||
|
||||
assert!(!Arc::ptr_eq(
|
||||
&rebuilt_mut.members[0].backend,
|
||||
&previous.members[0].backend
|
||||
));
|
||||
rebuilt_mut.reuse_observations(&previous);
|
||||
assert!(Arc::ptr_eq(
|
||||
&rebuilt_mut.members[0].backend,
|
||||
&previous.members[0].backend
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_policy_rejects_unbounded_or_non_5xx_configuration() {
|
||||
let mut settings = Settings::default();
|
||||
settings.retry.max_retries = 4;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.retry.max_retries = 1;
|
||||
settings.retry.retry_statuses = vec![429];
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traffic_rate_drops_to_zero_after_an_idle_sample_window() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
state
|
||||
.bytes_out
|
||||
.store(2048, std::sync::atomic::Ordering::Relaxed);
|
||||
lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2);
|
||||
state.sample_traffic();
|
||||
assert!(state.traffic_rate() > 0.0);
|
||||
lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2);
|
||||
state.sample_traffic();
|
||||
assert_eq!(state.traffic_rate(), 0.0);
|
||||
}
|
||||
#[test]
|
||||
fn active_failures_are_independent_from_passive_user_traffic() {
|
||||
let state = BackendState::new(HealthMode::Http);
|
||||
let settings = settings(HealthMode::Http);
|
||||
let passive = PassiveHealth::default();
|
||||
let fast = Duration::from_millis(5);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
state.observe_active(&settings, "status", fast);
|
||||
state.observe_passive(&settings, &passive, "ok", fast);
|
||||
state.observe_active(&settings, "status", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&settings, &passive, "http_5xx", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_health_is_opt_in_and_uses_separate_thresholds() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let fast = Duration::from_millis(5);
|
||||
for _ in 0..10 {
|
||||
state.observe_passive(&active, &PassiveHealth::default(), "http_5xx", fast);
|
||||
}
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 10);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
fall: Some(2),
|
||||
rise: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
assert!(state.healthy());
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
#[test]
|
||||
fn passive_http_5xx_can_be_explicitly_ignored_when_passive_health_is_on() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_http_5xx: false,
|
||||
fall: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..100 {
|
||||
state.observe_passive(&active, &passive, "http_5xx", Duration::from_millis(5));
|
||||
}
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 100);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_connect_errors_use_the_explicit_fall_threshold() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_connect_errors: true,
|
||||
fall: Some(3),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..2 {
|
||||
state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(5));
|
||||
assert!(state.healthy());
|
||||
}
|
||||
state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(5));
|
||||
assert!(!state.healthy());
|
||||
assert_eq!(state.connect_errors_total.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_health_can_stay_healthy_while_passive_health_is_off() {
|
||||
let state = BackendState::new(HealthMode::Tcp);
|
||||
let active = settings(HealthMode::Tcp);
|
||||
let passive = PassiveHealth::default();
|
||||
let fast = Duration::from_millis(5);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
for _ in 0..20 {
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
}
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 20);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhealthy_source_hands_off_when_the_original_health_domain_recovers() {
|
||||
let state = BackendState::new(HealthMode::Tcp);
|
||||
let active = HealthCheck {
|
||||
fall: 1,
|
||||
rise: 1,
|
||||
..settings(HealthMode::Tcp)
|
||||
};
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_http_5xx: true,
|
||||
fall: Some(1),
|
||||
rise: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let fast = Duration::from_millis(5);
|
||||
|
||||
state.observe_active(&active, "ok", fast);
|
||||
state.observe_active(&active, "connect_error", fast);
|
||||
{
|
||||
let health = lock(&state.health);
|
||||
assert_eq!(health.unhealthy_source, "active_tcp");
|
||||
}
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
{
|
||||
let health = lock(&state.health);
|
||||
assert!(!health.healthy);
|
||||
assert_eq!(health.unhealthy_source, "passive_http_5xx");
|
||||
assert_eq!(health.unhealthy_reason, "http_5xx");
|
||||
}
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_checks_never_quarantine_and_probes_are_single_flight() {
|
||||
let off = BackendState::new(HealthMode::Off);
|
||||
let settings = settings(HealthMode::Off);
|
||||
for _ in 0..5 {
|
||||
off.observe_passive(
|
||||
&settings,
|
||||
&PassiveHealth::default(),
|
||||
"connect_error",
|
||||
Duration::ZERO,
|
||||
);
|
||||
}
|
||||
assert!(off.healthy());
|
||||
assert!(!off.claim_probe(&settings));
|
||||
let on = BackendState::new(HealthMode::Http);
|
||||
let settings = super::tests::settings(HealthMode::Http);
|
||||
assert!(on.claim_probe(&settings));
|
||||
assert!(!on.claim_probe(&settings));
|
||||
on.observe_active(&settings, "ok", Duration::ZERO);
|
||||
assert!(!on.claim_probe(&settings));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn weighted_round_robin_distributes_three_to_one_without_body_collection() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Off);
|
||||
let mut counts = [0, 0];
|
||||
for _ in 0..40 {
|
||||
let selection = select(&pool);
|
||||
counts[usize::from(selection.lease.member.definition.id == "b")] += 1;
|
||||
}
|
||||
assert_eq!(counts, [30, 10]);
|
||||
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(pool.members[0].state.requests.load(Ordering::Relaxed), 0); // Mere cache selection isn't origin traffic.
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn least_connections_counts_a_lease_until_its_last_owner_drops() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let mut held = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let selection = select(&pool);
|
||||
assert_eq!(selection.lease.member.definition.id, "a");
|
||||
held.push(selection.lease);
|
||||
}
|
||||
let other = select(&pool);
|
||||
assert_eq!(other.lease.member.definition.id, "b");
|
||||
let clone = held[0].clone();
|
||||
held.clear();
|
||||
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 1);
|
||||
drop(clone);
|
||||
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn failover_selection_never_reuses_an_attempted_backend() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let first = select(&pool);
|
||||
let first_id = first.lease.member.definition.id.clone();
|
||||
drop(first);
|
||||
let retry = pool
|
||||
.select_retry(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
&HeaderMap::new(),
|
||||
false,
|
||||
std::slice::from_ref(&first_id),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(retry.lease.member.definition.id, first_id);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn backend_concurrency_limit_queues_then_rejects_and_recovers() {
|
||||
let pool = capacity_pool(1, 10);
|
||||
let first = select(&pool);
|
||||
let headers = HeaderMap::new();
|
||||
let saturated = pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await;
|
||||
assert!(matches!(saturated, Err(SelectionFailure::Capacity)));
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["capacity_rejections_total"], 1);
|
||||
assert_eq!(member["queue_timeouts_total"], 1);
|
||||
assert_eq!(member["active"], 1);
|
||||
drop(first);
|
||||
assert!(pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_capacity_queue_timeout_rejects_without_counting_a_queue_timeout() {
|
||||
let pool = capacity_pool(1, 0);
|
||||
let first = select(&pool);
|
||||
let headers = HeaderMap::new();
|
||||
let saturated = pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await;
|
||||
assert!(matches!(saturated, Err(SelectionFailure::Capacity)));
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["capacity_rejections_total"], 1);
|
||||
assert_eq!(member["queue_timeouts_total"], 0);
|
||||
drop(first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_backend_failures_are_exposed_without_losing_aggregate_counters() {
|
||||
let pool = capacity_pool(0, 0);
|
||||
let selection = select(&pool);
|
||||
selection.lease.error_detail(
|
||||
"dns",
|
||||
"lookup backend.example failed",
|
||||
Duration::from_millis(7),
|
||||
);
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["dns_errors_total"], 1);
|
||||
assert_eq!(member["connect_errors_total"], 1);
|
||||
assert_eq!(member["last_failure_class"], "dns");
|
||||
assert_eq!(
|
||||
member["last_failure_detail"],
|
||||
"lookup backend.example failed"
|
||||
);
|
||||
assert!(member["last_failure_at_ms"].as_u64().unwrap_or_default() > 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn traffic_and_adaptive_selection_use_observed_cost_and_weight() {
|
||||
for algorithm in [Algorithm::LeastTraffic, Algorithm::Adaptive] {
|
||||
let pool = pool(algorithm, StickyMode::Off);
|
||||
lock(&pool.members[0].state.traffic).rate = 50_000_000.0;
|
||||
lock(&pool.members[0].state.health).ewma_us = 1_000_000;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn ip_hash_is_stable_and_cannot_be_spoofed_with_forwarded_headers() {
|
||||
let pool = pool(Algorithm::IpHash, StickyMode::Off);
|
||||
let original = select(&pool).lease.member.definition.id.clone();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "10.0.0.99".parse().unwrap());
|
||||
for ip in ["127.0.0.1", "::ffff:127.0.0.1"] {
|
||||
for _ in 0..8 {
|
||||
assert_eq!(
|
||||
pool.select(ip.parse().unwrap(), &headers, false)
|
||||
.unwrap()
|
||||
.lease
|
||||
.member
|
||||
.definition
|
||||
.id,
|
||||
original
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn opaque_cookie_is_removed_from_origin_requests_and_protected_on_responses() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
let first = select(&pool);
|
||||
let cookie = first.cookie.unwrap().to_str().unwrap().to_owned();
|
||||
assert!(
|
||||
cookie.contains("HttpOnly")
|
||||
&& cookie.contains("SameSite=Lax")
|
||||
&& !cookie.contains("Secure")
|
||||
);
|
||||
let cookie = cookie.split(';').next().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", format!("{cookie}; app=ok").parse().unwrap());
|
||||
let next = pool
|
||||
.select("10.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
next.lease.member.definition.id,
|
||||
first.lease.member.definition.id
|
||||
);
|
||||
assert!(next.cookie.is_none());
|
||||
pool.strip_affinity(&mut headers);
|
||||
assert_eq!(headers["cookie"], "app=ok");
|
||||
headers.append(
|
||||
"set-cookie",
|
||||
format!("{}=origin-spoof", pool.cookie_name)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.append("set-cookie", "app=ok".parse().unwrap());
|
||||
pool.strip_affinity_response(&mut headers);
|
||||
assert_eq!(headers.get_all("set-cookie").iter().count(), 1);
|
||||
assert_eq!(headers["set-cookie"], "app=ok");
|
||||
let secure = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), true)
|
||||
.unwrap();
|
||||
assert!(secure
|
||||
.cookie
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("; Secure"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn failed_sticky_member_is_replaced_and_duplicate_cookies_are_ignored() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
let first = select(&pool);
|
||||
let cookie = first
|
||||
.cookie
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", format!("{cookie}; {cookie}").parse().unwrap());
|
||||
assert!(pool.affinity(&headers).is_none());
|
||||
headers.insert("cookie", cookie.parse().unwrap());
|
||||
lock(&first.lease.member.state.health).healthy = false;
|
||||
let next = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
next.lease.member.definition.id,
|
||||
first.lease.member.definition.id
|
||||
);
|
||||
assert!(next.cookie.is_some());
|
||||
lock(&next.lease.member.state.health).healthy = false;
|
||||
assert!(pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn slow_start_ramps_weight_and_error_is_counted_once_per_exchange() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let selected = select(&pool);
|
||||
let state = &selected.lease.member.state;
|
||||
assert!(state.weight(100, 100) < 100.0);
|
||||
assert_eq!(state.weight(100, 0), 100.0);
|
||||
selected.lease.started();
|
||||
selected.lease.bytes(50, true);
|
||||
selected.lease.bytes(100, false);
|
||||
selected.lease.error("timeout", Duration::from_secs(1));
|
||||
selected.lease.error("body", Duration::from_secs(2));
|
||||
selected.lease.retry_attempted();
|
||||
selected.lease.retry_succeeded();
|
||||
assert_eq!(state.requests.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.errors.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.timeouts_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.io_errors_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.bytes_in.load(Ordering::Relaxed), 50);
|
||||
assert_eq!(state.bytes_out.load(Ordering::Relaxed), 100);
|
||||
assert_eq!(state.retry_attempts_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.retry_successes_total.load(Ordering::Relaxed), 1);
|
||||
assert!(pool
|
||||
.prometheus()
|
||||
.contains("rust_proxy_backend_requests_total{vhost=\"app\",backend=\"a\"} 1"));
|
||||
assert!(pool
|
||||
.prometheus()
|
||||
.contains("rust_proxy_backend_retry_attempts_total{vhost=\"app\",backend=\"a\"} 1"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn slow_5xx_counts_both_observability_classes_when_passive_is_off() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let selected = select(&pool);
|
||||
selected.lease.response(500, Duration::from_secs(20), true);
|
||||
let state = &selected.lease.member.state;
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.slow_responses_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_upload_duration_does_not_trigger_passive_slow_failure() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let upload = select(&pool);
|
||||
upload.lease.response(200, Duration::from_secs(20), false);
|
||||
assert_eq!(upload.lease.member.state.errors.load(Ordering::Relaxed), 0);
|
||||
let read = select(&pool);
|
||||
read.lease.response(200, Duration::from_secs(20), true);
|
||||
assert_eq!(read.lease.member.state.errors.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn backup_is_only_new_assignment_when_no_primary_is_eligible() {
|
||||
let mut pool = pool(Algorithm::RoundRobin, StickyMode::Off);
|
||||
Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[1])
|
||||
.unwrap()
|
||||
.definition
|
||||
.backup = true;
|
||||
for _ in 0..8 {
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "a");
|
||||
}
|
||||
lock(&pool.members[0].state.health).healthy = false;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
lock(&pool.members[0].state.health).healthy = true;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "a");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn drain_rejects_new_sessions_but_preserves_valid_existing_affinity() {
|
||||
let mut pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[0])
|
||||
.unwrap()
|
||||
.definition
|
||||
.drain = true;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("{}={}", pool.cookie_name, pool.members[0].token)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let existing = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_eq!(existing.lease.member.definition.id, "a");
|
||||
lock(&pool.members[0].state.health).healthy = false;
|
||||
assert_eq!(
|
||||
pool.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap()
|
||||
.lease
|
||||
.member
|
||||
.definition
|
||||
.id,
|
||||
"b"
|
||||
);
|
||||
}
|
||||
-416
@@ -1,416 +0,0 @@
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
fn cold_settings() -> Settings {
|
||||
Settings {
|
||||
enabled: true,
|
||||
max_entries: 1,
|
||||
max_bytes: 256 * 1024,
|
||||
max_object_bytes: 64 * 1024,
|
||||
cold_enabled: true,
|
||||
cold_path: "cache/cold-test".into(),
|
||||
cold_max_bytes: 2 * 1024 * 1024,
|
||||
cold_max_entries: 16,
|
||||
cold_after_seconds: 300,
|
||||
..Settings::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn fill(cache: &Arc<Cache>, settings: &Settings, path: &str, body: &'static str) -> Ticket {
|
||||
let ticket = cache
|
||||
.ticket_scoped(
|
||||
11,
|
||||
"cold-test",
|
||||
"http",
|
||||
"cold.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
path,
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=120"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("etag", HeaderValue::from_static("\"cold-v1\""));
|
||||
let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap();
|
||||
cache
|
||||
.wrap(response, ticket.clone(), plan, settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
ticket
|
||||
}
|
||||
|
||||
async fn wait_for_cold(cache: &Cache, expected: usize) {
|
||||
for _ in 0..100 {
|
||||
if cache.view().cold_entries >= expected {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("cold tier did not persist in time");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selective_purge_removes_matching_cold_object() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = cold_settings();
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
let first = fill(&cache, &settings, "/one?v=1", "first-payload").await;
|
||||
let _second = fill(&cache, &settings, "/two", "second-payload").await;
|
||||
wait_for_cold(&cache, 1).await;
|
||||
assert_eq!(cache.view().cold_entries, 1);
|
||||
let result = cache
|
||||
.invalidate(InvalidationRequest {
|
||||
operation: InvalidationOperation::Purge,
|
||||
selector: InvalidationSelector::Exact,
|
||||
value: "/one?v=1".into(),
|
||||
host: Some("cold.example".into()),
|
||||
vhost: Some("cold-test".into()),
|
||||
ttl_seconds: None,
|
||||
confirm_wide: false,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 1);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
assert!(cache.get_tiered(&first, &settings).await.is_none());
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lru_demotion_persists_and_survives_restart() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = cold_settings();
|
||||
settings.validate().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
let first = fill(&cache, &settings, "/one", "first-payload").await;
|
||||
let _second = fill(&cache, &settings, "/two", "second-payload").await;
|
||||
wait_for_cold(&cache, 1).await;
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
assert_eq!(cache.view().cold_entries, 1);
|
||||
let cold = cache.dashboard(Some("cold-test"), 0, 50);
|
||||
assert!(cold
|
||||
.objects
|
||||
.iter()
|
||||
.any(|row| row.path == "/one" && row.tier == "cold"));
|
||||
|
||||
shutdown.cancel();
|
||||
drop(cache);
|
||||
|
||||
let restart_shutdown = CancellationToken::new();
|
||||
let restarted = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
restart_shutdown.clone(),
|
||||
);
|
||||
assert_eq!(restarted.view().cold_entries, 1);
|
||||
let hit = restarted
|
||||
.get_tiered(&first, &settings)
|
||||
.await
|
||||
.expect("cold object should be readable after restart");
|
||||
let payload = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(payload, Bytes::from_static(b"first-payload"));
|
||||
assert_eq!(restarted.view().cold_hits, 1);
|
||||
assert_eq!(restarted.view().cold_promotions, 1);
|
||||
restart_shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
|
||||
let transformed_ticket = cache
|
||||
.ticket_scoped(
|
||||
11,
|
||||
"cold-test",
|
||||
"http",
|
||||
"cold.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/transformed.json",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut transformed = Response::new(Body::from(r#"{"a":1}"#));
|
||||
transformed.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static(
|
||||
"public, max-age=120, stale-while-revalidate=30, stale-if-error=90",
|
||||
),
|
||||
);
|
||||
transformed.headers_mut().insert(
|
||||
"etag",
|
||||
HeaderValue::from_static("\"rustproxy-representation\""),
|
||||
);
|
||||
transformed
|
||||
.headers_mut()
|
||||
.insert("content-length", HeaderValue::from_static("7"));
|
||||
transformed
|
||||
.extensions_mut()
|
||||
.insert(crate::transform::OriginValidators {
|
||||
etag: Some(HeaderValue::from_static("\"origin-v1\"")),
|
||||
last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
|
||||
});
|
||||
transformed
|
||||
.extensions_mut()
|
||||
.insert(crate::transform::Applied {
|
||||
kind: crate::transform::Kind::Json,
|
||||
original_bytes: 9,
|
||||
transformed_bytes: 7,
|
||||
});
|
||||
let transformed_plan = plan(
|
||||
StatusCode::OK,
|
||||
transformed.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap(
|
||||
transformed,
|
||||
transformed_ticket.clone(),
|
||||
transformed_plan,
|
||||
&settings,
|
||||
)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let protected_ticket = cache
|
||||
.ticket_scoped(
|
||||
11,
|
||||
"cold-test",
|
||||
"http",
|
||||
"cold.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/protected.css",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let original_body = "body { color: red; }";
|
||||
let mut protected = Response::new(Body::from(original_body));
|
||||
protected.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=120, immutable, no-transform"),
|
||||
);
|
||||
protected
|
||||
.headers_mut()
|
||||
.insert("etag", HeaderValue::from_static("\"origin-css\""));
|
||||
protected.extensions_mut().insert(crate::compression::Skip);
|
||||
let protected_plan = plan(
|
||||
StatusCode::OK,
|
||||
protected.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap(
|
||||
protected,
|
||||
protected_ticket.clone(),
|
||||
protected_plan,
|
||||
&settings,
|
||||
)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cache.flush_hot_to_cold().await, 2);
|
||||
wait_for_cold(&cache, 2).await;
|
||||
shutdown.cancel();
|
||||
drop(cache);
|
||||
|
||||
let restart_shutdown = CancellationToken::new();
|
||||
let restarted = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
restart_shutdown.clone(),
|
||||
);
|
||||
assert_eq!(restarted.view().cold_entries, 2);
|
||||
// COLD entries intentionally keep validators and headers on disk to bound RAM.
|
||||
// Inspect them through the same verified metadata loader used on COLD hits.
|
||||
let (key, transformed) = {
|
||||
let cold = restarted.cold.lock().unwrap();
|
||||
cold.entries
|
||||
.iter()
|
||||
.find(|(_, entry)| entry.path == "/transformed.json")
|
||||
.map(|(key, entry)| (*key, entry.clone()))
|
||||
.expect("transformed cold entry")
|
||||
};
|
||||
assert_eq!(transformed.stale_while_revalidate, 30);
|
||||
assert_eq!(transformed.stale_if_error, 90);
|
||||
assert!(transformed.has_validator);
|
||||
assert!(transformed.transformed);
|
||||
let metadata = read_cold_metadata(&key, &transformed)
|
||||
.await
|
||||
.expect("persisted transformed metadata");
|
||||
assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
|
||||
assert_eq!(
|
||||
metadata.origin_last_modified.as_ref().unwrap(),
|
||||
"Mon, 26 Jan 2026 23:11:40 GMT"
|
||||
);
|
||||
assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\"");
|
||||
|
||||
let transformed_hit = restarted
|
||||
.get_tiered(&transformed_ticket, &settings)
|
||||
.await
|
||||
.expect("transformed object after restart");
|
||||
assert_eq!(
|
||||
transformed_hit.response.headers()["etag"],
|
||||
"\"rustproxy-representation\""
|
||||
);
|
||||
assert!(transformed_hit.transformed);
|
||||
assert!(transformed_hit
|
||||
.response
|
||||
.extensions()
|
||||
.get::<crate::transform::CachedTransformed>()
|
||||
.is_some());
|
||||
assert_eq!(
|
||||
transformed_hit
|
||||
.response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes(),
|
||||
Bytes::from_static(br#"{"a":1}"#)
|
||||
);
|
||||
|
||||
let protected_hit = restarted
|
||||
.get_tiered(&protected_ticket, &settings)
|
||||
.await
|
||||
.expect("no-transform object after restart");
|
||||
assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\"");
|
||||
assert!(crate::transform::no_transform(
|
||||
protected_hit.response.headers()
|
||||
));
|
||||
assert!(protected_hit
|
||||
.response
|
||||
.extensions()
|
||||
.get::<crate::compression::Skip>()
|
||||
.is_some());
|
||||
assert_eq!(
|
||||
protected_hit
|
||||
.response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes(),
|
||||
Bytes::from_static(b"body { color: red; }")
|
||||
);
|
||||
restart_shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_policy_can_disable_lru_demotion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.cold_on_lru = false;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/one", "first-payload").await;
|
||||
fill(&cache, &settings, "/two", "second-payload").await;
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
let view = cache.view();
|
||||
assert_eq!(view.entries, 1);
|
||||
assert_eq!(view.cold_entries, 0);
|
||||
assert_eq!(view.evictions, 1);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_policy_can_disable_idle_demotion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
settings.cold_on_idle = false;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/idle", "idle-payload").await;
|
||||
{
|
||||
let mut store = cache.store.lock().unwrap();
|
||||
for entry in store.entries.values_mut() {
|
||||
entry.last_access = Instant::now() - Duration::from_secs(600);
|
||||
}
|
||||
}
|
||||
cache.demote_idle();
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
|
||||
settings.cold_on_idle = true;
|
||||
cache.configure(settings.clone());
|
||||
cache.demote_idle();
|
||||
wait_for_cold(&cache, 1).await;
|
||||
assert_eq!(cache.view().entries, 0);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_policy_can_demote_by_total_hot_residency_age() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
settings.cold_on_idle = false;
|
||||
settings.cold_on_age = true;
|
||||
settings.cold_max_hot_seconds = 5;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/aged", "aged-payload").await;
|
||||
{
|
||||
let mut store = cache.store.lock().unwrap();
|
||||
for entry in store.entries.values_mut() {
|
||||
entry.resident_since = Instant::now() - Duration::from_secs(60);
|
||||
entry.last_access = Instant::now();
|
||||
}
|
||||
}
|
||||
cache.demote_idle();
|
||||
wait_for_cold(&cache, 1).await;
|
||||
assert_eq!(cache.view().entries, 0);
|
||||
assert_eq!(cache.view().cold_entries, 1);
|
||||
shutdown.cancel();
|
||||
}
|
||||
-327
@@ -1,327 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/one", "first-payload").await;
|
||||
fill(&cache, &settings, "/two", "second-payload").await;
|
||||
assert_eq!(cache.view().entries, 2);
|
||||
assert!(cache.view().resident_bytes > 0);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
|
||||
assert_eq!(cache.flush_hot_to_cold().await, 2);
|
||||
let view = cache.view();
|
||||
assert_eq!(view.entries, 0);
|
||||
assert_eq!(view.resident_bytes, 0);
|
||||
assert_eq!(view.hot_logical_bytes, 0);
|
||||
assert_eq!(
|
||||
view.transient_bytes, 0,
|
||||
"persisted ColdEntry must not retain the body allocation"
|
||||
);
|
||||
assert_eq!(view.cold_entries, 2);
|
||||
shutdown.cancel();
|
||||
|
||||
let restart_shutdown = CancellationToken::new();
|
||||
let restarted = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
restart_shutdown.clone(),
|
||||
);
|
||||
assert_eq!(restarted.view().cold_entries, 2);
|
||||
restart_shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
settings.cold_on_shutdown = false;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/update", "update-payload").await;
|
||||
assert_eq!(cache.flush_hot_to_cold().await, 0);
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
assert_eq!(cache.flush_hot_to_cold_for_update().await, 1);
|
||||
let view = cache.view();
|
||||
assert_eq!(view.entries, 0);
|
||||
assert_eq!(view.resident_bytes, 0);
|
||||
assert_eq!(view.cold_entries, 1);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn graceful_shutdown_flush_can_be_disabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
settings.cold_on_shutdown = false;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/one", "first-payload").await;
|
||||
assert_eq!(cache.flush_hot_to_cold().await, 0);
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_cache_uses_two_hex_character_shards() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = cold_settings();
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/one", "first-payload").await;
|
||||
fill(&cache, &settings, "/two", "second-payload").await;
|
||||
wait_for_cold(&cache, 1).await;
|
||||
let root = dir.path().join(&settings.cold_path);
|
||||
let mut shards = Vec::new();
|
||||
for item in std::fs::read_dir(&root).unwrap() {
|
||||
let item = item.unwrap();
|
||||
let name = item.file_name().to_string_lossy().to_string();
|
||||
assert!(
|
||||
item.path().is_dir(),
|
||||
"cold root must contain shard directories only"
|
||||
);
|
||||
assert_eq!(name.len(), 2);
|
||||
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
|
||||
shards.push(item.path());
|
||||
}
|
||||
assert!(!shards.is_empty());
|
||||
assert!(shards.iter().any(|shard| {
|
||||
std::fs::read_dir(shard)
|
||||
.unwrap()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.any(|entry| {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
name.ends_with(".json") || name.ends_with(".body")
|
||||
})
|
||||
}));
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_flat_cold_cache_is_not_migrated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = cold_settings();
|
||||
let root = dir.path().join(&settings.cold_path);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("legacy.body"), b"old").unwrap();
|
||||
std::fs::write(root.join("legacy.json"), b"{}").unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings,
|
||||
shutdown.clone(),
|
||||
);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
assert!(!root.join("legacy.body").exists());
|
||||
assert!(!root.join("legacy.json").exists());
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 1;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/one", "first-payload").await;
|
||||
fill(&cache, &settings, "/two", "second-payload").await;
|
||||
fill(&cache, &settings, "/three", "third-payload").await;
|
||||
wait_for_cold(&cache, 2).await;
|
||||
let job = cache
|
||||
.start_cold_cleanup(ColdCleanupRequest {
|
||||
unused_for_seconds: None,
|
||||
oldest: Some(1),
|
||||
hits_zero: false,
|
||||
})
|
||||
.unwrap();
|
||||
for _ in 0..100 {
|
||||
let current = cache.cold_cleanup_job(job.id).unwrap();
|
||||
if current.status == "done" {
|
||||
assert_eq!(current.removed_entries, 1);
|
||||
assert!(current.freed_bytes > 0);
|
||||
assert_eq!(cache.view().cold_entries, 1);
|
||||
shutdown.cancel();
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("cold cleanup did not finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_cold_cleanup_is_single_flight() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = cold_settings();
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings,
|
||||
shutdown.clone(),
|
||||
);
|
||||
let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
|
||||
let result = cache.start_cold_cleanup(ColdCleanupRequest {
|
||||
unused_for_seconds: None,
|
||||
oldest: Some(1),
|
||||
hits_zero: false,
|
||||
});
|
||||
assert!(result.is_err());
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 1;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/one", "first-payload").await;
|
||||
fill(&cache, &settings, "/two", "second-payload").await;
|
||||
wait_for_cold(&cache, 1).await;
|
||||
|
||||
let key = {
|
||||
let mut cold = cache.cold.lock().unwrap();
|
||||
let key = *cold.entries.keys().next().unwrap();
|
||||
cold.entries.get_mut(&key).unwrap().pinned_reads = 1;
|
||||
key
|
||||
};
|
||||
let request = ColdCleanupRequest {
|
||||
unused_for_seconds: None,
|
||||
oldest: Some(1),
|
||||
hits_zero: false,
|
||||
};
|
||||
let result = cache
|
||||
.remove_cold_candidates(Some(&request), None, None, 1)
|
||||
.await;
|
||||
assert_eq!(result.removed_entries, 0);
|
||||
assert!(cache.cold.lock().unwrap().entries.contains_key(&key));
|
||||
|
||||
cache
|
||||
.cold
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.get_mut(&key)
|
||||
.unwrap()
|
||||
.pinned_reads = 0;
|
||||
let result = cache
|
||||
.remove_cold_candidates(Some(&request), None, None, 1)
|
||||
.await;
|
||||
assert_eq!(result.removed_entries, 1);
|
||||
assert!(!cache.cold.lock().unwrap().entries.contains_key(&key));
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() {
|
||||
let cache = Cache::new(1);
|
||||
let key = [7_u8; 32];
|
||||
let template = ColdEntry {
|
||||
base_key: key,
|
||||
vary: Vec::new(),
|
||||
revision: 1,
|
||||
scope: "app".to_owned(),
|
||||
scheme: "https".to_owned(),
|
||||
authority: "example.test".to_owned(),
|
||||
domain: "example.test".to_owned(),
|
||||
path: "/asset".to_owned(),
|
||||
target: "https://example.test/asset".to_owned(),
|
||||
tags: Vec::new(),
|
||||
created_at: SystemTime::now(),
|
||||
body_path: PathBuf::from("old.body"),
|
||||
meta_path: PathBuf::from("old.json"),
|
||||
body_bytes: 1,
|
||||
body_sha256: [0u8; 32],
|
||||
disk_bytes: 2,
|
||||
stored_at: SystemTime::now(),
|
||||
last_access: SystemTime::now(),
|
||||
age_at_store_seconds: 0,
|
||||
resident_age_at_store_seconds: 0,
|
||||
ttl: 60,
|
||||
stale_while_revalidate: 0,
|
||||
stale_if_error: 0,
|
||||
must_revalidate: false,
|
||||
proxy_revalidate: false,
|
||||
has_validator: false,
|
||||
hits: 0,
|
||||
transformed: false,
|
||||
sequence: 1,
|
||||
generation: 1,
|
||||
skip_compression: false,
|
||||
requires_revalidation: false,
|
||||
pinned_reads: 1,
|
||||
};
|
||||
cache
|
||||
.cold
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.insert(key, template.clone());
|
||||
let old_guard = ColdReadGuard {
|
||||
cache: &cache,
|
||||
key,
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
let mut replacement = template;
|
||||
replacement.sequence = 2;
|
||||
replacement.generation = 2;
|
||||
replacement.pinned_reads = 1;
|
||||
cache.cold.lock().unwrap().entries.insert(key, replacement);
|
||||
drop(old_guard);
|
||||
|
||||
let cold = cache.cold.lock().unwrap();
|
||||
let replacement = cold.entries.get(&key).unwrap();
|
||||
assert_eq!(replacement.generation, 2);
|
||||
assert_eq!(replacement.pinned_reads, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cold_path_cannot_escape_data_directory() {
|
||||
let mut settings = Settings::default();
|
||||
settings.cold_path = "../outside".into();
|
||||
assert!(settings.validate().is_err());
|
||||
settings.cold_path = "/tmp/outside".into();
|
||||
assert!(settings.validate().is_err());
|
||||
settings.cold_path = "cache/cold".into();
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
Vendored
-404
@@ -1,404 +0,0 @@
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
fn headers(value: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("cache-control", value.parse().unwrap());
|
||||
h
|
||||
}
|
||||
fn settings() -> Settings {
|
||||
Settings {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn ticket(cache: &Cache, headers: &HeaderMap) -> Ticket {
|
||||
cache
|
||||
.ticket(1, "http", "example.test", "app", "/", headers)
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn ttl_safety_and_shared_directives() {
|
||||
let s = settings();
|
||||
for control in [
|
||||
"private, max-age=60",
|
||||
"no-store, max-age=60",
|
||||
"max-age=0",
|
||||
"max-age=invalid",
|
||||
"max-age=60,max-age=90",
|
||||
"public",
|
||||
"must-understand, max-age=60",
|
||||
"max-age=\"60",
|
||||
"max-age=60\"",
|
||||
"public=invalid,max-age=60",
|
||||
] {
|
||||
assert!(
|
||||
plan(StatusCode::OK, &headers(control), &s, Duration::ZERO).is_none(),
|
||||
"{control}"
|
||||
);
|
||||
}
|
||||
assert!(plan(
|
||||
StatusCode::OK,
|
||||
&headers("public,max-age=60"),
|
||||
&s,
|
||||
Duration::ZERO
|
||||
)
|
||||
.is_some());
|
||||
let mut revalidate_headers = headers("no-cache, max-age=60");
|
||||
revalidate_headers.insert("etag", HeaderValue::from_static("\"validator\""));
|
||||
let revalidate = plan(StatusCode::OK, &revalidate_headers, &s, Duration::ZERO)
|
||||
.expect("no-cache with a validator permits storage for validation");
|
||||
assert!(revalidate.requires_revalidation);
|
||||
assert!(plan(StatusCode::OK, &headers("no-cache"), &s, Duration::ZERO).is_none());
|
||||
let mut validator_only = headers("no-cache");
|
||||
validator_only.insert("etag", HeaderValue::from_static("\"validator\""));
|
||||
assert!(plan(StatusCode::OK, &validator_only, &s, Duration::ZERO).is_some());
|
||||
assert!(plan(
|
||||
StatusCode::OK,
|
||||
&headers("max-age=60,s-maxage=0"),
|
||||
&s,
|
||||
Duration::ZERO
|
||||
)
|
||||
.is_none());
|
||||
let shared = plan(
|
||||
StatusCode::OK,
|
||||
&headers("public, max-age=600, s-maxage=30"),
|
||||
&s,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(shared.ttl, 30);
|
||||
let mut h = headers("max-age=60");
|
||||
h.insert("age", "61".parse().unwrap());
|
||||
assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some());
|
||||
h.remove("age");
|
||||
h.insert("set-cookie", "session=secret".parse().unwrap());
|
||||
assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn request_policy_uses_conditional_cache_and_revalidation() {
|
||||
for name in [
|
||||
"authorization",
|
||||
"cookie",
|
||||
"if-match",
|
||||
"if-unmodified-since",
|
||||
"upgrade",
|
||||
] {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
"value".parse().unwrap(),
|
||||
);
|
||||
assert!(
|
||||
!request_cacheable(&Method::GET, &h, true, "/", false, &settings()),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
for name in ["if-none-match", "if-modified-since"] {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
"value".parse().unwrap(),
|
||||
);
|
||||
assert!(
|
||||
request_cacheable(&Method::GET, &h, true, "/", false, &settings()),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
for (value, reason) in [
|
||||
("no-cache", "request_no_cache"),
|
||||
("max-age=0", "request_max_age"),
|
||||
] {
|
||||
let h = headers(value);
|
||||
assert_eq!(
|
||||
request_policy(&Method::GET, &h, true, "/", false, &settings()),
|
||||
RequestPolicy::Revalidate(reason)
|
||||
);
|
||||
}
|
||||
let min_fresh = headers("min-fresh=1");
|
||||
assert_eq!(
|
||||
request_policy(&Method::GET, &min_fresh, true, "/", false, &settings()),
|
||||
RequestPolicy::Use
|
||||
);
|
||||
let immutable = headers("immutable");
|
||||
assert_eq!(
|
||||
request_policy(&Method::GET, &immutable, true, "/", false, &settings()),
|
||||
RequestPolicy::Use
|
||||
);
|
||||
let h = headers("no-store");
|
||||
assert_eq!(
|
||||
request_policy(&Method::GET, &h, true, "/", false, &settings()),
|
||||
RequestPolicy::Bypass("request_no_store")
|
||||
);
|
||||
let mut pragma = HeaderMap::new();
|
||||
pragma.insert("pragma", HeaderValue::from_static("no-cache"));
|
||||
assert_eq!(
|
||||
request_policy(&Method::GET, &pragma, true, "/", false, &settings()),
|
||||
RequestPolicy::Revalidate("request_pragma_no_cache")
|
||||
);
|
||||
assert!(!request_cacheable(
|
||||
&Method::POST,
|
||||
&HeaderMap::new(),
|
||||
true,
|
||||
"/",
|
||||
false,
|
||||
&settings()
|
||||
));
|
||||
assert!(!request_cacheable(
|
||||
&Method::GET,
|
||||
&HeaderMap::new(),
|
||||
false,
|
||||
"/",
|
||||
false,
|
||||
&settings()
|
||||
));
|
||||
assert!(request_cacheable(
|
||||
&Method::GET,
|
||||
&HeaderMap::new(),
|
||||
true,
|
||||
"/",
|
||||
false,
|
||||
&settings()
|
||||
));
|
||||
assert!(request_cacheable(
|
||||
&Method::HEAD,
|
||||
&HeaderMap::new(),
|
||||
true,
|
||||
"/",
|
||||
false,
|
||||
&settings()
|
||||
));
|
||||
let mut single_range = HeaderMap::new();
|
||||
single_range.insert("range", HeaderValue::from_static("bytes=0-3"));
|
||||
assert_eq!(
|
||||
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()),
|
||||
RequestPolicy::Use
|
||||
);
|
||||
assert!(request_cacheable(
|
||||
&Method::GET,
|
||||
&single_range,
|
||||
true,
|
||||
"/asset.bin",
|
||||
false,
|
||||
&settings()
|
||||
));
|
||||
single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9"));
|
||||
assert_eq!(
|
||||
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()),
|
||||
RequestPolicy::Bypass("request_range_unsupported")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn rfc5861_windows_and_revalidation_flags_are_separate_metadata() {
|
||||
let s = settings();
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
&headers("public, max-age=60, stale-while-revalidate=30, stale-if-error=90, must-revalidate, proxy-revalidate"),
|
||||
&s,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.expect("revalidation flags must not prevent storage");
|
||||
assert_eq!(plan.ttl, 60);
|
||||
assert_eq!(plan.stale_while_revalidate, 30);
|
||||
assert_eq!(plan.stale_if_error, 90);
|
||||
assert!(plan.must_revalidate);
|
||||
assert!(plan.proxy_revalidate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_max_stale_is_bounded_and_respects_revalidation_flags() {
|
||||
let unbounded = headers("max-stale");
|
||||
let bounded = headers("max-stale=15");
|
||||
assert_eq!(stale_mode(120, 60, 0, false, false, false, &unbounded), Some(false));
|
||||
assert_eq!(stale_mode(70, 60, 0, false, false, false, &bounded), Some(false));
|
||||
assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None);
|
||||
assert_eq!(stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), Some(true));
|
||||
assert_eq!(stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), None);
|
||||
assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None);
|
||||
assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_state_distinguishes_fresh_stale_and_expired() {
|
||||
let none = HeaderMap::new();
|
||||
assert_eq!(
|
||||
cache_state(30, 60, 20, false, false, false, &none),
|
||||
CacheState::Fresh
|
||||
);
|
||||
assert_eq!(
|
||||
cache_state(70, 60, 20, false, false, false, &none),
|
||||
CacheState::StaleButServable {
|
||||
background_revalidate: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
cache_state(90, 60, 20, false, false, false, &none),
|
||||
CacheState::Expired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_if_error_is_only_available_inside_its_window() {
|
||||
let base = Revalidation {
|
||||
headers: HeaderMap::new(),
|
||||
body: Bytes::from_static(b"body"),
|
||||
body_allocated: 4,
|
||||
_transient_leases: Vec::new(),
|
||||
hits: 0,
|
||||
transformed: false,
|
||||
storage_key: [0; 32],
|
||||
resident_age_at_lookup: Duration::ZERO,
|
||||
skip_compression: false,
|
||||
reusable_without_validation: false,
|
||||
age: 75,
|
||||
ttl: 60,
|
||||
stale_if_error: 30,
|
||||
must_revalidate: false,
|
||||
proxy_revalidate: false,
|
||||
origin_etag: None,
|
||||
origin_last_modified: None,
|
||||
};
|
||||
assert!(base.can_stale_if_error());
|
||||
assert!(!Revalidation { age: 91, ..base.clone() }.can_stale_if_error());
|
||||
assert!(!Revalidation { must_revalidate: true, ..base.clone() }.can_stale_if_error());
|
||||
assert!(!Revalidation { proxy_revalidate: true, ..base }.can_stale_if_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freshness_expiry_does_not_force_physical_eviction() {
|
||||
assert_eq!(retention_deadline(60, 0, 0, false), u64::MAX);
|
||||
assert_eq!(retention_deadline(60, 30, 0, false), u64::MAX);
|
||||
assert_eq!(retention_deadline(60, 0, 45, false), u64::MAX);
|
||||
assert_eq!(retention_deadline(60, 0, 0, true), u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_max_age_only_revalidates_when_cached_age_exceeds_limit() {
|
||||
let fresh = headers("max-age=30");
|
||||
assert!(request_freshness_allows(10, 60, &fresh));
|
||||
assert!(!request_freshness_allows(31, 60, &fresh));
|
||||
assert_eq!(
|
||||
request_policy(&Method::GET, &fresh, true, "/", false, &settings()),
|
||||
RequestPolicy::Use
|
||||
);
|
||||
let zero = headers("max-age=0");
|
||||
assert_eq!(
|
||||
request_policy(&Method::GET, &zero, true, "/", false, &settings()),
|
||||
RequestPolicy::Revalidate("request_max_age")
|
||||
);
|
||||
let constrained_stale = headers("max-age=30, max-stale");
|
||||
assert_eq!(
|
||||
stale_mode(70, 60, 0, false, false, false, &constrained_stale),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_and_argument_immutable_do_not_invalidate_cache_control() {
|
||||
let duplicate = headers("public, max-age=60, immutable, immutable");
|
||||
assert!(plan(StatusCode::OK, &duplicate, &settings(), Duration::ZERO).is_some());
|
||||
let argument = headers("public, max-age=60, immutable=ignored");
|
||||
assert!(plan(StatusCode::OK, &argument, &settings(), Duration::ZERO).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_transform_does_not_disable_shared_storage() {
|
||||
let protected = headers("public, max-age=60, no-transform");
|
||||
assert!(plan(StatusCode::OK, &protected, &settings(), Duration::ZERO).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transformed_plan_uses_transformed_content_length() {
|
||||
let s = settings();
|
||||
let origin = plan(
|
||||
StatusCode::OK,
|
||||
&{
|
||||
let mut h = headers("public, max-age=60");
|
||||
h.insert("content-length", HeaderValue::from_static("95"));
|
||||
h
|
||||
},
|
||||
&s,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
let transformed = plan(
|
||||
StatusCode::OK,
|
||||
&{
|
||||
let mut h = headers("public, max-age=60");
|
||||
h.insert("content-length", HeaderValue::from_static("76"));
|
||||
h
|
||||
},
|
||||
&s,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(origin.restrict(transformed).expected_length, Some(76));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transformed_representation_is_stored_and_reused() {
|
||||
let cache = Cache::new(1);
|
||||
let cache_settings = settings();
|
||||
let request_headers = HeaderMap::new();
|
||||
let ticket = ticket(&cache, &request_headers);
|
||||
let origin_body = br#"{ "message": "hello" }"#;
|
||||
let mut response = Response::new(Body::from(origin_body.as_slice()));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", HeaderValue::from_static("application/json"));
|
||||
response.headers_mut().insert(
|
||||
"content-length",
|
||||
HeaderValue::from_str(&origin_body.len().to_string()).unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("etag", HeaderValue::from_static("\"origin\""));
|
||||
let origin_plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&cache_settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
let transform_settings = crate::transform::Settings {
|
||||
enabled: true,
|
||||
json: true,
|
||||
..Default::default()
|
||||
};
|
||||
let transformed = crate::transform::apply(
|
||||
response,
|
||||
"/data.json",
|
||||
&transform_settings,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let transformed_plan = plan(
|
||||
StatusCode::OK,
|
||||
transformed.headers(),
|
||||
&cache_settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
let expected_etag = transformed.headers()["etag"].clone();
|
||||
let wrapped = cache.wrap(
|
||||
transformed,
|
||||
ticket.clone(),
|
||||
origin_plan.restrict(transformed_plan),
|
||||
&cache_settings,
|
||||
);
|
||||
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(&first[..], br#"{"message":"hello"}"#);
|
||||
|
||||
let hit = cache.get(&ticket).expect("transformed representation should be cached");
|
||||
assert_eq!(hit.response.headers()["etag"], expected_etag);
|
||||
assert_eq!(
|
||||
hit.response.headers()["content-length"].to_str().unwrap(),
|
||||
first.len().to_string()
|
||||
);
|
||||
let second = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(second, first);
|
||||
}
|
||||
Vendored
-411
@@ -1,411 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn every_enabled_transform_kind_can_fill_and_reuse_the_cached_representation() {
|
||||
let cases: [(&str, &str, &[u8], &str); 5] = [
|
||||
(
|
||||
"/app.js",
|
||||
"application/javascript",
|
||||
&b"const value = 1;\n"[..],
|
||||
"javascript",
|
||||
),
|
||||
(
|
||||
"/app.css",
|
||||
"text/css",
|
||||
&b"body { color: red; }\n"[..],
|
||||
"css",
|
||||
),
|
||||
(
|
||||
"/page.html",
|
||||
"text/html",
|
||||
&b"<div><!--drop--><b>x</b></div>"[..],
|
||||
"html",
|
||||
),
|
||||
(
|
||||
"/icon.svg",
|
||||
"image/svg+xml",
|
||||
&b"<svg><!--drop--><path d=\"M0 0\"/></svg>"[..],
|
||||
"svg",
|
||||
),
|
||||
(
|
||||
"/data.json",
|
||||
"application/json",
|
||||
&br#"{ "a": 1 }"#[..],
|
||||
"json",
|
||||
),
|
||||
];
|
||||
|
||||
for (path, content_type, origin_body, kind) in cases {
|
||||
let cache = Cache::new(1);
|
||||
let cache_settings = settings();
|
||||
let request_headers = HeaderMap::new();
|
||||
let ticket = cache
|
||||
.ticket(1, "http", "example.test", "app", path, &request_headers)
|
||||
.unwrap();
|
||||
let mut response = Response::new(Body::from(origin_body));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"content-type",
|
||||
HeaderValue::from_str(content_type).unwrap(),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"content-length",
|
||||
HeaderValue::from_str(&origin_body.len().to_string()).unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("etag", HeaderValue::from_static("\"origin\""));
|
||||
let origin_plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&cache_settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut transform_settings = crate::transform::Settings {
|
||||
enabled: true,
|
||||
javascript: false,
|
||||
css: false,
|
||||
html: false,
|
||||
svg: false,
|
||||
json: false,
|
||||
..Default::default()
|
||||
};
|
||||
match kind {
|
||||
"javascript" => transform_settings.javascript = true,
|
||||
"css" => transform_settings.css = true,
|
||||
"html" => transform_settings.html = true,
|
||||
"svg" => transform_settings.svg = true,
|
||||
"json" => transform_settings.json = true,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let transformed = crate::transform::apply(
|
||||
response,
|
||||
path,
|
||||
&transform_settings,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let transformed_plan = plan(
|
||||
StatusCode::OK,
|
||||
transformed.headers(),
|
||||
&cache_settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
let expected_etag = transformed.headers()["etag"].clone();
|
||||
let expected_length = transformed.headers()["content-length"].clone();
|
||||
let wrapped = cache.wrap(
|
||||
transformed,
|
||||
ticket.clone(),
|
||||
origin_plan.restrict(transformed_plan),
|
||||
&cache_settings,
|
||||
);
|
||||
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(!first.is_empty(), "{kind}");
|
||||
assert_ne!(first.as_ref(), origin_body, "{kind} should change the chosen fixture");
|
||||
|
||||
let hit = cache.get(&ticket).expect("transformed representation should be cached");
|
||||
assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}");
|
||||
assert_eq!(
|
||||
hit.response.headers()["content-length"],
|
||||
expected_length,
|
||||
"{kind}"
|
||||
);
|
||||
let second = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(second, first, "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_hit_uses_weak_etag_and_last_modified() {
|
||||
let mut response = HeaderMap::new();
|
||||
response.insert("etag", HeaderValue::from_static("W/\"abc\""));
|
||||
response.insert(
|
||||
"last-modified",
|
||||
HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
);
|
||||
let mut request = HeaderMap::new();
|
||||
request.insert("if-none-match", HeaderValue::from_static("\"abc\""));
|
||||
assert!(conditional_not_modified(&request, &response));
|
||||
request.insert("if-none-match", HeaderValue::from_static("\"other\""));
|
||||
assert!(!conditional_not_modified(&request, &response));
|
||||
request.remove("if-none-match");
|
||||
request.insert(
|
||||
"if-modified-since",
|
||||
HeaderValue::from_static("Tue, 27 Jan 2026 00:00:00 GMT"),
|
||||
);
|
||||
assert!(conditional_not_modified(&request, &response));
|
||||
}
|
||||
#[test]
|
||||
fn query_strings_remain_distinct_and_bypass_query_is_only_a_request_policy() {
|
||||
let cache = Cache::new(1);
|
||||
let headers = HeaderMap::new();
|
||||
let v1 = cache
|
||||
.ticket(1, "http", "example.test", "app", "/file.css?v=1", &headers)
|
||||
.unwrap();
|
||||
let v2 = cache
|
||||
.ticket(1, "http", "example.test", "app", "/file.css?v=2", &headers)
|
||||
.unwrap();
|
||||
let flag = cache
|
||||
.ticket(1, "http", "example.test", "app", "/file.css?F", &headers)
|
||||
.unwrap();
|
||||
assert_ne!(v1.key, v2.key);
|
||||
assert_ne!(v1.key, flag.key);
|
||||
assert_ne!(v2.key, flag.key);
|
||||
|
||||
let mut cache_settings = settings();
|
||||
assert_eq!(
|
||||
request_policy(
|
||||
&Method::GET,
|
||||
&headers,
|
||||
true,
|
||||
"/file.css",
|
||||
true,
|
||||
&cache_settings,
|
||||
),
|
||||
RequestPolicy::Use
|
||||
);
|
||||
cache_settings.bypass_query = true;
|
||||
assert_eq!(
|
||||
request_policy(
|
||||
&Method::GET,
|
||||
&headers,
|
||||
true,
|
||||
"/file.css",
|
||||
true,
|
||||
&cache_settings,
|
||||
),
|
||||
RequestPolicy::Bypass("query_excluded")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transformed_client_conditionals_use_the_representation_etag_not_the_origin_etag() {
|
||||
let transform_settings = crate::transform::Settings {
|
||||
enabled: true,
|
||||
json: true,
|
||||
..Default::default()
|
||||
};
|
||||
let body = br#"{ "a": 1 }"#;
|
||||
let mut response = Response::new(Body::from(body.as_slice()));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", HeaderValue::from_static("application/json"));
|
||||
response.headers_mut().insert(
|
||||
"content-length",
|
||||
HeaderValue::from_str(&body.len().to_string()).unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("etag", HeaderValue::from_static("\"origin\""));
|
||||
let response = crate::transform::apply(response, "/data.json", &transform_settings, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let representation_etag = response.headers()["etag"].clone();
|
||||
assert_ne!(representation_etag, HeaderValue::from_static("\"origin\""));
|
||||
|
||||
let mut request = HeaderMap::new();
|
||||
request.insert("if-none-match", representation_etag.clone());
|
||||
assert!(conditional_not_modified(&request, response.headers()));
|
||||
request.insert("if-none-match", HeaderValue::from_static("\"origin\""));
|
||||
assert!(!conditional_not_modified(&request, response.headers()));
|
||||
|
||||
let validators = response
|
||||
.extensions()
|
||||
.get::<crate::transform::OriginValidators>()
|
||||
.unwrap();
|
||||
assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_separates_frontend_scheme() {
|
||||
let cache = Cache::new(1);
|
||||
let headers = HeaderMap::new();
|
||||
let http = cache
|
||||
.ticket(1, "http", "example.test", "app", "/asset", &headers)
|
||||
.unwrap();
|
||||
let https = cache
|
||||
.ticket(1, "https", "example.test", "app", "/asset", &headers)
|
||||
.unwrap();
|
||||
assert_ne!(http.key, https.key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_ignores_revalidation_headers() {
|
||||
let cache = Cache::new(1);
|
||||
let base = ticket(&cache, &HeaderMap::new());
|
||||
for (name, value) in [
|
||||
("cache-control", "no-cache"),
|
||||
("pragma", "no-cache"),
|
||||
("if-none-match", "\"origin\""),
|
||||
("if-modified-since", "Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(value).unwrap(),
|
||||
);
|
||||
assert_eq!(base.key, ticket(&cache, &headers).key, "{name}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cache_memory_defaults_are_bounded_and_sane() {
|
||||
let s = Settings::default();
|
||||
assert_eq!(s.memory_preset, None);
|
||||
assert_eq!(s.cold_preset, None);
|
||||
assert_eq!(s.max_bytes, 256 * 1024 * 1024);
|
||||
assert_eq!(s.max_object_bytes, 16 * 1024 * 1024);
|
||||
assert_eq!(s.transient_max_bytes, 64 * 1024 * 1024);
|
||||
assert_eq!(s.cold_queue_max_bytes, 32 * 1024 * 1024);
|
||||
assert_eq!(s.hot_eviction_max_objects, 64);
|
||||
assert!(s.validate().is_ok());
|
||||
assert!(Cache::fill_headroom_bytes(8 * 1024 * 1024) <= 128 * 1024);
|
||||
assert!(Cache::fill_headroom_bytes(s.max_bytes) <= 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_preset_intent_round_trips_with_configuration() {
|
||||
let mut settings = Settings::default();
|
||||
settings.memory_preset = Some(MemoryPreset::Recommended);
|
||||
settings.cold_preset = Some(ColdPreset::Recommended);
|
||||
let encoded = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(encoded["memory_preset"], "recommended");
|
||||
assert_eq!(encoded["cold_preset"], "recommended");
|
||||
let decoded: Settings = serde_json::from_value(encoded).unwrap();
|
||||
assert_eq!(decoded.memory_preset, Some(MemoryPreset::Recommended));
|
||||
assert_eq!(decoded.cold_preset, Some(ColdPreset::Recommended));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_budget_is_hard_and_released_with_lease() {
|
||||
let cache = Cache::new(1);
|
||||
let mut s = Settings::default();
|
||||
s.transient_max_bytes = 128 * 1024;
|
||||
s.cold_queue_max_bytes = 64 * 1024;
|
||||
let first = cache.try_reserve_transient(96 * 1024, &s).expect("first reservation");
|
||||
assert!(cache.try_reserve_transient(40 * 1024, &s).is_none());
|
||||
assert_eq!(cache.view().transient_bytes, 96 * 1024);
|
||||
drop(first);
|
||||
assert_eq!(cache.view().transient_bytes, 0);
|
||||
assert!(cache.try_reserve_transient(128 * 1024, &s).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cold_queue_budget_cannot_exceed_transient_budget() {
|
||||
let mut s = Settings::default();
|
||||
s.transient_max_bytes = 8 * 1024 * 1024;
|
||||
s.cold_queue_max_bytes = 16 * 1024 * 1024;
|
||||
assert!(s.validate().is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
#[test]
|
||||
fn cache_limits_allow_explicit_multi_gibibyte_budgets() {
|
||||
let mut s = Settings::default();
|
||||
s.max_bytes = 4usize * 1024 * 1024 * 1024;
|
||||
s.max_object_bytes = 2usize * 1024 * 1024 * 1024;
|
||||
s.transient_max_bytes = 512usize * 1024 * 1024;
|
||||
s.cold_queue_max_bytes = 256usize * 1024 * 1024;
|
||||
assert!(s.validate().is_ok());
|
||||
s.max_object_bytes = s.max_bytes.saturating_add(1);
|
||||
assert!(s.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn revalidation_only_reuses_matching_client_validator() {
|
||||
let cached = Revalidation {
|
||||
headers: {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("etag", HeaderValue::from_static("W/\"origin\""));
|
||||
h.insert(
|
||||
"last-modified",
|
||||
HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
);
|
||||
h
|
||||
},
|
||||
body: Bytes::from_static(b"body"),
|
||||
body_allocated: 4,
|
||||
_transient_leases: Vec::new(),
|
||||
hits: 0,
|
||||
transformed: false,
|
||||
storage_key: [0; 32],
|
||||
resident_age_at_lookup: Duration::ZERO,
|
||||
skip_compression: false,
|
||||
reusable_without_validation: false,
|
||||
age: 61,
|
||||
ttl: 60,
|
||||
stale_if_error: 30,
|
||||
must_revalidate: false,
|
||||
proxy_revalidate: false,
|
||||
origin_etag: Some(HeaderValue::from_static("W/\"origin\"")),
|
||||
origin_last_modified: Some(HeaderValue::from_static(
|
||||
"Mon, 26 Jan 2026 23:11:40 GMT",
|
||||
)),
|
||||
};
|
||||
let mut matching = HeaderMap::new();
|
||||
matching.insert("if-none-match", HeaderValue::from_static("\"origin\""));
|
||||
assert!(cached.apply_validators(&mut matching));
|
||||
let mut other = HeaderMap::new();
|
||||
other.insert("if-none-match", HeaderValue::from_static("\"other\""));
|
||||
assert!(!cached.apply_validators(&mut other));
|
||||
assert_eq!(other["if-none-match"], "\"other\"");
|
||||
let mut empty = HeaderMap::new();
|
||||
assert!(cached.apply_validators(&mut empty));
|
||||
assert_eq!(empty["if-none-match"], "W/\"origin\"");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn transformed_etag_is_never_used_as_an_origin_validator() {
|
||||
let cache = Cache::new(1);
|
||||
let ticket = ticket(&cache, &HeaderMap::new());
|
||||
let settings = settings();
|
||||
let modified = HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT");
|
||||
let public_etag = HeaderValue::from_static("\"rustproxy-deadbeef\"");
|
||||
let mut response_headers = headers("public, max-age=60");
|
||||
response_headers.insert("etag", public_etag.clone());
|
||||
response_headers.insert("last-modified", modified.clone());
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
&response_headers,
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
let mut response = Response::new(Body::from("transformed"));
|
||||
*response.headers_mut() = response_headers;
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::transform::OriginValidators {
|
||||
etag: None,
|
||||
last_modified: Some(modified.clone()),
|
||||
});
|
||||
cache
|
||||
.wrap(response, ticket.clone(), plan, &settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cached = cache.revalidation(&ticket).expect("stored response");
|
||||
assert!(cached.origin_etag.is_none());
|
||||
assert_eq!(cached.origin_last_modified.as_ref(), Some(&modified));
|
||||
let mut outbound = HeaderMap::new();
|
||||
assert!(cached.apply_validators(&mut outbound));
|
||||
assert!(!outbound.contains_key("if-none-match"));
|
||||
assert_eq!(outbound["if-modified-since"], modified);
|
||||
|
||||
let mut client_conditional = HeaderMap::new();
|
||||
client_conditional.insert("if-none-match", public_etag.clone());
|
||||
assert!(cached.apply_validators(&mut client_conditional));
|
||||
assert!(!client_conditional.contains_key("if-none-match"));
|
||||
assert_eq!(client_conditional["if-modified-since"], modified);
|
||||
|
||||
let mut validation = HeaderMap::new();
|
||||
validation.insert("cache-control", HeaderValue::from_static("public, max-age=60"));
|
||||
validation.insert("etag", HeaderValue::from_static("\"origin-new\""));
|
||||
validation.insert("last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"));
|
||||
let merged = cached.merge_validation_headers(&validation);
|
||||
assert_eq!(merged["etag"], public_etag);
|
||||
}
|
||||
Vendored
-441
@@ -1,441 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn vary_selects_variants_without_fragmenting_the_primary_hash() {
|
||||
let cache = Cache::new(1);
|
||||
let s = settings();
|
||||
let mut en = HeaderMap::new();
|
||||
en.insert("accept-language", "en".parse().unwrap());
|
||||
let mut pl = en.clone();
|
||||
pl.insert("accept-language", "pl".parse().unwrap());
|
||||
let en_ticket = ticket(&cache, &en);
|
||||
let pl_ticket = ticket(&cache, &pl);
|
||||
assert_eq!(en_ticket.key, pl_ticket.key);
|
||||
|
||||
let mut h = headers("max-age=60");
|
||||
h.insert("vary", HeaderValue::from_static("Accept-Language"));
|
||||
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
|
||||
let mut response = Response::new(Body::from("hello"));
|
||||
*response.headers_mut() = h.clone();
|
||||
cache
|
||||
.wrap(response, en_ticket.clone(), p, &s)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cache.get(&en_ticket).is_some());
|
||||
assert!(cache.get(&pl_ticket).is_none());
|
||||
|
||||
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
|
||||
let mut response = Response::new(Body::from("czesc"));
|
||||
*response.headers_mut() = h;
|
||||
cache
|
||||
.wrap(response, pl_ticket.clone(), p, &s)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
let en_hit = cache.get(&en_ticket).expect("English Vary variant");
|
||||
let pl_hit = cache.get(&pl_ticket).expect("Polish Vary variant");
|
||||
assert_eq!(
|
||||
en_hit
|
||||
.response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes(),
|
||||
Bytes::from_static(b"hello")
|
||||
);
|
||||
assert_eq!(
|
||||
pl_hit
|
||||
.response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes(),
|
||||
Bytes::from_static(b"czesc")
|
||||
);
|
||||
|
||||
let mut star = headers("max-age=60");
|
||||
star.insert("vary", HeaderValue::from_static("*"));
|
||||
assert!(plan(StatusCode::OK, &star, &s, Duration::ZERO).is_none());
|
||||
for vary in [
|
||||
"x-request-id",
|
||||
"cache-control",
|
||||
"pragma",
|
||||
"if-none-match",
|
||||
"if-modified-since",
|
||||
] {
|
||||
let mut h = headers("max-age=60");
|
||||
h.insert("vary", vary.parse().unwrap());
|
||||
assert!(
|
||||
plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some(),
|
||||
"valid Vary field must remain cacheable: {vary}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn canonical_identity_drops_accept_encoding_as_a_cache_variant() {
|
||||
let cache = Cache::new(1);
|
||||
let s = settings();
|
||||
let mut gzip = HeaderMap::new();
|
||||
gzip.insert("accept-encoding", HeaderValue::from_static("gzip"));
|
||||
let mut brotli = HeaderMap::new();
|
||||
brotli.insert("accept-encoding", HeaderValue::from_static("br"));
|
||||
let gzip_ticket = ticket(&cache, &gzip);
|
||||
let brotli_ticket = ticket(&cache, &brotli);
|
||||
|
||||
let mut origin_headers = headers("public, max-age=60");
|
||||
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding"));
|
||||
origin_headers.insert("content-length", HeaderValue::from_static("7"));
|
||||
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
|
||||
.expect("origin plan");
|
||||
|
||||
let mut transformed_headers = headers("public, max-age=60");
|
||||
transformed_headers.insert("content-length", HeaderValue::from_static("7"));
|
||||
let transformed_plan = plan(
|
||||
StatusCode::OK,
|
||||
&transformed_headers,
|
||||
&s,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.expect("transformed plan");
|
||||
let cache_plan = origin_plan
|
||||
.without_vary("accept-encoding")
|
||||
.restrict(transformed_plan);
|
||||
assert!(cache_plan.vary.is_empty());
|
||||
|
||||
let mut response = Response::new(Body::from("payload"));
|
||||
*response.headers_mut() = transformed_headers;
|
||||
cache
|
||||
.wrap(response, gzip_ticket.clone(), cache_plan, &s)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let hit = cache
|
||||
.get(&brotli_ticket)
|
||||
.expect("canonical identity must be shared across client encodings");
|
||||
assert_eq!(
|
||||
hit.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"payload")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_identity_preserves_non_encoding_vary_dimensions() {
|
||||
let cache = Cache::new(1);
|
||||
let s = settings();
|
||||
let mut light = HeaderMap::new();
|
||||
light.insert("accept-encoding", HeaderValue::from_static("gzip"));
|
||||
light.insert("x-theme", HeaderValue::from_static("light"));
|
||||
let mut dark = HeaderMap::new();
|
||||
dark.insert("accept-encoding", HeaderValue::from_static("br"));
|
||||
dark.insert("x-theme", HeaderValue::from_static("dark"));
|
||||
|
||||
let mut origin_headers = headers("public, max-age=60");
|
||||
origin_headers.insert(
|
||||
"vary",
|
||||
HeaderValue::from_static("Accept-Encoding, X-Theme"),
|
||||
);
|
||||
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
|
||||
.expect("origin plan");
|
||||
let mut transformed_headers = headers("public, max-age=60");
|
||||
transformed_headers.insert("vary", HeaderValue::from_static("X-Theme"));
|
||||
let transformed_plan = plan(
|
||||
StatusCode::OK,
|
||||
&transformed_headers,
|
||||
&s,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.expect("transformed plan");
|
||||
let cache_plan = origin_plan
|
||||
.without_vary("accept-encoding")
|
||||
.restrict(transformed_plan);
|
||||
assert_eq!(cache_plan.vary, vec!["x-theme".to_owned()]);
|
||||
|
||||
let mut response = Response::new(Body::from("light"));
|
||||
*response.headers_mut() = transformed_headers;
|
||||
cache
|
||||
.wrap(response, ticket(&cache, &light), cache_plan, &s)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cache.get(&ticket(&cache, &light)).is_some());
|
||||
assert!(cache.get(&ticket(&cache, &dark)).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vary_schema_change_removes_incompatible_fallbacks() {
|
||||
let cache = Cache::new(1);
|
||||
let s = settings();
|
||||
let mut en = HeaderMap::new();
|
||||
en.insert("accept-language", HeaderValue::from_static("en"));
|
||||
let mut pl = HeaderMap::new();
|
||||
pl.insert("accept-language", HeaderValue::from_static("pl"));
|
||||
let en_ticket = ticket(&cache, &en);
|
||||
let pl_ticket = ticket(&cache, &pl);
|
||||
|
||||
fill(&cache, en_ticket.clone(), "shared-old", &s).await;
|
||||
assert!(cache.get(&pl_ticket).is_some());
|
||||
|
||||
let mut varied_headers = headers("max-age=60");
|
||||
varied_headers.insert("vary", HeaderValue::from_static("Accept-Language"));
|
||||
let varied_plan = plan(StatusCode::OK, &varied_headers, &s, Duration::ZERO).unwrap();
|
||||
let mut varied = Response::new(Body::from("english-new"));
|
||||
*varied.headers_mut() = varied_headers;
|
||||
cache
|
||||
.wrap(varied, en_ticket.clone(), varied_plan, &s)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cache.get(&en_ticket).is_some());
|
||||
assert!(
|
||||
cache.get(&pl_ticket).is_none(),
|
||||
"an older no-Vary object must not shadow a newer Vary schema"
|
||||
);
|
||||
|
||||
fill(&cache, pl_ticket.clone(), "shared-new", &s).await;
|
||||
let hit = cache
|
||||
.get(&en_ticket)
|
||||
.expect("new no-Vary schema should replace old variants");
|
||||
assert_eq!(
|
||||
hit.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"shared-new")
|
||||
);
|
||||
}
|
||||
|
||||
async fn fill(cache: &Arc<Cache>, ticket: Ticket, data: &'static str, settings: &Settings) {
|
||||
let h = headers("max-age=60");
|
||||
let p = plan(StatusCode::OK, &h, settings, Duration::ZERO).unwrap();
|
||||
let mut r = Response::new(Body::from(data));
|
||||
*r.headers_mut() = h;
|
||||
cache
|
||||
.wrap(r, ticket, p, settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn max_stale_can_reuse_an_object_after_normal_ttl_expiry() {
|
||||
let cache = Cache::new(1);
|
||||
let s = settings();
|
||||
let initial = ticket(&cache, &HeaderMap::new());
|
||||
let response_headers = headers("public, max-age=1");
|
||||
let p = plan(StatusCode::OK, &response_headers, &s, Duration::ZERO).unwrap();
|
||||
let mut response = Response::new(Body::from("stale-payload"));
|
||||
*response.headers_mut() = response_headers;
|
||||
cache
|
||||
.wrap(response, initial.clone(), p, &s)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let mut store = cache.store.lock().unwrap();
|
||||
let entry = store.entries.values_mut().next().expect("stored object");
|
||||
entry.received = Instant::now() - Duration::from_secs(2);
|
||||
}
|
||||
assert!(cache.get(&initial).is_none(), "normal request must not receive stale data");
|
||||
assert_eq!(cache.view().entries, 1, "freshness expiry must not physically evict the object");
|
||||
|
||||
let stale = ticket(&cache, &headers("max-stale"));
|
||||
let hit = cache.get(&stale).expect("explicit max-stale may reuse retained stale data");
|
||||
assert!(hit.stale);
|
||||
assert_eq!(
|
||||
hit.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"stale-payload")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_no_cache_is_stored_only_for_validation() {
|
||||
let cache = Cache::new(1);
|
||||
let t = ticket(&cache, &HeaderMap::new());
|
||||
let s = settings();
|
||||
let mut h = headers("no-cache");
|
||||
h.insert("etag", HeaderValue::from_static("\"origin\""));
|
||||
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
|
||||
assert!(p.requires_revalidation);
|
||||
let mut response = Response::new(Body::from("payload"));
|
||||
*response.headers_mut() = h;
|
||||
cache
|
||||
.wrap(response, t.clone(), p, &s)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
cache.get(&t).is_none(),
|
||||
"no-cache must not be reused without validation"
|
||||
);
|
||||
let candidate = cache.revalidation(&t).expect("stored validator/body");
|
||||
assert!(!candidate.reusable_without_validation());
|
||||
let mut validation = HeaderMap::new();
|
||||
validation.insert("cache-control", HeaderValue::from_static("no-cache"));
|
||||
validation.insert("etag", HeaderValue::from_static("\"origin\""));
|
||||
let merged = candidate.merge_validation_headers(&validation);
|
||||
let hit = cache
|
||||
.publish_revalidated(t.clone(), &candidate, merged, &validation, &s, Duration::ZERO)
|
||||
.expect("validated body can be delivered");
|
||||
assert_eq!(
|
||||
hit.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"payload")
|
||||
);
|
||||
assert!(
|
||||
cache.get(&t).is_none(),
|
||||
"stored no-cache response still requires validation next time"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn resident_age_survives_successful_revalidation() {
|
||||
let cache = Cache::new(1);
|
||||
let t = ticket(&cache, &HeaderMap::new());
|
||||
let s = settings();
|
||||
let mut h = headers("no-cache");
|
||||
h.insert("etag", HeaderValue::from_static("\"origin\""));
|
||||
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
|
||||
let mut response = Response::new(Body::from("payload"));
|
||||
*response.headers_mut() = h;
|
||||
cache
|
||||
.wrap(response, t.clone(), p, &s)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let mut store = cache.store.lock().unwrap();
|
||||
let entry = store.entries.values_mut().next().expect("stored object");
|
||||
entry.resident_age_base = Duration::from_secs(30);
|
||||
entry.resident_since = Instant::now();
|
||||
}
|
||||
let candidate = cache.revalidation(&t).expect("revalidation candidate");
|
||||
assert!(candidate.resident_age_at_lookup >= Duration::from_secs(30));
|
||||
let mut validation = HeaderMap::new();
|
||||
validation.insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
validation.insert("etag", HeaderValue::from_static("\"origin\""));
|
||||
let merged = candidate.merge_validation_headers(&validation);
|
||||
let hit = cache
|
||||
.publish_revalidated(t, &candidate, merged, &validation, &s, Duration::from_secs(2))
|
||||
.expect("validated object");
|
||||
assert!(hit.resident_age_seconds >= 32);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_body_hit_age_and_purge() {
|
||||
let cache = Cache::new(1);
|
||||
let t = ticket(&cache, &HeaderMap::new());
|
||||
assert!(cache.get(&t).is_none());
|
||||
fill(&cache, t.clone(), "payload", &settings()).await;
|
||||
let view = cache.view();
|
||||
assert_eq!(view.hot_logical_bytes, b"payload".len());
|
||||
assert_eq!(view.hot_allocated_bytes, view.resident_bytes);
|
||||
assert!(view.hot_allocated_bytes > view.hot_logical_bytes);
|
||||
assert!(view.hot_allocated_bytes <= view.hot_limit_bytes);
|
||||
let hit = cache.get(&t).unwrap();
|
||||
assert_eq!(hit.hits, 1);
|
||||
assert!(hit.response.headers().contains_key("age"));
|
||||
assert_eq!(
|
||||
hit.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"payload")
|
||||
);
|
||||
assert_eq!(cache.purge(None), 1);
|
||||
assert!(cache.get(&t).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_hit_and_dashboard_age_advance_from_storage_clock() {
|
||||
let cache = Cache::new(1);
|
||||
let t = ticket(&cache, &HeaderMap::new());
|
||||
fill(&cache, t.clone(), "payload", &settings()).await;
|
||||
{
|
||||
let mut store = cache.store.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let entry = store.entries.values_mut().next().expect("stored entry");
|
||||
entry.received = Instant::now() - Duration::from_secs(5);
|
||||
}
|
||||
|
||||
let hit = cache.get(&t).expect("fresh cached response");
|
||||
assert!(
|
||||
hit.response.headers()["age"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.parse::<u64>()
|
||||
.unwrap()
|
||||
>= 5
|
||||
);
|
||||
assert!(cache.dashboard(None, 0, 50).objects[0].age_seconds >= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_enable_static_cookie_boost_for_common_assets() {
|
||||
let settings = Settings::default();
|
||||
assert!(settings.enabled);
|
||||
assert!(settings.static_ignore_cookies);
|
||||
for path in [
|
||||
"/asset.jpg",
|
||||
"/asset.jpeg",
|
||||
"/asset.png",
|
||||
"/app.css",
|
||||
"/app.js",
|
||||
"/font.woff2",
|
||||
"/audio.mp3",
|
||||
"/note.txt",
|
||||
] {
|
||||
assert!(settings.ignore_cookie_for_path(path), "{path}");
|
||||
}
|
||||
assert!(!settings.ignore_cookie_for_path("/account"));
|
||||
assert!(!settings.ignore_cookie_for_path("/api/profile.json"));
|
||||
}
|
||||
#[test]
|
||||
fn static_cookie_bypass_is_limited_to_configured_extensions() {
|
||||
let mut settings = settings();
|
||||
settings.static_ignore_cookies = true;
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("cookie", HeaderValue::from_static("session=secret"));
|
||||
assert_eq!(
|
||||
request_reason(&Method::GET, &h, true, "/asset.jpg", false, &settings),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_reason(&Method::GET, &h, true, "/account", false, &settings),
|
||||
Some("request_cookie")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn age_uses_corrected_received_age_plus_response_delay_without_rounding_up() {
|
||||
let settings = settings();
|
||||
let mut h = headers("max-age=120");
|
||||
h.insert(
|
||||
"date",
|
||||
httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10))
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
h.insert("age", HeaderValue::from_static("3"));
|
||||
let age_plan = plan(StatusCode::OK, &h, &settings, Duration::from_millis(250)).unwrap();
|
||||
assert!(age_plan.initial_age >= Duration::from_secs(10));
|
||||
assert!(age_plan.initial_age < Duration::from_secs(12));
|
||||
let fresh = plan(
|
||||
StatusCode::OK,
|
||||
&headers("max-age=60"),
|
||||
&settings,
|
||||
Duration::from_millis(250),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fresh.initial_age.as_secs(), 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn purged_inflight_fill_cannot_repopulate() {
|
||||
let cache = Cache::new(1);
|
||||
let t = ticket(&cache, &HeaderMap::new());
|
||||
cache.purge(None);
|
||||
fill(&cache, t, "old", &settings()).await;
|
||||
assert_eq!(cache.view().entries, 0);
|
||||
}
|
||||
Vendored
-221
@@ -1,221 +0,0 @@
|
||||
#[tokio::test]
|
||||
async fn revision_isolation_and_capacity() {
|
||||
let cache = Cache::new(1);
|
||||
let s = Settings {
|
||||
max_entries: 1,
|
||||
..settings()
|
||||
};
|
||||
let a = ticket(&cache, &HeaderMap::new());
|
||||
fill(&cache, a.clone(), "a", &s).await;
|
||||
let h = HeaderMap::new();
|
||||
let b = cache
|
||||
.ticket(1, "http", "example.test", "app", "/other", &h)
|
||||
.unwrap();
|
||||
fill(&cache, b.clone(), "b", &s).await;
|
||||
assert!(cache.get(&a).is_none());
|
||||
assert!(cache.get(&b).is_some());
|
||||
assert_eq!(cache.view().evictions, 1);
|
||||
cache.purge(Some(2));
|
||||
assert!(cache
|
||||
.ticket(1, "http", "example.test", "app", "/", &h)
|
||||
.is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn hot_byte_budget_evicts_before_another_fill_grows() {
|
||||
let cache = Cache::new(1);
|
||||
let s = Settings {
|
||||
max_bytes: 96 * 1024,
|
||||
max_object_bytes: 64 * 1024,
|
||||
max_entries: 16,
|
||||
transient_max_bytes: 128 * 1024,
|
||||
cold_queue_max_bytes: 64 * 1024,
|
||||
..settings()
|
||||
};
|
||||
let headers = HeaderMap::new();
|
||||
let first = cache
|
||||
.ticket(1, "http", "example.test", "app", "/first", &headers)
|
||||
.unwrap();
|
||||
let second = cache
|
||||
.ticket(1, "http", "example.test", "app", "/second", &headers)
|
||||
.unwrap();
|
||||
fill(&cache, first.clone(), "first", &s).await;
|
||||
assert!(cache.get(&first).is_some());
|
||||
let before = cache.view();
|
||||
assert!(before.hot_allocated_bytes <= s.max_bytes);
|
||||
|
||||
fill(&cache, second.clone(), "second", &s).await;
|
||||
let after = cache.view();
|
||||
assert!(after.hot_allocated_bytes <= s.max_bytes);
|
||||
assert_eq!(after.fill_bytes_active, 0);
|
||||
assert_eq!(after.transient_bytes, 0);
|
||||
assert!(cache.get(&first).is_none(), "LRU entry should make room before the next fill allocation");
|
||||
assert!(cache.get(&second).is_some());
|
||||
assert!(after.evictions >= 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn dropped_partial_bodies_and_oversize_are_never_cached() {
|
||||
let cache = Cache::new(1);
|
||||
let t = ticket(&cache, &HeaderMap::new());
|
||||
let s = settings();
|
||||
let h = headers("max-age=60");
|
||||
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
|
||||
let r = Response::new(Body::from("not-consumed"));
|
||||
drop(cache.wrap(r, t.clone(), p, &s));
|
||||
assert!(cache.get(&t).is_none());
|
||||
assert_eq!(cache.view().fills_active, 0);
|
||||
let small = Settings {
|
||||
max_object_bytes: 2,
|
||||
..s
|
||||
};
|
||||
let h = headers("max-age=60");
|
||||
let p = plan(StatusCode::OK, &h, &small, Duration::ZERO).unwrap();
|
||||
let mut response = Response::new(Body::from("oversize"));
|
||||
*response.headers_mut() = h;
|
||||
let streamed = cache
|
||||
.wrap(response, t.clone(), p, &small)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes();
|
||||
assert_eq!(streamed, Bytes::from_static(b"oversize"));
|
||||
assert!(cache.get(&t).is_none());
|
||||
assert_eq!(cache.view().fill_bytes_active, 0);
|
||||
assert_eq!(cache.view().transient_bytes, 0);
|
||||
}
|
||||
#[test]
|
||||
fn varnish_primary_hash_uses_host_and_url_not_unrelated_request_headers() {
|
||||
let cache = Cache::new(1);
|
||||
let mut a = HeaderMap::new();
|
||||
a.append("user-agent", "browser-a".parse().unwrap());
|
||||
a.append("sec-ch-ua", "brand-a".parse().unwrap());
|
||||
let mut b = HeaderMap::new();
|
||||
b.append("user-agent", "browser-b".parse().unwrap());
|
||||
b.append("x-forwarded-for", "192.0.2.8".parse().unwrap());
|
||||
let base = cache
|
||||
.ticket(
|
||||
1,
|
||||
"http",
|
||||
"Example.Test",
|
||||
"pool/member-a",
|
||||
"/asset.js?x=1",
|
||||
&a,
|
||||
)
|
||||
.unwrap();
|
||||
let other_transport = cache
|
||||
.ticket(
|
||||
1,
|
||||
"https",
|
||||
"example.test",
|
||||
"pool/member-b",
|
||||
"/asset.js?x=1",
|
||||
&b,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(base.key, other_transport.key);
|
||||
let other_url = cache
|
||||
.ticket(
|
||||
1,
|
||||
"https",
|
||||
"example.test",
|
||||
"pool/member-b",
|
||||
"/asset.js?x=2",
|
||||
&b,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(base.key, other_url.key);
|
||||
let explicit_port = cache
|
||||
.ticket(
|
||||
1,
|
||||
"http",
|
||||
"example.test:8080",
|
||||
"pool/member-a",
|
||||
"/asset.js?x=1",
|
||||
&a,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(base.key, explicit_port.key);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn only_if_cached_can_reuse_a_normal_get() {
|
||||
let cache = Cache::new(1);
|
||||
let plain = HeaderMap::new();
|
||||
fill(&cache, ticket(&cache, &plain), "payload", &settings()).await;
|
||||
let only = headers("only-if-cached");
|
||||
assert!(request_cacheable(
|
||||
&Method::GET,
|
||||
&only,
|
||||
true,
|
||||
"/",
|
||||
false,
|
||||
&settings()
|
||||
));
|
||||
assert!(only_if_cached(&only));
|
||||
assert!(cache.get(&ticket(&cache, &only)).is_some());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn dropped_partial_fill_is_not_published_and_a_later_get_can_fill() {
|
||||
use futures_util::stream;
|
||||
use http_body_util::StreamBody;
|
||||
|
||||
let cache = Cache::new(1);
|
||||
let s = settings();
|
||||
let t = ticket(&cache, &HeaderMap::new());
|
||||
let frames = vec![
|
||||
Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"first-"))),
|
||||
Ok(Frame::data(Bytes::from_static(b"second"))),
|
||||
];
|
||||
let mut response = Response::new(Body::new(StreamBody::new(stream::iter(frames))));
|
||||
*response.headers_mut() = headers("max-age=60");
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-length", HeaderValue::from_static("12"));
|
||||
let p = plan(StatusCode::OK, response.headers(), &s, Duration::ZERO).unwrap();
|
||||
let mut body = cache.wrap(response, t.clone(), p, &s).into_body();
|
||||
let first = body
|
||||
.frame()
|
||||
.await
|
||||
.expect("first frame")
|
||||
.expect("successful first frame")
|
||||
.into_data()
|
||||
.expect("data frame");
|
||||
assert_eq!(first, Bytes::from_static(b"first-"));
|
||||
drop(body);
|
||||
|
||||
assert!(cache.get(&t).is_none(), "partial body must never become a HIT");
|
||||
assert_eq!(cache.view().fills_active, 0);
|
||||
assert_eq!(cache.view().rejected_fills, 1);
|
||||
|
||||
fill(&cache, t.clone(), "complete", &s).await;
|
||||
let hit = cache.get(&t).expect("a later complete GET can fill the cache");
|
||||
assert_eq!(
|
||||
hit.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"complete")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_streams_and_trailers_cannot_publish_entries() {
|
||||
use futures_util::stream;
|
||||
use http_body_util::StreamBody;
|
||||
for fail in [true, false] {
|
||||
let cache = Cache::new(1);
|
||||
let s = settings();
|
||||
let t = ticket(&cache, &HeaderMap::new());
|
||||
let frames = vec![
|
||||
Ok(Frame::data(Bytes::from_static(b"partial"))),
|
||||
if fail {
|
||||
Err(std::io::Error::other("broken origin"))
|
||||
} else {
|
||||
Ok(Frame::trailers(HeaderMap::new()))
|
||||
},
|
||||
];
|
||||
let mut r = Response::new(Body::new(StreamBody::new(stream::iter(frames))));
|
||||
*r.headers_mut() = headers("max-age=60");
|
||||
let p = plan(StatusCode::OK, r.headers(), &s, Duration::ZERO).unwrap();
|
||||
let result = cache.wrap(r, t.clone(), p, &s).into_body().collect().await;
|
||||
assert_eq!(result.is_err(), fail);
|
||||
assert!(cache.get(&t).is_none());
|
||||
assert_eq!(cache.view().fills_active, 0);
|
||||
}
|
||||
}
|
||||
-421
@@ -1,421 +0,0 @@
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
fn active_settings() -> Settings {
|
||||
Settings {
|
||||
enabled: true,
|
||||
..Settings::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn store(
|
||||
cache: &Arc<Cache>,
|
||||
scope: &str,
|
||||
host: &str,
|
||||
target: &str,
|
||||
request_headers: HeaderMap,
|
||||
extra_response_headers: &[(&str, &str)],
|
||||
body: &'static str,
|
||||
) -> Ticket {
|
||||
let ticket = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
scope,
|
||||
"http",
|
||||
host,
|
||||
"origin@127.0.0.1:8080",
|
||||
target,
|
||||
&request_headers,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = active_settings();
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=120, stale-if-error=60"),
|
||||
);
|
||||
for (name, value) in extra_response_headers {
|
||||
response.headers_mut().insert(
|
||||
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(value).unwrap(),
|
||||
);
|
||||
}
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap(response, ticket.clone(), plan, &settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
ticket
|
||||
}
|
||||
|
||||
fn invalidation(
|
||||
operation: InvalidationOperation,
|
||||
selector: InvalidationSelector,
|
||||
value: &str,
|
||||
host: Option<&str>,
|
||||
vhost: Option<&str>,
|
||||
) -> InvalidationRequest {
|
||||
InvalidationRequest {
|
||||
operation,
|
||||
selector,
|
||||
value: value.to_owned(),
|
||||
host: host.map(str::to_owned),
|
||||
vhost: vhost.map(str::to_owned),
|
||||
ttl_seconds: None,
|
||||
confirm_wide: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_single_range_returns_206_and_preserves_validators() {
|
||||
let cache = Cache::new(22);
|
||||
let ticket = store(
|
||||
&cache,
|
||||
"range",
|
||||
"range.example",
|
||||
"/asset.bin",
|
||||
HeaderMap::new(),
|
||||
&[
|
||||
("etag", "\"asset-v1\""),
|
||||
("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
],
|
||||
"abcdefghij",
|
||||
)
|
||||
.await;
|
||||
let mut request_headers = HeaderMap::new();
|
||||
request_headers.insert("range", HeaderValue::from_static("bytes=2-5"));
|
||||
let range = match request_range(&Method::GET, &request_headers) {
|
||||
RangeSelection::Single(range) => range,
|
||||
other => panic!("unexpected range selection: {other:?}"),
|
||||
};
|
||||
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
|
||||
assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(hit.response.headers()["content-range"], "bytes 2-5/10");
|
||||
assert_eq!(hit.response.headers()["content-length"], "4");
|
||||
assert_eq!(hit.response.headers()["accept-ranges"], "bytes");
|
||||
assert_eq!(hit.response.headers()["etag"], "\"asset-v1\"");
|
||||
assert!(hit.response.headers().contains_key("age"));
|
||||
let body = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, Bytes::from_static(b"cdef"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_range_supports_suffix_open_unsatisfied_and_if_range() {
|
||||
let cache = Cache::new(22);
|
||||
let ticket = store(
|
||||
&cache,
|
||||
"range",
|
||||
"range.example",
|
||||
"/asset.bin",
|
||||
HeaderMap::new(),
|
||||
&[
|
||||
("etag", "\"asset-v1\""),
|
||||
("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
],
|
||||
"abcdefghij",
|
||||
)
|
||||
.await;
|
||||
let cases = [
|
||||
(ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"),
|
||||
(ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"),
|
||||
];
|
||||
for (range, expected, content_range) in cases {
|
||||
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
|
||||
assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(hit.response.headers()["content-range"], content_range);
|
||||
let body = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, Bytes::copy_from_slice(expected.as_bytes()));
|
||||
}
|
||||
|
||||
let unsatisfied = cache.get(&ticket).unwrap().apply_range(
|
||||
ByteRange { start: Some(50), end: None },
|
||||
None,
|
||||
);
|
||||
assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10");
|
||||
assert_eq!(
|
||||
unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(),
|
||||
0
|
||||
);
|
||||
|
||||
let mismatch = HeaderValue::from_static("\"asset-v2\"");
|
||||
let full = cache.get(&ticket).unwrap().apply_range(
|
||||
ByteRange { start: Some(0), end: Some(2) },
|
||||
Some(&mismatch),
|
||||
);
|
||||
assert_eq!(full.response.status(), StatusCode::OK);
|
||||
assert!(!full.response.headers().contains_key("content-range"));
|
||||
assert_eq!(
|
||||
full.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"abcdefghij")
|
||||
);
|
||||
|
||||
let matched = HeaderValue::from_static("\"asset-v1\"");
|
||||
let partial = cache.get(&ticket).unwrap().apply_range(
|
||||
ByteRange { start: Some(0), end: Some(2) },
|
||||
Some(&matched),
|
||||
);
|
||||
assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() {
|
||||
let mut multipart = HeaderMap::new();
|
||||
multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5"));
|
||||
assert_eq!(
|
||||
request_range(&Method::GET, &multipart),
|
||||
RangeSelection::Unsupported
|
||||
);
|
||||
|
||||
let mut duplicate = HeaderMap::new();
|
||||
duplicate.append("range", HeaderValue::from_static("bytes=0-1"));
|
||||
duplicate.append("range", HeaderValue::from_static("bytes=4-5"));
|
||||
assert_eq!(
|
||||
request_range(&Method::GET, &duplicate),
|
||||
RangeSelection::Unsupported
|
||||
);
|
||||
|
||||
let mut response_headers = HeaderMap::new();
|
||||
response_headers.insert(
|
||||
"last-modified",
|
||||
HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
);
|
||||
let newer_if_range = HeaderValue::from_static("Tue, 27 Jan 2026 23:11:40 GMT");
|
||||
assert!(if_range_matches(&response_headers, Some(&newer_if_range)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_miss_single_flight_waits_until_fill_is_published() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 500;
|
||||
let ticket = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
"collapse",
|
||||
"http",
|
||||
"collapse.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/same",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let leader = match cache.claim_fill(&ticket, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("first request must become fill leader"),
|
||||
};
|
||||
let follower_cache = cache.clone();
|
||||
let follower_ticket = ticket.clone();
|
||||
let follower_settings = settings.clone();
|
||||
let follower = tokio::spawn(async move {
|
||||
match follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await
|
||||
{
|
||||
FillClaim::Waited => follower_cache.get(&follower_ticket).is_some(),
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if cache.view().collapse_waiters == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
assert_eq!(cache.view().collapse_waiters, 1);
|
||||
assert!(!follower.is_finished());
|
||||
|
||||
let mut response = Response::new(Body::from("collapsed-payload"));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap_with_fill(response, ticket.clone(), plan, &settings, Some(leader))
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(follower.await.unwrap());
|
||||
assert_eq!(cache.view().collapsed_fills, 1);
|
||||
assert_eq!(cache.view().collapse_waiters, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_flight_timeout_and_leader_drop_release_the_key() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 10;
|
||||
let ticket = cache
|
||||
.ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new())
|
||||
.unwrap();
|
||||
let leader = match cache.claim_fill(&ticket, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("missing leader"),
|
||||
};
|
||||
assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout));
|
||||
assert_eq!(cache.view().collapse_timeouts, 1);
|
||||
drop(leader);
|
||||
assert!(matches!(
|
||||
cache.claim_fill(&ticket, &settings).await,
|
||||
FillClaim::Leader(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_single_flight_waiter_releases_waiter_metric() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 5_000;
|
||||
let ticket = cache
|
||||
.ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new())
|
||||
.unwrap();
|
||||
let leader = match cache.claim_fill(&ticket, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("missing leader"),
|
||||
};
|
||||
let follower_cache = cache.clone();
|
||||
let follower_ticket = ticket.clone();
|
||||
let follower_settings = settings.clone();
|
||||
let follower = tokio::spawn(async move {
|
||||
follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if cache.view().collapse_waiters == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
assert_eq!(cache.view().collapse_waiters, 1);
|
||||
follower.abort();
|
||||
let _ = follower.await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(cache.view().collapse_waiters, 0);
|
||||
drop(leader);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_flight_retries_after_another_vary_representation_is_published() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 500;
|
||||
let mut en_headers = HeaderMap::new();
|
||||
en_headers.insert("accept-language", HeaderValue::from_static("en"));
|
||||
let mut pl_headers = HeaderMap::new();
|
||||
pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
|
||||
let en = cache
|
||||
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers)
|
||||
.unwrap();
|
||||
let pl = cache
|
||||
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers)
|
||||
.unwrap();
|
||||
assert_eq!(en.key, pl.key, "Vary is learned from the response");
|
||||
let leader = match cache.claim_fill(&en, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("first representation must become leader"),
|
||||
};
|
||||
let follower_cache = cache.clone();
|
||||
let follower_ticket = pl.clone();
|
||||
let follower_settings = settings.clone();
|
||||
let follower = tokio::spawn(async move {
|
||||
assert!(matches!(
|
||||
follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await,
|
||||
FillClaim::Waited
|
||||
));
|
||||
assert!(follower_cache.get(&follower_ticket).is_none());
|
||||
matches!(
|
||||
follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await,
|
||||
FillClaim::Leader(_)
|
||||
)
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if cache.view().collapse_waiters == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
|
||||
let mut response = Response::new(Body::from("hello"));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("vary", HeaderValue::from_static("Accept-Language"));
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap_with_fill(response, en, plan, &settings, Some(leader))
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(follower.await.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_if_error_policy_is_configurable_by_error_class_and_status() {
|
||||
let mut settings = Settings::default();
|
||||
settings.stale_if_error_timeout = false;
|
||||
settings.stale_if_error_dns = false;
|
||||
settings.stale_if_error_statuses = vec![500, 503];
|
||||
assert!(!settings.allows_stale_error_class(StaleErrorClass::Timeout));
|
||||
assert!(!settings.allows_stale_error_class(StaleErrorClass::Dns));
|
||||
assert!(settings.allows_stale_error_class(StaleErrorClass::Connection));
|
||||
assert!(settings.allows_stale_error_class(StaleErrorClass::Tls));
|
||||
assert!(settings.allows_stale_error_status(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
assert!(settings.allows_stale_error_status(StatusCode::SERVICE_UNAVAILABLE));
|
||||
assert!(!settings.allows_stale_error_status(StatusCode::BAD_GATEWAY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() {
|
||||
let cache = Cache::new(22);
|
||||
let ticket = cache
|
||||
.ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new())
|
||||
.unwrap();
|
||||
let mut settings = active_settings();
|
||||
settings.proactive_refresh_enabled = true;
|
||||
settings.proactive_refresh_percent = 100;
|
||||
settings.proactive_refresh_max_inflight = 1;
|
||||
let scheduled = cache
|
||||
.begin_proactive_schedule(&ticket, &settings)
|
||||
.expect("first schedule");
|
||||
assert!(cache.begin_proactive_schedule(&ticket, &settings).is_none());
|
||||
let running = cache
|
||||
.try_begin_proactive_refresh(&settings)
|
||||
.expect("first inflight refresh");
|
||||
assert!(cache.try_begin_proactive_refresh(&settings).is_none());
|
||||
assert_eq!(cache.view().proactive_inflight, 1);
|
||||
drop(running);
|
||||
drop(scheduled);
|
||||
assert!(cache.begin_proactive_schedule(&ticket, &settings).is_some());
|
||||
assert_eq!(cache.view().proactive_inflight, 0);
|
||||
}
|
||||
-471
@@ -1,471 +0,0 @@
|
||||
#[test]
|
||||
fn proactive_refresh_pending_tasks_are_bounded() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.proactive_refresh_enabled = true;
|
||||
settings.proactive_refresh_percent = 100;
|
||||
settings.proactive_refresh_max_inflight = 1;
|
||||
let mut guards = Vec::new();
|
||||
for index in 0..64 {
|
||||
let ticket = cache
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"refresh.example",
|
||||
"origin",
|
||||
&format!("/asset-{index}"),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
guards.push(
|
||||
cache
|
||||
.begin_proactive_schedule(&ticket, &settings)
|
||||
.expect("schedule below pending limit"),
|
||||
);
|
||||
}
|
||||
let overflow = cache
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"refresh.example",
|
||||
"origin",
|
||||
"/overflow",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none());
|
||||
assert!(cache.view().proactive_skipped > 0);
|
||||
drop(guards);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_revalidation_set_has_a_hard_limit() {
|
||||
let cache = Cache::new(22);
|
||||
let base = Revalidation {
|
||||
headers: HeaderMap::new(),
|
||||
body: Bytes::new(),
|
||||
body_allocated: 0,
|
||||
_transient_leases: Vec::new(),
|
||||
hits: 0,
|
||||
transformed: false,
|
||||
storage_key: [0; 32],
|
||||
resident_age_at_lookup: Duration::ZERO,
|
||||
skip_compression: false,
|
||||
reusable_without_validation: false,
|
||||
age: 0,
|
||||
ttl: 0,
|
||||
stale_if_error: 0,
|
||||
must_revalidate: false,
|
||||
proxy_revalidate: false,
|
||||
origin_etag: None,
|
||||
origin_last_modified: None,
|
||||
};
|
||||
let mut guards = Vec::new();
|
||||
for index in 0..BACKGROUND_REVALIDATION_LIMIT {
|
||||
let mut cached = base.clone();
|
||||
cached.storage_key[0..8].copy_from_slice(&(index as u64).to_be_bytes());
|
||||
guards.push(
|
||||
cache
|
||||
.begin_background_revalidation(&cached)
|
||||
.expect("revalidation below hard limit"),
|
||||
);
|
||||
}
|
||||
let mut overflow = base;
|
||||
overflow.storage_key = [0xff; 32];
|
||||
assert!(cache.begin_background_revalidation(&overflow).is_none());
|
||||
drop(guards);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() {
|
||||
let cache = Cache::new(22);
|
||||
let one = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/asset.js?v=1",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:one")],
|
||||
"one",
|
||||
)
|
||||
.await;
|
||||
let two = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/asset.js?v=2",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:two")],
|
||||
"two",
|
||||
)
|
||||
.await;
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Exact,
|
||||
"/asset.js?v=1",
|
||||
Some("purge.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 1);
|
||||
assert!(cache.get(&one).is_none());
|
||||
assert!(cache.get(&two).is_some());
|
||||
|
||||
let three = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/assets/a.css",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:three")],
|
||||
"three",
|
||||
)
|
||||
.await;
|
||||
let four = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/assets/b.css",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:four")],
|
||||
"four",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Prefix,
|
||||
"/assets/",
|
||||
Some("purge.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap()
|
||||
.affected_entries,
|
||||
2
|
||||
);
|
||||
assert!(cache.get(&three).is_none());
|
||||
assert!(cache.get(&four).is_none());
|
||||
|
||||
let tagged = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/tagged",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:tagged")],
|
||||
"tagged",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Tag,
|
||||
"release:tagged",
|
||||
Some("purge.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap()
|
||||
.affected_entries,
|
||||
1
|
||||
);
|
||||
assert!(cache.get(&tagged).is_none());
|
||||
|
||||
let keyed = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/by-key",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"keyed",
|
||||
)
|
||||
.await;
|
||||
let key = key_hex(&keyed.key);
|
||||
assert_eq!(
|
||||
cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::CacheKey,
|
||||
&key,
|
||||
Some("purge.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap()
|
||||
.affected_entries,
|
||||
1
|
||||
);
|
||||
assert!(cache.get(&keyed).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selective_purge_is_a_barrier_for_a_fill_started_before_the_purge() {
|
||||
let cache = Cache::new(22);
|
||||
let settings = active_settings();
|
||||
let old_ticket = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
"app",
|
||||
"http",
|
||||
"purge.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/late",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Tag,
|
||||
"release:late",
|
||||
Some("purge.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 0);
|
||||
|
||||
let mut response = Response::new(Body::from("old-fill"));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-tag", HeaderValue::from_static("release:late"));
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap(response, old_ticket, plan, &settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cache.view().entries, 0, "pre-purge fill must not republish");
|
||||
|
||||
let new_ticket = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/late",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:late")],
|
||||
"new-fill",
|
||||
)
|
||||
.await;
|
||||
assert!(cache.get(&new_ticket).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_of_one_vary_variant_removes_the_whole_variant_family() {
|
||||
let cache = Cache::new(22);
|
||||
let mut en_headers = HeaderMap::new();
|
||||
en_headers.insert("accept-language", HeaderValue::from_static("en"));
|
||||
let mut pl_headers = HeaderMap::new();
|
||||
pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
|
||||
let en = store(
|
||||
&cache,
|
||||
"app",
|
||||
"vary.example",
|
||||
"/localized",
|
||||
en_headers,
|
||||
&[("vary", "Accept-Language"), ("cache-tag", "variant:en")],
|
||||
"hello",
|
||||
)
|
||||
.await;
|
||||
let pl = store(
|
||||
&cache,
|
||||
"app",
|
||||
"vary.example",
|
||||
"/localized",
|
||||
pl_headers,
|
||||
&[("vary", "Accept-Language"), ("cache-tag", "variant:pl")],
|
||||
"czesc",
|
||||
)
|
||||
.await;
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Tag,
|
||||
"variant:en",
|
||||
Some("vary.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 2);
|
||||
assert!(cache.get(&en).is_none());
|
||||
assert!(cache.get(&pl).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ban_is_lazy_blocks_old_object_allows_new_fill_and_can_be_removed() {
|
||||
let cache = Cache::new(22);
|
||||
let old = store(
|
||||
&cache,
|
||||
"app",
|
||||
"ban.example",
|
||||
"/page",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"old",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Ban,
|
||||
InvalidationSelector::Exact,
|
||||
"/page",
|
||||
Some("ban.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
let ban_id = result.ban_id.unwrap();
|
||||
assert_eq!(result.affected_entries, 0);
|
||||
assert_eq!(cache.view().entries, 1, "BAN must not scan storage eagerly");
|
||||
assert!(cache.get(&old).is_none());
|
||||
assert_eq!(cache.view().entries, 0);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
let fresh = store(
|
||||
&cache,
|
||||
"app",
|
||||
"ban.example",
|
||||
"/page",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"fresh",
|
||||
)
|
||||
.await;
|
||||
assert!(cache.get(&fresh).is_some(), "old BAN must not reject newer objects");
|
||||
assert_eq!(cache.bans().len(), 1);
|
||||
assert!(cache.remove_ban(ban_id));
|
||||
assert!(cache.bans().is_empty());
|
||||
assert!(!cache.remove_ban(ban_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regex_and_root_prefix_require_explicit_wide_confirmation() {
|
||||
let cache = Cache::new(22);
|
||||
store(
|
||||
&cache,
|
||||
"app",
|
||||
"wide.example",
|
||||
"/one",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"one",
|
||||
)
|
||||
.await;
|
||||
let regex = invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Regex,
|
||||
".*",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(cache.invalidate(regex.clone()).is_err());
|
||||
let scoped_regex = invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Regex,
|
||||
"^/one$",
|
||||
Some("wide.example"),
|
||||
Some("app"),
|
||||
);
|
||||
assert!(cache.invalidate(scoped_regex).is_err());
|
||||
let mut confirmed = regex;
|
||||
confirmed.confirm_wide = true;
|
||||
assert_eq!(cache.invalidate(confirmed).unwrap().affected_entries, 1);
|
||||
|
||||
let prefix = invalidation(
|
||||
InvalidationOperation::Ban,
|
||||
InvalidationSelector::Prefix,
|
||||
"/",
|
||||
Some("wide.example"),
|
||||
Some("app"),
|
||||
);
|
||||
assert!(cache.invalidate(prefix.clone()).is_err());
|
||||
let mut confirmed_prefix = prefix;
|
||||
confirmed_prefix.confirm_wide = true;
|
||||
assert!(cache.invalidate(confirmed_prefix).unwrap().ban_id.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn many_distinct_selective_purges_do_not_retain_tombstones_forever() {
|
||||
let cache = Cache::new(22);
|
||||
store(
|
||||
&cache,
|
||||
"app",
|
||||
"memory.example",
|
||||
"/stable",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"unchanged",
|
||||
)
|
||||
.await;
|
||||
// A production cache may see an unbounded stream of PURGEs for URLs
|
||||
// that will never be cached again. The invalidated-key index must
|
||||
// not remember all of those removed objects indefinitely.
|
||||
for index in 0..512 {
|
||||
let target = format!("/unique-{index}");
|
||||
let old = store(
|
||||
&cache,
|
||||
"app",
|
||||
"memory.example",
|
||||
&target,
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"x",
|
||||
)
|
||||
.await;
|
||||
assert!(cache.ticket_current(&old));
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Exact,
|
||||
&target,
|
||||
Some("memory.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 1);
|
||||
assert!(!cache.ticket_current(&old), "pre-purge ticket must remain stale");
|
||||
let state = cache.store.lock().unwrap_or_else(|e| e.into_inner());
|
||||
assert!(
|
||||
state.key_generations.len() <= 1,
|
||||
"key generations must not accumulate for already purged URLs"
|
||||
);
|
||||
}
|
||||
// A new ticket after the invalidation barrier is valid and can fill
|
||||
// the same URL again, preserving ordinary cache behavior.
|
||||
let new = store(
|
||||
&cache,
|
||||
"app",
|
||||
"memory.example",
|
||||
"/unique-0",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"new",
|
||||
)
|
||||
.await;
|
||||
assert!(cache.ticket_current(&new));
|
||||
assert!(cache.get(&new).is_some());
|
||||
let stable = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
"app",
|
||||
"http",
|
||||
"memory.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/stable",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cache.get(&stable).is_some(), "unrelated entries must survive");
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
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 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");
|
||||
for invalid in [
|
||||
"",
|
||||
"a..b",
|
||||
"*.test",
|
||||
"a/b",
|
||||
"user@host",
|
||||
"-bad.test",
|
||||
"a:99999",
|
||||
] {
|
||||
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 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);
|
||||
}
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
#[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,
|
||||
..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());
|
||||
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()];
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
#[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}");
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
use super::*;
|
||||
use super::fastcgi::{
|
||||
fastcgi_body_stream, fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record,
|
||||
FCGI_END_REQUEST, FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT,
|
||||
};
|
||||
use axum::{body::Body, response::Response};
|
||||
use bytes::Bytes;
|
||||
use futures_util::StreamExt;
|
||||
use http::{
|
||||
header::{
|
||||
ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH,
|
||||
CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE,
|
||||
LAST_MODIFIED, LOCATION, RANGE, VARY,
|
||||
},
|
||||
HeaderMap, HeaderValue, Method, StatusCode, Uri, Version,
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use std::{collections::BTreeMap, path::Path, time::Duration};
|
||||
use tempfile::TempDir;
|
||||
use tokio::{io::AsyncWriteExt, sync::mpsc, time::Instant};
|
||||
|
||||
fn service(root: &Path, listing: bool) -> CompiledService {
|
||||
Service {
|
||||
id: "site".into(),
|
||||
name: "Site".into(),
|
||||
document_root: root.to_path_buf(),
|
||||
index: vec!["index.html".into(), "index.htm".into()],
|
||||
directory_listing: listing,
|
||||
..Default::default()
|
||||
}
|
||||
.compile()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn request(method: Method, uri: &str) -> http::Request<()> {
|
||||
http::Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn serve_request(
|
||||
service: &CompiledService,
|
||||
request: &http::Request<()>,
|
||||
path: &str,
|
||||
) -> Response {
|
||||
serve_body_request(service, request, path, Body::empty()).await
|
||||
}
|
||||
|
||||
async fn serve_body_request(
|
||||
service: &CompiledService,
|
||||
request: &http::Request<()>,
|
||||
path: &str,
|
||||
body: Body,
|
||||
) -> Response {
|
||||
serve(
|
||||
service,
|
||||
request.method().clone(),
|
||||
request.headers().clone(),
|
||||
request.uri().clone(),
|
||||
path,
|
||||
body,
|
||||
RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: Some(12345),
|
||||
server_name: "example.test".into(),
|
||||
server_port: 80,
|
||||
tls: false,
|
||||
version: Version::HTTP_11,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_head_index_and_directory_redirect_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("dir")).unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"root").unwrap();
|
||||
std::fs::write(tmp.path().join("dir/index.html"), b"nested").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "root");
|
||||
|
||||
let response = serve_request(&service, &request(Method::HEAD, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CONTENT_LENGTH], "4");
|
||||
assert!(response.into_body().collect().await.unwrap().to_bytes().is_empty());
|
||||
|
||||
let response = serve_request(&service, &request(Method::GET, "/dir?x=1"), "/dir").await;
|
||||
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
|
||||
assert_eq!(response.headers()[LOCATION], "/dir/?x=1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("empty")).unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
|
||||
let missing = serve_request(&service, &request(Method::GET, "/missing.txt"), "/missing.txt").await;
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let directory = serve_request(&service, &request(Method::GET, "/empty/"), "/empty/").await;
|
||||
assert_eq!(directory.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(directory.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
|
||||
let directory_body = directory.into_body().collect().await.unwrap().to_bytes();
|
||||
let directory_body = std::str::from_utf8(&directory_body).unwrap();
|
||||
assert!(directory_body.contains("403"));
|
||||
assert!(!directory_body.contains("directory listing is disabled"));
|
||||
|
||||
let directory_head = serve_request(&service, &request(Method::HEAD, "/empty/"), "/empty/").await;
|
||||
assert_eq!(directory_head.status(), StatusCode::FORBIDDEN);
|
||||
assert!(directory_head.into_body().collect().await.unwrap().to_bytes().is_empty());
|
||||
|
||||
let method = serve_request(&service, &request(Method::POST, "/missing.txt"), "/missing.txt").await;
|
||||
assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert_eq!(method.headers()[ALLOW], "GET, HEAD");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn last_modified_condition_returns_304() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"validator").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await;
|
||||
let last_modified = first.headers()[LAST_MODIFIED].clone();
|
||||
assert_eq!(first.headers()[CONTENT_TYPE], "text/plain; charset=utf-8");
|
||||
assert_eq!(first.headers()[CONTENT_LENGTH], "9");
|
||||
|
||||
let mut conditional = request(Method::GET, "/file.txt");
|
||||
conditional
|
||||
.headers_mut()
|
||||
.insert(IF_MODIFIED_SINCE, last_modified);
|
||||
let response = serve_request(&service, &conditional, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
|
||||
assert!(response.headers().contains_key(LAST_MODIFIED));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validators_and_single_ranges_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await;
|
||||
let etag = first.headers()[ETAG].clone();
|
||||
assert_eq!(first.headers()[ACCEPT_RANGES], "bytes");
|
||||
|
||||
let mut conditional = request(Method::GET, "/file.txt");
|
||||
conditional.headers_mut().insert(IF_NONE_MATCH, etag);
|
||||
assert_eq!(
|
||||
serve_request(&service, &conditional, "/file.txt").await.status(),
|
||||
StatusCode::NOT_MODIFIED
|
||||
);
|
||||
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged.headers_mut().insert(RANGE, "bytes=2-5".parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(response.headers()[CONTENT_RANGE], "bytes 2-5/10");
|
||||
assert_eq!(response.headers()[CONTENT_LENGTH], "4");
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "2345");
|
||||
|
||||
let mut invalid = request(Method::GET, "/file.txt");
|
||||
invalid.headers_mut().insert(RANGE, "bytes=99-100".parse().unwrap());
|
||||
let response = serve_request(&service, &invalid, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10");
|
||||
|
||||
let mut if_range_match = request(Method::GET, "/file.txt");
|
||||
if_range_match.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap());
|
||||
if_range_match.headers_mut().insert(IF_RANGE, first.headers()[ETAG].clone());
|
||||
assert_eq!(serve_request(&service, &if_range_match, "/file.txt").await.status(), StatusCode::PARTIAL_CONTENT);
|
||||
|
||||
let mut if_range_miss = request(Method::GET, "/file.txt");
|
||||
if_range_miss.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap());
|
||||
if_range_miss.headers_mut().insert(IF_RANGE, "\"different\"".parse().unwrap());
|
||||
let response = serve_request(&service, &if_range_miss, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CONTENT_LENGTH], "10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_byte_ranges_stream_each_requested_part() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(RANGE, "bytes=0-1,6-8".parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
let content_type = response.headers()[CONTENT_TYPE].to_str().unwrap().to_owned();
|
||||
assert!(content_type.starts_with("multipart/byteranges; boundary="));
|
||||
assert!(!response.headers().contains_key(CONTENT_RANGE));
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body.contains("Content-Range: bytes 0-1/10"));
|
||||
assert!(body.contains("Content-Range: bytes 6-8/10"));
|
||||
assert!(body.contains("\r\n01\r\n"));
|
||||
assert!(body.contains("\r\n678\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn too_many_byte_ranges_are_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let ranges = std::iter::repeat("0-0").take(17).collect::<Vec<_>>().join(",");
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(RANGE, format!("bytes={ranges}").parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_escapes_html_and_encodes_urls() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("a & b.txt"), b"x").unwrap();
|
||||
let service = service(tmp.path(), true);
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("a & b.txt"));
|
||||
assert!(body.contains("a%20%26%20b.txt"));
|
||||
assert!(!body.contains(tmp.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_directory_listing_is_repeatable_across_refreshes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("first.txt"), b"one").unwrap();
|
||||
std::fs::write(tmp.path().join("second.txt"), b"two").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "repeat-listing".into(),
|
||||
name: "Repeat listing".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: Vec::new(),
|
||||
directory_listing: true,
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
for _ in 0..2 {
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("first.txt"));
|
||||
assert!(body.contains("second.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("with-index")).unwrap();
|
||||
std::fs::create_dir(tmp.path().join("without-index")).unwrap();
|
||||
std::fs::write(tmp.path().join("with-index/index.html"), b"INDEX-WINS").unwrap();
|
||||
std::fs::write(tmp.path().join("with-index/other.txt"), b"hidden by index").unwrap();
|
||||
std::fs::write(tmp.path().join("without-index/listed.txt"), b"listed").unwrap();
|
||||
let service = service(tmp.path(), true);
|
||||
|
||||
let indexed = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/with-index/"),
|
||||
"/with-index/",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(indexed.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
indexed.into_body().collect().await.unwrap().to_bytes(),
|
||||
"INDEX-WINS"
|
||||
);
|
||||
|
||||
let listed = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/without-index/"),
|
||||
"/without-index/",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(listed.status(), StatusCode::OK);
|
||||
let body = listed.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("listed.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mime_overrides_default_mime_and_cache_control_are_applied_to_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("asset.foo"), b"foo").unwrap();
|
||||
std::fs::write(tmp.path().join("asset.unknown"), b"unknown").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "mime".into(),
|
||||
name: "MIME".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
mime_types: BTreeMap::from([(".FOO".into(), "application/x-foo".into())]),
|
||||
default_mime_type: "application/x-fallback".into(),
|
||||
cache_control: Some("public, max-age=3600".into()),
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
assert!(configured.mime_types.contains_key("foo"));
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let custom = serve_request(&service, &request(Method::GET, "/asset.foo"), "/asset.foo").await;
|
||||
assert_eq!(custom.headers()[CONTENT_TYPE], "application/x-foo");
|
||||
assert_eq!(custom.headers()[CACHE_CONTROL], "public, max-age=3600");
|
||||
|
||||
let fallback = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/asset.unknown"),
|
||||
"/asset.unknown",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(fallback.headers()[CONTENT_TYPE], "application/x-fallback");
|
||||
assert_eq!(fallback.headers()[CACHE_CONTROL], "public, max-age=3600");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_files_rewrites_cache_rules_headers_and_downloads_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"SPA").unwrap();
|
||||
std::fs::write(tmp.path().join("style.css"), b"css").unwrap();
|
||||
std::fs::write(tmp.path().join("archive.zip"), b"zip").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "routing".into(),
|
||||
name: "Routing".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
try_files: vec!["$uri".into(), "/index.html?$query_string".into()],
|
||||
rewrites: vec![
|
||||
RewriteRule {
|
||||
pattern: "^/internal$".into(),
|
||||
replacement: "/style.css".into(),
|
||||
status: None,
|
||||
},
|
||||
RewriteRule {
|
||||
pattern: "^/old/(.*)$".into(),
|
||||
replacement: "/new/$1".into(),
|
||||
status: Some(301),
|
||||
},
|
||||
],
|
||||
cache_control: Some("no-cache".into()),
|
||||
cache_rules: vec![CacheRule {
|
||||
matcher: "ext:css".into(),
|
||||
cache_control: "public, max-age=3600".into(),
|
||||
}],
|
||||
response_headers: BTreeMap::from([("x-static-service".into(), "yes".into())]),
|
||||
download_extensions: vec!["zip".into()],
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let spa = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/missing?x=1"),
|
||||
"/missing",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(spa.status(), StatusCode::OK);
|
||||
assert_eq!(spa.headers()[CACHE_CONTROL], "no-cache");
|
||||
assert_eq!(spa.headers()["x-static-service"], "yes");
|
||||
assert_eq!(spa.into_body().collect().await.unwrap().to_bytes(), "SPA");
|
||||
|
||||
let internal = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/internal"),
|
||||
"/internal",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(internal.status(), StatusCode::OK);
|
||||
assert_eq!(internal.headers()[CONTENT_TYPE], "text/css; charset=utf-8");
|
||||
assert_eq!(internal.headers()[CACHE_CONTROL], "public, max-age=3600");
|
||||
|
||||
let redirect = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/old/path"),
|
||||
"/old/path",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(redirect.status(), StatusCode::MOVED_PERMANENTLY);
|
||||
assert_eq!(redirect.headers()[LOCATION], "/new/path");
|
||||
|
||||
let download = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/archive.zip"),
|
||||
"/archive.zip",
|
||||
)
|
||||
.await;
|
||||
assert!(download.headers()[CONTENT_DISPOSITION]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("attachment;"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn precompressed_sidecars_keep_mime_validators_and_range_semantics() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("app.js"), b"ORIGINAL").unwrap();
|
||||
std::fs::write(tmp.path().join("app.js.br"), b"BROTLI").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "compressed".into(),
|
||||
name: "Compressed".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
precompressed: Precompressed {
|
||||
enabled: true,
|
||||
brotli: true,
|
||||
gzip: false,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let mut accepted = request(Method::GET, "/app.js");
|
||||
accepted
|
||||
.headers_mut()
|
||||
.insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap());
|
||||
let response = serve_request(&service, &accepted, "/app.js").await;
|
||||
assert_eq!(response.headers()[CONTENT_ENCODING], "br");
|
||||
assert_eq!(response.headers()[CONTENT_TYPE], "text/javascript; charset=utf-8");
|
||||
assert_eq!(response.headers()[VARY], "Accept-Encoding");
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "BROTLI");
|
||||
|
||||
let mut ranged = request(Method::GET, "/app.js");
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap());
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(RANGE, "bytes=0-2".parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/app.js").await;
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(response.headers()[CONTENT_RANGE], "bytes 0-2/6");
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "BRO");
|
||||
|
||||
let plain = serve_request(&service, &request(Method::GET, "/app.js"), "/app.js").await;
|
||||
assert!(!plain.headers().contains_key(CONTENT_ENCODING));
|
||||
assert_eq!(plain.into_body().collect().await.unwrap().to_bytes(), "ORIGINAL");
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
async fn fastcgi_fixture(response_body: &'static [u8]) -> std::net::SocketAddr {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap();
|
||||
if kind == FCGI_STDIN && content.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut stdout = b"Status: 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nX-PHP-FPM: fixture\r\n\r\n".to_vec();
|
||||
stdout.extend_from_slice(response_body);
|
||||
write_fcgi_record(&mut stream, FCGI_STDOUT, &stdout).await.unwrap();
|
||||
write_fcgi_record(&mut stream, FCGI_STDOUT, &[]).await.unwrap();
|
||||
write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
});
|
||||
address
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fastcgi_stream_can_be_polled_repeatedly_after_eof() {
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
tx.send(Ok(Bytes::from_static(b"data"))).await.unwrap();
|
||||
drop(tx);
|
||||
let mut body = Box::pin(fastcgi_body_stream(rx));
|
||||
assert_eq!(body.next().await.unwrap().unwrap(), Bytes::from_static(b"data"));
|
||||
assert!(body.next().await.is_none());
|
||||
// Without `fuse` this panics inside futures-util::stream::unfold.
|
||||
assert!(body.next().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_fpm_gzip_response_finishes_after_fastcgi_eof() {
|
||||
use std::io::Read;
|
||||
|
||||
let expected = b"FastCGI compressed response regression: 0123456789 0123456789 0123456789";
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'placeholder';").unwrap();
|
||||
let address = fastcgi_fixture(expected).await;
|
||||
let mut configured = Service {
|
||||
id: "php-compression-regression".into(),
|
||||
name: "PHP compression regression".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.php".into()],
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
connect_timeout_ms: 500,
|
||||
request_timeout_ms: 5_000,
|
||||
max_concurrent_requests: 1,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let mut accept = HeaderMap::new();
|
||||
accept.insert("accept-encoding", "gzip".parse().unwrap());
|
||||
let settings = crate::compression::Settings {
|
||||
gzip: true,
|
||||
br: false,
|
||||
zstd: false,
|
||||
deflate: false,
|
||||
min_bytes: 32,
|
||||
};
|
||||
let response = crate::compression::encode(response, &accept, &Method::GET, &settings).await;
|
||||
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
|
||||
|
||||
let compressed = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let mut decoded = Vec::new();
|
||||
flate2::read::GzDecoder::new(compressed.as_ref())
|
||||
.read_to_end(&mut decoded)
|
||||
.unwrap();
|
||||
assert_eq!(decoded.as_slice(), expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'SOURCE';").unwrap();
|
||||
let address = fastcgi_fixture(b"PHP-OK").await;
|
||||
let mut configured = Service {
|
||||
id: "php".into(),
|
||||
name: "PHP".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.php".into()],
|
||||
try_files: vec!["$uri".into(), "/index.php?$query_string".into()],
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
connect_timeout_ms: 500,
|
||||
request_timeout_ms: 5_000,
|
||||
max_concurrent_requests: 4,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_body_request(
|
||||
&service,
|
||||
&request(Method::POST, "/missing?name=test"),
|
||||
"/missing",
|
||||
Body::from("payload"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["x-php-fpm"], "fixture");
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, "PHP-OK");
|
||||
assert!(!std::str::from_utf8(&body).unwrap().contains("SOURCE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_fpm_executes_configured_directory_index() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'INDEX-SOURCE';").unwrap();
|
||||
let address = fastcgi_fixture(b"PHP-INDEX").await;
|
||||
let mut configured = Service {
|
||||
id: "php-index".into(),
|
||||
name: "PHP index".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.php".into()],
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
connect_timeout_ms: 500,
|
||||
request_timeout_ms: 5_000,
|
||||
max_concurrent_requests: 4,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, "PHP-INDEX");
|
||||
assert!(!String::from_utf8_lossy(&body).contains("INDEX-SOURCE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_request_body_limit_returns_413_before_fastcgi_connect() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'secret';").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-body-limit".into(),
|
||||
name: "PHP body limit".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: "tcp:127.0.0.1:9".into(),
|
||||
max_request_body_bytes: 5,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let mut req = request(Method::POST, "/index.php");
|
||||
req.headers_mut().insert(CONTENT_LENGTH, HeaderValue::from_static("10"));
|
||||
let response = serve_body_request(&service, &req, "/index.php", Body::from("0123456789")).await;
|
||||
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_fpm_failure_returns_gateway_error_instead_of_php_source() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"SECRET-PHP-SOURCE").unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let mut configured = Service {
|
||||
id: "php-fail".into(),
|
||||
name: "PHP fail".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
connect_timeout_ms: 100,
|
||||
request_timeout_ms: 500,
|
||||
max_concurrent_requests: 1,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/index.php"),
|
||||
"/index.php",
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(response.status(), StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT));
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(!String::from_utf8_lossy(&body).contains("SECRET-PHP-SOURCE"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn inside_root_symlink_policy_allows_only_targets_beneath_root() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("inside.txt"), b"inside").unwrap();
|
||||
std::fs::write(outside.path().join("outside.txt"), b"outside").unwrap();
|
||||
symlink(tmp.path().join("inside.txt"), tmp.path().join("inside-link.txt")).unwrap();
|
||||
symlink(outside.path().join("outside.txt"), tmp.path().join("outside-link.txt")).unwrap();
|
||||
let mut configured = Service {
|
||||
id: "links".into(),
|
||||
name: "Links".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
symlink_policy: SymlinkPolicy::InsideRoot,
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let inside = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/inside-link.txt"),
|
||||
"/inside-link.txt",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(inside.status(), StatusCode::OK);
|
||||
assert_eq!(inside.into_body().collect().await.unwrap().to_bytes(), "inside");
|
||||
|
||||
let outside = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/outside-link.txt"),
|
||||
"/outside-link.txt",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(outside.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_index_can_go_directly_to_directory_listing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"x").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "listing-only".into(),
|
||||
name: "Listing only".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: Vec::new(),
|
||||
directory_listing: true,
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("file.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
assert!(body.contains(&format!("RustProxy <b>{}</b>", env!("CARGO_PKG_VERSION"))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn traversal_and_symlink_escape_are_not_served() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("ok.txt"), b"ok").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
assert_eq!(
|
||||
serve_request(&service, &request(Method::GET, "/../secret"), "/../secret")
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap();
|
||||
symlink(outside.path().join("secret.txt"), tmp.path().join("escape.txt")).unwrap();
|
||||
let response = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/escape.txt"),
|
||||
"/escape.txt",
|
||||
)
|
||||
.await;
|
||||
assert_ne!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn htaccess_wordpress_style_front_controller_and_protected_files_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"front-controller").unwrap();
|
||||
std::fs::write(tmp.path().join("existing.txt"), b"existing").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(".htaccess"),
|
||||
br#"<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
</IfModule>
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-rewrite".into(),
|
||||
name: "htaccess rewrite".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let rewritten = serve_request(&service, &request(Method::GET, "/missing"), "/missing").await;
|
||||
assert_eq!(rewritten.status(), StatusCode::OK);
|
||||
assert_eq!(rewritten.into_body().collect().await.unwrap().to_bytes(), "front-controller");
|
||||
|
||||
let existing = serve_request(&service, &request(Method::GET, "/existing.txt"), "/existing.txt").await;
|
||||
assert_eq!(existing.status(), StatusCode::OK);
|
||||
assert_eq!(existing.into_body().collect().await.unwrap().to_bytes(), "existing");
|
||||
|
||||
let protected = serve_request(&service, &request(Method::GET, "/.htaccess"), "/.htaccess").await;
|
||||
assert_eq!(protected.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn htaccess_directory_index_mime_headers_expires_and_nested_scope_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("docs")).unwrap();
|
||||
std::fs::write(tmp.path().join("docs/home.foo"), b"nested").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("docs/.htaccess"),
|
||||
br#"DirectoryIndex home.foo
|
||||
Options -Indexes
|
||||
AddType text/x-foo .foo
|
||||
Header always set X-Htaccess yes
|
||||
ExpiresActive On
|
||||
ExpiresByType text/x-foo "access plus 1 hour"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-nested".into(),
|
||||
name: "htaccess nested".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: true,
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/docs/"), "/docs/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CONTENT_TYPE], "text/x-foo");
|
||||
assert_eq!(response.headers()["x-htaccess"], "yes");
|
||||
assert_eq!(response.headers()[CACHE_CONTROL], "public, max-age=3600");
|
||||
assert!(response.headers().contains_key(http::header::EXPIRES));
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "nested");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_source_extensions_are_not_served_without_php_fpm() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'secret';").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let response = serve_request(&service, &request(Method::GET, "/index.php"), "/index.php").await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(!String::from_utf8_lossy(&body).contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htaccess_php_addhandler_requires_php_fpm() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.phtml"), b"secret").unwrap();
|
||||
std::fs::write(tmp.path().join(".htaccess"), b"AddHandler application/x-httpd-php .phtml\n").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-handler".into(),
|
||||
name: "htaccess handler".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
assert!(configured.compile().unwrap_err().to_string().contains("requires PHP-FPM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htaccess_security_sensitive_unsupported_directives_fail_closed() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"secret").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(".htaccess"),
|
||||
br#"<FilesMatch "\.php$">
|
||||
SetHandler application/x-httpd-php
|
||||
</FilesMatch>
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-filesmatch".into(),
|
||||
name: "htaccess filesmatch".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, strict: false, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let error = configured.compile().unwrap_err().to_string();
|
||||
assert!(error.contains("Files/FilesMatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htaccess_strict_mode_rejects_unsupported_directives() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"ok").unwrap();
|
||||
std::fs::write(tmp.path().join(".htaccess"), b"ErrorDocument 404 /404.html\n").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-strict".into(),
|
||||
name: "htaccess strict".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, strict: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
assert!(configured.compile().unwrap_err().to_string().contains("ErrorDocument"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn htaccess_configuration_file_cannot_be_a_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(outside.path().join("outside.conf"), b"RewriteEngine On\n").unwrap();
|
||||
symlink(outside.path().join("outside.conf"), tmp.path().join(".htaccess")).unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-symlink".into(),
|
||||
name: "htaccess symlink".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let error = configured.compile().unwrap_err().to_string();
|
||||
assert!(error.contains("must not be a symlink"));
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
#[test]
|
||||
fn fastcgi_never_forwards_proxy_header_as_http_proxy() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "fastcgi-params".into(),
|
||||
name: "FastCGI params".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let php = service.php.as_ref().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("proxy", HeaderValue::from_static("http://attacker.invalid:8080"));
|
||||
headers.insert("user-agent", HeaderValue::from_static("security-test"));
|
||||
let uri: Uri = "/index.php?q=1".parse().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
php,
|
||||
&Method::GET,
|
||||
&headers,
|
||||
&uri,
|
||||
"/index.php",
|
||||
None,
|
||||
Some("q=1"),
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: Some(12345),
|
||||
server_name: "example.test".into(),
|
||||
server_port: 80,
|
||||
tls: false,
|
||||
version: Version::HTTP_11,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!encoded.windows(b"HTTP_PROXY".len()).any(|window| window == b"HTTP_PROXY"));
|
||||
assert!(encoded.windows(b"HTTP_USER_AGENT".len()).any(|window| window == b"HTTP_USER_AGENT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_custom_fastcgi_params_and_php_values_are_validated() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-custom".into(),
|
||||
name: "PHP custom".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
pass_authorization: false,
|
||||
custom_params: BTreeMap::from([("app_env".into(), "production".into())]),
|
||||
php_values: BTreeMap::from([("memory_limit".into(), "256M".into())]),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
assert!(configured.php.custom_params.contains_key("APP_ENV"));
|
||||
let service = configured.compile().unwrap();
|
||||
let php = service.php.as_ref().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("authorization", HeaderValue::from_static("Bearer secret"));
|
||||
let uri: Uri = "/index.php".parse().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
php,
|
||||
&Method::GET,
|
||||
&headers,
|
||||
&uri,
|
||||
"/index.php",
|
||||
None,
|
||||
None,
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: None,
|
||||
server_name: "example.test".into(),
|
||||
server_port: 443,
|
||||
tls: true,
|
||||
version: Version::HTTP_2,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(encoded.windows(b"APP_ENV".len()).any(|window| window == b"APP_ENV"));
|
||||
assert!(encoded.windows(b"production".len()).any(|window| window == b"production"));
|
||||
assert!(encoded.windows(b"PHP_VALUE".len()).any(|window| window == b"PHP_VALUE"));
|
||||
assert!(encoded.windows(b"memory_limit=256M".len()).any(|window| window == b"memory_limit=256M"));
|
||||
assert!(!encoded.windows(b"HTTP_AUTHORIZATION".len()).any(|window| window == b"HTTP_AUTHORIZATION"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_path_info_keeps_script_filename_separate() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-path-info".into(),
|
||||
name: "PHP PATH_INFO".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
path_info: true,
|
||||
endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
assert!(service.php.as_ref().unwrap().path_info);
|
||||
let uri: Uri = "/index.php/foo/bar?q=1".parse().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
service.php.as_ref().unwrap(),
|
||||
&Method::GET,
|
||||
&HeaderMap::new(),
|
||||
&uri,
|
||||
"/index.php",
|
||||
Some("/foo/bar"),
|
||||
Some("q=1"),
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: Some(12345),
|
||||
server_name: "example.test".into(),
|
||||
server_port: 80,
|
||||
tls: false,
|
||||
version: Version::HTTP_11,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(encoded.windows(b"PATH_INFO".len()).any(|window| window == b"PATH_INFO"));
|
||||
assert!(encoded.windows(b"/foo/bar".len()).any(|window| window == b"/foo/bar"));
|
||||
assert!(encoded.windows(b"SCRIPT_FILENAME".len()).any(|window| window == b"SCRIPT_FILENAME"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_custom_fastcgi_params_cannot_override_core_or_php_admin_values() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for name in ["SCRIPT_FILENAME", "HTTP_HOST", "PHP_VALUE", "PHP_ADMIN_VALUE"] {
|
||||
let mut configured = Service {
|
||||
id: "php-reserved".into(),
|
||||
name: "PHP reserved".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
custom_params: BTreeMap::from([(name.into(), "bad".into())]),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(configured.validate().unwrap_err().to_string().contains("reserved name"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_custom_fastcgi_data_has_a_combined_bound() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let custom_params = (0..17)
|
||||
.map(|index| (format!("APP_{index}"), "x".repeat(8192)))
|
||||
.collect();
|
||||
let mut configured = Service {
|
||||
id: "php-custom-bound".into(),
|
||||
name: "PHP custom bound".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php { custom_params, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
assert!(configured
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("at most 128 KiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_response_cannot_control_downstream_framing_or_hop_headers() {
|
||||
let (_, headers) = parse_cgi_headers(
|
||||
b"Content-Type: text/plain\r\nContent-Length: 999999\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nTE: trailers\r\nTrailer: X-Test\r\nProxy-Authenticate: Basic realm=x\r\nProxy-Authorization: secret\r\nX-App: ok\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(headers.get("x-app").unwrap(), "ok");
|
||||
for name in [
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
] {
|
||||
assert!(!headers.contains_key(name), "unexpected FastCGI framing header: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_response_rejects_informational_status_codes() {
|
||||
let error = parse_cgi_headers(b"Status: 101 Switching Protocols\r\nContent-Type: text/plain\r\n")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("unsupported informational/invalid status"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hardening_blocks_common_probe_paths_and_trace() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"ok").unwrap();
|
||||
std::fs::write(tmp.path().join(".env"), b"secret").unwrap();
|
||||
std::fs::write(tmp.path().join("backup.sql"), b"secret").unwrap();
|
||||
std::fs::write(tmp.path().join("public.txt"), b"public").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "hardened".into(),
|
||||
name: "hardened".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec![],
|
||||
directory_listing: true,
|
||||
hardening: Hardening { block_common_attacks: true, strip_unnecessary_headers: false },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
for path in ["/.env", "/wp-config.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php", "/backup.sql"] {
|
||||
let response = serve_request(&service, &request(Method::GET, path), path).await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}");
|
||||
}
|
||||
let trace = serve_request(&service, &request(Method::TRACE, "/"), "/").await;
|
||||
assert_eq!(trace.status(), StatusCode::FORBIDDEN);
|
||||
let listing = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
let listing = String::from_utf8_lossy(&listing.into_body().collect().await.unwrap().to_bytes()).to_string();
|
||||
assert!(listing.contains("public.txt"));
|
||||
assert!(!listing.contains(".env"));
|
||||
assert!(!listing.contains("backup.sql"));
|
||||
let well_known = serve_request(&service, &request(Method::GET, "/.well-known/missing"), "/.well-known/missing").await;
|
||||
assert_eq!(well_known.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hardening_strips_fingerprint_headers_after_source_rules() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"ok").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "headers".into(),
|
||||
name: "headers".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
response_headers: BTreeMap::from([
|
||||
("server".into(), "Example/1.0".into()),
|
||||
("x-powered-by".into(), "PHP/8.4".into()),
|
||||
("x-custom".into(), "kept".into()),
|
||||
]),
|
||||
hardening: Hardening { block_common_attacks: false, strip_unnecessary_headers: true },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/index.html"), "/index.html").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(!response.headers().contains_key("server"));
|
||||
assert!(!response.headers().contains_key("x-powered-by"));
|
||||
assert_eq!(response.headers()["x-custom"], "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_admin_values_are_operator_only_and_serialized() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-admin".into(), name: "PHP admin".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true, endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
php_admin_values: BTreeMap::from([("open_basedir".into(), "/var/www/webmail/:/tmp/".into())]),
|
||||
..Default::default()
|
||||
}, ..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service, service.php.as_ref().unwrap(), &Method::GET, &HeaderMap::new(),
|
||||
&"/index.php".parse().unwrap(), "/index.php", None, None,
|
||||
&RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: None,
|
||||
server_name: "webmail.example.test".into(), server_port: 8589, tls: false,
|
||||
version: Version::HTTP_11 },
|
||||
).unwrap();
|
||||
assert!(encoded.windows(b"PHP_ADMIN_VALUE".len()).any(|w| w == b"PHP_ADMIN_VALUE"));
|
||||
assert!(encoded.windows(b"open_basedir=/var/www/webmail/:/tmp/".len())
|
||||
.any(|w| w == b"open_basedir=/var/www/webmail/:/tmp/"));
|
||||
configured.php.php_values.insert("open_basedir".into(), "/".into());
|
||||
assert!(configured.validate().is_err());
|
||||
configured.php.php_values.clear();
|
||||
configured.php.php_admin_values.insert("unsafe".into(), "value\nother=1".into());
|
||||
assert!(configured.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_always_sets_http_host_for_http2_http3_wordpress_requests() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "host-fallback".into(),
|
||||
name: "Host fallback".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let php = service.php.as_ref().unwrap();
|
||||
let uri: Uri = "/wp-admin/index.php".parse().unwrap();
|
||||
for version in [Version::HTTP_2, Version::HTTP_3] {
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
php,
|
||||
&Method::GET,
|
||||
&HeaderMap::new(),
|
||||
&uri,
|
||||
"/wp-admin/index.php",
|
||||
None,
|
||||
None,
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: Some(12345),
|
||||
server_name: "example.test".into(),
|
||||
server_port: 443,
|
||||
tls: true,
|
||||
version,
|
||||
},
|
||||
).unwrap();
|
||||
assert!(encoded.windows(b"HTTP_HOST".len()).any(|w| w == b"HTTP_HOST"));
|
||||
assert!(encoded.windows(b"example.test".len()).any(|w| w == b"example.test"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_http_host_keeps_explicit_host_and_port() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "host-port".into(),
|
||||
name: "Host port".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(http::header::HOST, HeaderValue::from_static("example.test:8443"));
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
service.php.as_ref().unwrap(),
|
||||
&Method::GET,
|
||||
&headers,
|
||||
&"/index.php".parse().unwrap(),
|
||||
"/index.php",
|
||||
None,
|
||||
None,
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: None,
|
||||
server_name: "example.test".into(),
|
||||
server_port: 443,
|
||||
tls: true,
|
||||
version: Version::HTTP_11,
|
||||
},
|
||||
).unwrap();
|
||||
assert!(encoded.windows(b"HTTP_HOST".len()).any(|w| w == b"HTTP_HOST"));
|
||||
assert!(encoded.windows(b"example.test:8443".len()).any(|w| w == b"example.test:8443"));
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// Decode the actual FastCGI PARAMS records received by a mock PHP-FPM peer.
|
||||
// This tests the Web Service dispatch and parameter generation together.
|
||||
fn decode_fastcgi_params(mut bytes: &[u8]) -> BTreeMap<String, String> {
|
||||
fn read_len(bytes: &mut &[u8]) -> usize {
|
||||
let prefix = bytes[0];
|
||||
if prefix & 0x80 == 0 {
|
||||
*bytes = &bytes[1..];
|
||||
prefix as usize
|
||||
} else {
|
||||
let value = u32::from_be_bytes(bytes[..4].try_into().unwrap()) & 0x7fff_ffff;
|
||||
*bytes = &bytes[4..];
|
||||
value as usize
|
||||
}
|
||||
}
|
||||
let mut result = BTreeMap::new();
|
||||
while !bytes.is_empty() {
|
||||
let name_len = read_len(&mut bytes);
|
||||
let value_len = read_len(&mut bytes);
|
||||
let name = std::str::from_utf8(&bytes[..name_len]).unwrap().to_owned();
|
||||
let value = std::str::from_utf8(&bytes[name_len..name_len + value_len]).unwrap().to_owned();
|
||||
bytes = &bytes[name_len + value_len..];
|
||||
result.insert(name, value);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn fastcgi_fixture_record_params() -> (
|
||||
std::net::SocketAddr,
|
||||
tokio::sync::oneshot::Receiver<BTreeMap<String, String>>,
|
||||
) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut params = Vec::new();
|
||||
loop {
|
||||
let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap();
|
||||
if kind == FCGI_PARAMS {
|
||||
params.extend_from_slice(&content);
|
||||
}
|
||||
if kind == FCGI_STDIN && content.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = tx.send(decode_fastcgi_params(¶ms));
|
||||
write_fcgi_record(&mut stream, FCGI_STDOUT,
|
||||
b"Status: 200 OK\r\nContent-Type: text/css\r\n\r\nPHP-STATIC-OK").await.unwrap();
|
||||
write_fcgi_record(&mut stream, FCGI_STDOUT, &[]).await.unwrap();
|
||||
write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
});
|
||||
(address, rx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn roundcube_static_php_path_info_runs_before_front_controller_try_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("static.php"), b"<?php // static asset handler").unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php // front controller").unwrap();
|
||||
// This is the Roundcube v1.7 URL layout; /static.php/skins/... is
|
||||
// virtual PATH_INFO, not a readable directory on disk.
|
||||
let uri = "/static.php/skins/elastic/images/favicon.ico?s=1783249197";
|
||||
let (address, received) = fastcgi_fixture_record_params().await;
|
||||
let mut configured = Service {
|
||||
id: "roundcube".into(),
|
||||
name: "Roundcube".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.php".into()],
|
||||
try_files: vec!["$uri".into(), "$uri/".into(), "/index.php?$query_string".into()],
|
||||
php: Php {
|
||||
enabled: true,
|
||||
path_info: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, uri),
|
||||
"/static.php/skins/elastic/images/favicon.ico").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CONTENT_TYPE], "text/css");
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "PHP-STATIC-OK");
|
||||
let params = received.await.unwrap();
|
||||
assert_eq!(params["SCRIPT_FILENAME"].as_str(), tmp.path().join("static.php").to_string_lossy().as_ref());
|
||||
assert_eq!(params["SCRIPT_NAME"], "/static.php");
|
||||
assert_eq!(params["PATH_INFO"], "/skins/elastic/images/favicon.ico");
|
||||
assert_eq!(params["QUERY_STRING"], "s=1783249197");
|
||||
assert_eq!(params["REQUEST_URI"], uri);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_php_path_info_try_files_keeps_legacy_front_controller_fallback() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("static.php"), b"<?php // static asset handler").unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php // front controller").unwrap();
|
||||
let (address, received) = fastcgi_fixture_record_params().await;
|
||||
let mut configured = Service {
|
||||
id: "roundcube-no-path-info".into(),
|
||||
name: "Roundcube without PATH_INFO".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
try_files: vec!["$uri".into(), "/index.php?$query_string".into()],
|
||||
php: Php {
|
||||
enabled: true,
|
||||
path_info: false,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let uri = "/static.php/plugins/zipdownload/zipdownload.min.js?s=1783249197";
|
||||
let response = serve_request(&service, &request(Method::GET, uri),
|
||||
"/static.php/plugins/zipdownload/zipdownload.min.js").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let _ = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let params = received.await.unwrap();
|
||||
assert_eq!(params["SCRIPT_FILENAME"].as_str(), tmp.path().join("index.php").to_string_lossy().as_ref());
|
||||
assert_eq!(params["SCRIPT_NAME"], "/index.php");
|
||||
assert!(!params.contains_key("PATH_INFO"));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user