mod cache_fast_path_tests { use super::*; use crate::{auth::Auth, config::{self, Config, Update, ValidationPolicy}, db::Store}; use std::net::SocketAddr; use tempfile::TempDir; use tower::ServiceExt; const CSS: &[u8] = b"/* origin comment */\nbody { color: red; margin: 0px; }\n"; struct Fixture { app: Arc, _directory: TempDir } struct Received { status: StatusCode, headers: HeaderMap, body: Bytes } impl Fixture { async fn new(config: Config) -> Self { let directory = TempDir::new().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(&directory.path().join("db"), &config).await.unwrap(); let auth = Auth::new(store.clone(), directory.path().to_owned()).await.unwrap(); let snapshot = config::compile(config, 0, &policy).unwrap(); let app = App::new(store, snapshot, policy, auth, 32, Duration::from_secs(5), Duration::from_secs(5), Duration::from_secs(30)); Self { app, _directory: directory } } async fn send(&self, method: Method, path: &str, headers: &[(&str, &str)], body: Body) -> Received { send(self.app.clone(), method, path, headers, body).await } async fn get(&self, path: &str, headers: &[(&str, &str)]) -> Received { self.send(Method::GET, path, headers, Body::empty()).await } async fn change(&self, edit: impl FnOnce(&mut Config)) { let current = self.app.config.load_full(); let mut config = current.envelope.config.clone(); edit(&mut config); self.app.update(Update { expected_revision: current.envelope.revision, config }).await.unwrap(); } async fn stop(self) { self.app.shutdown.cancel(); self.app.tunnels.close(); self.app.tunnels.wait().await; self.app.store.close().await; } } async fn send(app: Arc, method: Method, path: &str, headers: &[(&str, &str)], body: Body) -> Received { let mut builder = Request::builder().method(method).uri(path) .header(HOST, "localhost").header(USER_AGENT, "cache-plan-regression/1.0"); for (name, value) in headers { builder = builder.header(*name, *value); } let mut request = builder.body(body).unwrap(); request.extensions_mut().insert(PeerAddr("192.0.2.50:45678".parse().unwrap())); request.extensions_mut().insert(LocalAddr("127.0.0.1:8080".parse().unwrap())); let response = router(app).oneshot(request).await.unwrap(); let (parts, body) = response.into_parts(); let body = body.collect().await.unwrap().to_bytes(); Received { status: parts.status, headers: parts.headers, body } } fn config_for(origin: SocketAddr, enabled: bool) -> Config { let mut config = config::demo_config(origin); config.cache.enabled = enabled; config.cache.expose_status = true; config.cache.expose_hits = true; config.cache.expose_cache_status = true; config.cache.expose_only_hits = false; config.cache.static_ignore_cookies = false; config } fn web_config(directory: &TempDir, enabled: bool) -> Config { std::fs::write(directory.path().join("style.css"), CSS).unwrap(); let mut config = config_for("127.0.0.1:9000".parse().unwrap(), enabled); config.web_services.push(crate::web_server::Service { id: "site".into(), name: "Site".into(), document_root: directory.path().to_owned(), cache_control: Some("public, max-age=600".into()), ..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(); config } #[tokio::test] async fn content_type_policy_denies_before_local_web_server_and_cache_and_updates_live() { let directory = TempDir::new().unwrap(); let mut config = web_config(&directory, true); config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy { enabled: true, blocked: vec!["multipart/form-data".into(), "image/*".into()], }; let fixture = Fixture::new(config).await; // Warm the cache before testing a header-denied cache hit. assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK); assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK); assert_eq!(fixture.get("/style.css", &[("content-type", "multipart/form-data; boundary=example")]).await.status, StatusCode::FORBIDDEN); assert_eq!(fixture.get("/style.css", &[("content-type", "IMAGE/PNG")]).await.status, StatusCode::FORBIDDEN); assert_eq!(fixture.send(Method::POST, "/style.css", &[("content-type", "multipart/form-data; boundary=x")], Body::from("sample")).await.status, StatusCode::FORBIDDEN); assert_eq!(fixture.get("/style.css", &[("content-type", "text/plain")]).await.status, StatusCode::OK); assert_eq!(fixture.get("/style.css", &[("content-type", "application/json, multipart/form-data")]).await.status, StatusCode::FORBIDDEN); fixture.change(|config| config.vhosts[0].content_type_policy.enabled = false).await; assert_eq!(fixture.get("/style.css", &[("content-type", "multipart/form-data")]).await.status, StatusCode::OK); fixture.stop().await; } #[tokio::test] async fn content_type_policy_denies_before_reverse_proxy_upstream_request() { let origin = origin().await; let mut config = config_for(origin.addr, false); config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy { enabled: true, blocked: vec!["multipart/form-data".into()], }; let fixture = Fixture::new(config).await; let blocked = fixture.send(Method::POST, "/", &[("content-type", "Multipart/Form-Data; boundary=abc")], Body::from("--abc\r\n")).await; assert_eq!(blocked.status, StatusCode::FORBIDDEN); assert_eq!(origin.requests.load(Ordering::Relaxed), 0); let allowed = fixture.get("/", &[("content-type", "application/json")]).await; assert_eq!(allowed.status, StatusCode::OK); assert_eq!(origin.requests.load(Ordering::Relaxed), 1); fixture.stop().await; } #[tokio::test] async fn web_cache_disabled_keeps_get_head_range_conditionals_and_observability() { let directory = TempDir::new().unwrap(); let fixture = Fixture::new(web_config(&directory, false)).await; let first = fixture.get("/style.css", &[]).await; assert_eq!(first.status, StatusCode::OK); assert_eq!(first.body, CSS); assert!(!first.headers.contains_key("x-cache")); let etag = first.headers["etag"].to_str().unwrap(); let head = fixture.send(Method::HEAD, "/style.css", &[], Body::empty()).await; assert_eq!(head.status, StatusCode::OK); assert_eq!(head.headers["content-length"], CSS.len().to_string()); assert!(head.body.is_empty()); let partial = fixture.get("/style.css", &[("range", "bytes=0-3"), ("if-range", etag)]).await; assert_eq!(partial.status, StatusCode::PARTIAL_CONTENT); assert_eq!(partial.body, &CSS[..4]); let mismatch = fixture.get("/style.css", &[("range", "bytes=0-3"), ("if-range", "\"different\"")]).await; assert_eq!(mismatch.status, StatusCode::OK); assert_eq!(mismatch.body, CSS); let conditional = fixture.get("/style.css", &[("if-none-match", etag)]).await; assert_eq!(conditional.status, StatusCode::NOT_MODIFIED); assert!(conditional.body.is_empty()); let invalid = fixture.get("/style.css", &[("range", "bytes=99999-")]).await; assert_eq!(invalid.status, StatusCode::RANGE_NOT_SATISFIABLE); assert_eq!(invalid.headers["content-range"], format!("bytes */{}", CSS.len())); let only_cached = fixture.get("/style.css", &[("cache-control", "only-if-cached")]).await; assert_eq!(only_cached.body, CSS); let logs = fixture.app.logs.recent(); assert_eq!(logs.len(), 7); assert!(logs.iter().all(|entry| entry.cache_reason == "cache_disabled")); assert!(logs.iter().all(|entry| entry.request_id.len() == 64)); assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 7); assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0); assert_eq!(fixture.app.logs.stats().queue_dropped, 0); fixture.stop().await; } #[tokio::test] async fn web_cache_hot_toggles_keep_miss_hit_and_bypass_diagnostics() { let directory = TempDir::new().unwrap(); let fixture = Fixture::new(web_config(&directory, false)).await; assert!(!fixture.get("/style.css", &[]).await.headers.contains_key("x-cache")); fixture.change(|config| config.cache.enabled = true).await; assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS"); let hit = fixture.get("/style.css", &[]).await; assert_eq!(hit.headers["x-cache"], "HIT"); assert_eq!(hit.body, CSS); fixture.change(|config| config.vhosts[0].routes.push(crate::vhost::PathRoute { id: "uncached".into(), path_prefix: "/style.css".into(), cache: Some(false), source: crate::vhost::Source::WebServer { web_service_id: "site".into() }, ..Default::default() })).await; let bypass = fixture.get("/style.css", &[]).await; assert_eq!(bypass.headers["x-cache"], "BYPASS"); assert_eq!(bypass.headers["x-cache-reason"], "cache_disabled"); fixture.change(|config| { config.vhosts[0].routes.clear(); config.vhosts[0].cache.enabled = Some(false); }).await; assert!(!fixture.get("/style.css", &[]).await.headers.contains_key("x-cache")); fixture.change(|config| config.vhosts[0].cache.enabled = None).await; assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS"); assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "HIT"); fixture.stop().await; } #[tokio::test] async fn web_transform_remains_independent_of_disabled_cache_and_range() { let directory = TempDir::new().unwrap(); let mut config = web_config(&directory, false); config.transform.enabled = true; config.vhosts[0].transform.enabled = true; let fixture = Fixture::new(config).await; let normal = fixture.get("/style.css", &[]).await; assert_eq!(normal.status, StatusCode::OK); assert!(normal.body.len() < CSS.len(), "transform must remain active with cache off"); let range = fixture.get("/style.css", &[("range", "bytes=0-3"), ("accept-encoding", "gzip")]).await; assert_eq!(range.status, StatusCode::PARTIAL_CONTENT); assert_eq!(range.body, &CSS[..4]); assert!(!range.headers.contains_key("content-encoding")); fixture.change(|config| config.vhosts[0].transform.enabled = false).await; assert_eq!(fixture.get("/style.css", &[]).await.body, CSS); fixture.stop().await; } #[tokio::test] async fn cache_fast_paths_keep_global_and_location_access_control() { let directory = TempDir::new().unwrap(); for enabled in [false, true] { let mut config = web_config(&directory, enabled); let deny = crate::acl::Acl { default: crate::acl::Action::Deny, rules: vec![] }; config.host_policies.push(config::HostPolicy { hosts: vec!["localhost".into()], acl: deny.clone() }); let fixture = Fixture::new(config).await; assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::FORBIDDEN); fixture.change(|config| { config.host_policies.clear(); config.vhosts[0].routes.push(crate::vhost::PathRoute { id: "private".into(), path_prefix: "/style.css".into(), acl: Some(deny), source: crate::vhost::Source::WebServer { web_service_id: "site".into() }, ..Default::default() }); }).await; assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::FORBIDDEN); fixture.stop().await; } } struct Origin { addr: SocketAddr, requests: Arc, task: tokio::task::JoinHandle<()> } impl Drop for Origin { fn drop(&mut self) { self.task.abort(); } } async fn origin() -> Origin { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let requests = Arc::new(AtomicU64::new(0)); let count = requests.clone(); let app = Router::new().fallback(move |request: Request| { let count = count.clone(); async move { count.fetch_add(1, Ordering::Relaxed); let (parts, body) = request.into_parts(); if parts.uri.path() == "/slow" { tokio::time::sleep(Duration::from_millis(25)).await; } let input = body.collect().await.unwrap().to_bytes(); let variant = parts.headers.get("x-variant").and_then(|v| v.to_str().ok()).unwrap_or("default"); let output = if parts.method == Method::POST { input } else { Bytes::from(format!("origin-{variant}")) }; let mut response = Response::new(Body::from(output.clone())); response.headers_mut().insert("etag", HeaderValue::from_static("\"origin-v1\"")); response.headers_mut().insert("cache-control", HeaderValue::from_static( if parts.uri.path() == "/private" { "private, max-age=600" } else { "public, max-age=600" })); response.headers_mut().insert("vary", HeaderValue::from_static("X-Variant")); response.headers_mut().insert("content-type", HeaderValue::from_static("text/plain")); for name in ["range", "if-range", "if-none-match", "cookie", "authorization", "x-request-id"] { if let Some(value) = parts.headers.get(name) { let echoed: http::HeaderName = format!("x-seen-{name}").parse().unwrap(); response.headers_mut().insert(echoed, value.clone()); } } if parts.headers.get("if-none-match").is_some_and(|v| v == "\"origin-v1\"") { *response.status_mut() = StatusCode::NOT_MODIFIED; *response.body_mut() = Body::empty(); } else if parts.headers.get("range").is_some_and(|v| v == "bytes=0-2") && parts.headers.get("if-range").is_none_or(|v| v == "\"origin-v1\"") { *response.status_mut() = StatusCode::PARTIAL_CONTENT; response.headers_mut().insert("content-range", format!("bytes 0-2/{}", output.len()).parse().unwrap()); *response.body_mut() = Body::from(output.slice(..3)); } response } }); let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); Origin { addr, requests, task } } #[tokio::test] async fn proxy_disabled_cache_preserves_headers_post_and_conditionals() { let origin = origin().await; let fixture = Fixture::new(config_for(origin.addr, false)).await; let range = fixture.get("/inspect", &[("range", "bytes=0-2"), ("if-range", "\"origin-v1\""), ("cookie", "session=one"), ("authorization", "Bearer example")]).await; assert_eq!(range.status, StatusCode::PARTIAL_CONTENT); assert_eq!(range.body, "ori"); assert_eq!(range.headers["x-seen-range"], "bytes=0-2"); assert_eq!(range.headers["x-seen-if-range"], "\"origin-v1\""); assert_eq!(range.headers["x-seen-cookie"], "session=one"); assert_eq!(range.headers["x-seen-authorization"], "Bearer example"); assert_eq!(range.headers["x-seen-x-request-id"].as_bytes().len(), 64); assert!(!range.headers.contains_key("x-cache")); let conditional = fixture.get("/inspect", &[("if-none-match", "\"origin-v1\"")]).await; assert_eq!(conditional.status, StatusCode::NOT_MODIFIED); assert!(conditional.body.is_empty()); let input = futures_util::stream::iter([ Ok::<_, std::io::Error>(Bytes::from_static(b"streamed ")), Ok(Bytes::from_static(b"content")), ]); let post = fixture.send(Method::POST, "/echo", &[], Body::from_stream(input)).await; assert_eq!(post.status, StatusCode::OK); assert_eq!(post.body, "streamed content"); assert_eq!(origin.requests.load(Ordering::Relaxed), 3); assert!(fixture.app.logs.recent().iter().all(|entry| entry.cache_reason == "cache_disabled")); fixture.stop().await; } #[tokio::test] async fn proxy_cache_on_preserves_vary_private_revalidation_and_ranges() { let origin = origin().await; let fixture = Fixture::new(config_for(origin.addr, true)).await; for variant in ["a", "b"] { assert_eq!(fixture.get("/vary", &[("x-variant", variant)]).await.headers["x-cache"], "MISS"); } let hit = fixture.get("/vary", &[("x-variant", "a")]).await; assert_eq!(hit.headers["x-cache"], "HIT"); assert_eq!(hit.body, "origin-a"); let range = fixture.get("/vary", &[("x-variant", "a"), ("range", "bytes=0-2"), ("if-range", "\"origin-v1\"")]).await; assert_eq!(range.status, StatusCode::PARTIAL_CONTENT); assert_eq!(range.headers["x-cache"], "HIT"); assert_eq!(range.body, "ori"); let validated = fixture.get("/vary", &[("x-variant", "a"), ("cache-control", "no-cache")]).await; assert_eq!(validated.status, StatusCode::OK); assert_eq!(validated.headers["x-cache"], "HIT"); assert_eq!(validated.headers["x-cache-reason"], "cache_revalidated"); assert_eq!(validated.body, "origin-a"); let conditional = fixture.get("/vary", &[("x-variant", "a"), ("if-none-match", "\"origin-v1\"")]).await; assert_eq!(conditional.status, StatusCode::NOT_MODIFIED); let bypass = fixture.get("/vary", &[("authorization", "Bearer example")]).await; assert_eq!(bypass.headers["x-cache-reason"], "request_authorization"); for _ in 0..2 { assert_eq!(fixture.get("/private", &[]).await.headers["x-cache"], "MISS"); } assert_eq!(origin.requests.load(Ordering::Relaxed), 6); fixture.stop().await; } #[tokio::test] async fn proxy_cache_enabled_concurrent_fill_stays_single_flight() { let origin = origin().await; let fixture = Fixture::new(config_for(origin.addr, true)).await; let mut requests = tokio::task::JoinSet::new(); for _ in 0..12 { let app = fixture.app.clone(); requests.spawn(async move { send(app, Method::GET, "/slow", &[], Body::empty()).await }); } while let Some(result) = requests.join_next().await { let response = result.unwrap(); assert_eq!(response.status, StatusCode::OK); assert_eq!(response.body, "origin-default"); } assert_eq!(origin.requests.load(Ordering::Relaxed), 1); assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 12); assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0); fixture.stop().await; } }