diff --git a/src/admin/tests/part_01.rs b/src/admin/tests/part_01.rs deleted file mode 100644 index 0527e53..0000000 --- a/src/admin/tests/part_01.rs +++ /dev/null @@ -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; - } diff --git a/src/admin/tests/part_02.rs b/src/admin/tests/part_02.rs deleted file mode 100644 index 3d6e3a7..0000000 --- a/src/admin/tests/part_02.rs +++ /dev/null @@ -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; - } diff --git a/src/admin/tests/part_03.rs b/src/admin/tests/part_03.rs deleted file mode 100644 index 43f1040..0000000 --- a/src/admin/tests/part_03.rs +++ /dev/null @@ -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; - } diff --git a/src/admin/tests/part_04.rs b/src/admin/tests/part_04.rs deleted file mode 100644 index 09e4e2b..0000000 --- a/src/admin/tests/part_04.rs +++ /dev/null @@ -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; - } - - - - diff --git a/src/admin/tests/part_05.rs b/src/admin/tests/part_05.rs deleted file mode 100644 index 17155a8..0000000 --- a/src/admin/tests/part_05.rs +++ /dev/null @@ -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; - } diff --git a/src/admin/tests/part_06.rs b/src/admin/tests/part_06.rs deleted file mode 100644 index bd742d2..0000000 --- a/src/admin/tests/part_06.rs +++ /dev/null @@ -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::>(); - 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()); -} diff --git a/src/admin/tests/part_07.rs b/src/admin/tests/part_07.rs deleted file mode 100644 index 7615e37..0000000 --- a/src/admin/tests/part_07.rs +++ /dev/null @@ -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 = 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; - } diff --git a/src/admin/tests/part_08.rs b/src/admin/tests/part_08.rs deleted file mode 100644 index 3b5d426..0000000 --- a/src/admin/tests/part_08.rs +++ /dev/null @@ -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()); - } diff --git a/src/admin/tests/part_09.rs b/src/admin/tests/part_09.rs deleted file mode 100644 index 3b82186..0000000 --- a/src/admin/tests/part_09.rs +++ /dev/null @@ -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::(r#"{"unexpected":1}"#).is_err()); - } diff --git a/src/admin/tests/part_10.rs b/src/admin/tests/part_10.rs deleted file mode 100644 index 77fea4b..0000000 --- a/src/admin/tests/part_10.rs +++ /dev/null @@ -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; - } diff --git a/src/admin/tests/part_11.rs b/src/admin/tests/part_11.rs deleted file mode 100644 index 03e5d6c..0000000 --- a/src/admin/tests/part_11.rs +++ /dev/null @@ -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; - } diff --git a/src/balance/tests/part_01.rs b/src/balance/tests/part_01.rs deleted file mode 100644 index 36c1dbd..0000000 --- a/src/balance/tests/part_01.rs +++ /dev/null @@ -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 { - 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 { - 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 - ); - } - } -} diff --git a/src/balance/tests/part_02.rs b/src/balance/tests/part_02.rs deleted file mode 100644 index 868a835..0000000 --- a/src/balance/tests/part_02.rs +++ /dev/null @@ -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" - ); -} diff --git a/src/cache/tests/cold_tier_tests/part_01.rs b/src/cache/tests/cold_tier_tests/part_01.rs deleted file mode 100644 index f621890..0000000 --- a/src/cache/tests/cold_tier_tests/part_01.rs +++ /dev/null @@ -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, 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::() - .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::() - .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(); -} diff --git a/src/cache/tests/cold_tier_tests/part_02.rs b/src/cache/tests/cold_tier_tests/part_02.rs deleted file mode 100644 index 68245d9..0000000 --- a/src/cache/tests/cold_tier_tests/part_02.rs +++ /dev/null @@ -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()); -} diff --git a/src/cache/tests/tests/part_01.rs b/src/cache/tests/tests/part_01.rs deleted file mode 100644 index c39a8ef..0000000 --- a/src/cache/tests/tests/part_01.rs +++ /dev/null @@ -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); - } diff --git a/src/cache/tests/tests/part_02.rs b/src/cache/tests/tests/part_02.rs deleted file mode 100644 index 550a9a5..0000000 --- a/src/cache/tests/tests/part_02.rs +++ /dev/null @@ -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"
x
"[..], - "html", - ), - ( - "/icon.svg", - "image/svg+xml", - &b""[..], - "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::() - .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); - } diff --git a/src/cache/tests/tests/part_03.rs b/src/cache/tests/tests/part_03.rs deleted file mode 100644 index 30223ea..0000000 --- a/src/cache/tests/tests/part_03.rs +++ /dev/null @@ -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, 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::() - .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); - } diff --git a/src/cache/tests/tests/part_04.rs b/src/cache/tests/tests/part_04.rs deleted file mode 100644 index bf01009..0000000 --- a/src/cache/tests/tests/part_04.rs +++ /dev/null @@ -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); - } - } diff --git a/src/cache/tests/v022_cache_tests/part_01.rs b/src/cache/tests/v022_cache_tests/part_01.rs deleted file mode 100644 index af1ea4e..0000000 --- a/src/cache/tests/v022_cache_tests/part_01.rs +++ /dev/null @@ -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, - 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); - } diff --git a/src/cache/tests/v022_cache_tests/part_02.rs b/src/cache/tests/v022_cache_tests/part_02.rs deleted file mode 100644 index 76b9b65..0000000 --- a/src/cache/tests/v022_cache_tests/part_02.rs +++ /dev/null @@ -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"); - } diff --git a/src/config/tests/part_01.rs b/src/config/tests/part_01.rs deleted file mode 100644 index db61fea..0000000 --- a/src/config/tests/part_01.rs +++ /dev/null @@ -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); -} - diff --git a/src/config/tests/part_02.rs b/src/config/tests/part_02.rs deleted file mode 100644 index bafb06c..0000000 --- a/src/config/tests/part_02.rs +++ /dev/null @@ -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":"

Not found

"} - }])) - .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" - ); -} diff --git a/src/config/tests/part_03.rs b/src/config/tests/part_03.rs deleted file mode 100644 index 0350e3c..0000000 --- a/src/config/tests/part_03.rs +++ /dev/null @@ -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" 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::>().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"); -} diff --git a/src/web_server/tests/part_02.rs b/src/web_server/tests/part_02.rs deleted file mode 100644 index 583fbdb..0000000 --- a/src/web_server/tests/part_02.rs +++ /dev/null @@ -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"{}", 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#" -RewriteEngine On -RewriteCond %{REQUEST_FILENAME} !-f -RewriteCond %{REQUEST_FILENAME} !-d -RewriteRule . /index.html [L] - -"#, - ) - .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" -SetHandler application/x-httpd-php - -"#, - ) - .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")); -} diff --git a/src/web_server/tests/part_03.rs b/src/web_server/tests/part_03.rs deleted file mode 100644 index fc085b7..0000000 --- a/src/web_server/tests/part_03.rs +++ /dev/null @@ -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")); -} diff --git a/src/web_server/tests/part_04.rs b/src/web_server/tests/part_04.rs deleted file mode 100644 index 3fb46bf..0000000 --- a/src/web_server/tests/part_04.rs +++ /dev/null @@ -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 { - 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>, -) { - 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"