diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f6de4bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: fmt + run: cargo fmt --all -- --check + + - name: clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: test + run: cargo test --all diff --git a/Cargo.toml b/Cargo.toml index a2a6991..1b7252a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ blake3 = "1" sled = "0.34" bincode = "1" +[dev-dependencies] +tempfile = "3" + [profile.release] lto = true codegen-units = 1 diff --git a/src/cache.rs b/src/cache.rs index 9c8647b..229eaf7 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -61,7 +61,9 @@ pub async fn handle_cached( http::HeaderValue::from_static("shopware=ESI/1.0"), ); - let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap_or_default(); + let body_bytes = axum::body::to_bytes(body, usize::MAX) + .await + .unwrap_or_default(); let up = state .client @@ -74,14 +76,22 @@ pub async fn handle_cached( let status = up.status(); let mut resp_headers = up.headers().clone(); - let bytes = up.bytes().await.map_err(|e| format!("upstream body: {e}"))?; + let bytes = up + .bytes() + .await + .map_err(|e| format!("upstream body: {e}"))?; // Decide TTL let ttl = ttl_from_headers(&resp_headers).unwrap_or(Duration::from_secs(0)); - let cacheable = ttl.as_secs() > 0 && (parts.method == http::Method::GET || parts.method == http::Method::HEAD); + let cacheable = ttl.as_secs() > 0 + && (parts.method == http::Method::GET || parts.method == http::Method::HEAD); // VCL: sw-dynamic-cache-bypass => hit-for-miss 1s - if resp_headers.get("sw-dynamic-cache-bypass").and_then(|v| v.to_str().ok()) == Some("1") { + if resp_headers + .get("sw-dynamic-cache-bypass") + .and_then(|v| v.to_str().ok()) + == Some("1") + { resp_headers.remove("sw-dynamic-cache-bypass"); return Ok(build_response(status, resp_headers, bytes, &norm_uri)); } @@ -99,7 +109,10 @@ pub async fn handle_cached( fn build_cache_key(uri: &Uri, headers: &HeaderMap) -> String { let mut key = uri.to_string(); - let ctx = headers.get("sw-cache-hash").and_then(|v| v.to_str().ok()).unwrap_or(""); + let ctx = headers + .get("sw-cache-hash") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); let cur = extract_cookie(headers, "sw-currency").unwrap_or_default(); if !ctx.is_empty() { @@ -126,7 +139,12 @@ fn extract_cookie(headers: &HeaderMap, name: &str) -> Option { None } -fn lookup(cache: &Cache, key: &str, req_headers: &HeaderMap, uri: &Uri) -> Result, String> { +fn lookup( + cache: &Cache, + key: &str, + req_headers: &HeaderMap, + uri: &Uri, +) -> Result, String> { let inner = cache.inner.read(); let Some((meta, body)) = inner.disk.get(key)? else { return Ok(None); @@ -142,7 +160,10 @@ fn lookup(cache: &Cache, key: &str, req_headers: &HeaderMap, uri: &Uri) -> Resul } // VCL hit logic: pass if client states matches invalidation states - if let (Some(req_states), Some(obj_states)) = (extract_cookie(req_headers, "sw-states"), meta.invalidation_states.as_deref()) { + if let (Some(req_states), Some(obj_states)) = ( + extract_cookie(req_headers, "sw-states"), + meta.invalidation_states.as_deref(), + ) { if req_states.contains("logged-in") && obj_states.contains("logged-in") { return Ok(None); } @@ -179,7 +200,11 @@ fn store( let tags = headers .get("xkey") .and_then(|v| v.to_str().ok()) - .map(|s| s.split_whitespace().map(|t| t.to_string()).collect::>()) + .map(|s| { + s.split_whitespace() + .map(|t| t.to_string()) + .collect::>() + }) .unwrap_or_default(); let invalidation_states = headers @@ -215,7 +240,12 @@ fn ttl_from_headers(headers: &HeaderMap) -> Option { None } -fn build_response(status: http::StatusCode, mut headers: HeaderMap, bytes: Bytes, uri: &Uri) -> axum::response::Response { +fn build_response( + status: http::StatusCode, + mut headers: HeaderMap, + bytes: Bytes, + uri: &Uri, +) -> axum::response::Response { normalize::apply_client_cache_policy(uri, &mut headers); normalize::strip_internal_headers(&mut headers); @@ -226,3 +256,65 @@ fn build_response(status: http::StatusCode, mut headers: HeaderMap, bytes: Bytes *resp.headers_mut() = headers; resp } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_key_varies_by_context_hash_when_present() { + let uri: Uri = "/foo?a=1".parse().unwrap(); + let mut h1 = HeaderMap::new(); + h1.insert("sw-cache-hash", http::HeaderValue::from_static("abc")); + h1.insert( + http::header::COOKIE, + http::HeaderValue::from_static("sw-currency=EUR"), + ); + + let mut h2 = HeaderMap::new(); + h2.insert("sw-cache-hash", http::HeaderValue::from_static("def")); + h2.insert( + http::header::COOKIE, + http::HeaderValue::from_static("sw-currency=EUR"), + ); + + let k1 = build_cache_key(&uri, &h1); + let k2 = build_cache_key(&uri, &h2); + assert_ne!(k1, k2); + } + + #[test] + fn cache_key_falls_back_to_currency_when_no_context_hash() { + let uri: Uri = "/foo?a=1".parse().unwrap(); + + let mut h1 = HeaderMap::new(); + h1.insert( + http::header::COOKIE, + http::HeaderValue::from_static("sw-currency=EUR"), + ); + + let mut h2 = HeaderMap::new(); + h2.insert( + http::header::COOKIE, + http::HeaderValue::from_static("sw-currency=USD"), + ); + + let k1 = build_cache_key(&uri, &h1); + let k2 = build_cache_key(&uri, &h2); + assert_ne!(k1, k2); + } + + #[test] + fn extract_cookie_parses_simple_cookie_header() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::COOKIE, + http::HeaderValue::from_static("a=1; sw-currency=EUR; b=2"), + ); + assert_eq!( + extract_cookie(&headers, "sw-currency").as_deref(), + Some("EUR") + ); + assert_eq!(extract_cookie(&headers, "missing"), None); + } +} diff --git a/src/config.rs b/src/config.rs index e4136bc..6565077 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,19 +12,31 @@ pub struct Config { impl Config { pub fn from_env() -> Result { - let listen = std::env::var("CODYCACHE_LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); - let origin = std::env::var("CODYCACHE_ORIGIN").map_err(|_| "CODYCACHE_ORIGIN is required".to_string())?; + let listen = + std::env::var("CODYCACHE_LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); + let origin = std::env::var("CODYCACHE_ORIGIN") + .map_err(|_| "CODYCACHE_ORIGIN is required".to_string())?; - let cache_dir = std::env::var("CODYCACHE_CACHE_DIR").unwrap_or_else(|_| "./cache".to_string()); + let cache_dir = + std::env::var("CODYCACHE_CACHE_DIR").unwrap_or_else(|_| "./cache".to_string()); - let purgers_raw = std::env::var("CODYCACHE_PURGERS").unwrap_or_else(|_| "127.0.0.1/32,::1/128".to_string()); + let purgers_raw = std::env::var("CODYCACHE_PURGERS") + .unwrap_or_else(|_| "127.0.0.1/32,::1/128".to_string()); let purgers = purgers_raw .split(',') .map(str::trim) .filter(|s| !s.is_empty()) - .map(|s| s.parse::().map_err(|e| format!("invalid CIDR/IP in CODYCACHE_PURGERS: {s}: {e}"))) + .map(|s| { + s.parse::() + .map_err(|e| format!("invalid CIDR/IP in CODYCACHE_PURGERS: {s}: {e}")) + }) .collect::, _>>()?; - Ok(Self { listen, origin, purgers, cache_dir }) + Ok(Self { + listen, + origin, + purgers, + cache_dir, + }) } } diff --git a/src/disk.rs b/src/disk.rs index 6b55cb4..d0a31cf 100644 --- a/src/disk.rs +++ b/src/disk.rs @@ -4,12 +4,11 @@ use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use std::{ fs, - io, path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}, }; -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct DiskStore { root: PathBuf, db: sled::Db, @@ -33,7 +32,11 @@ impl DiskStore { let root = root.as_ref().to_path_buf(); fs::create_dir_all(root.join("entries")).map_err(|e| format!("create cache dir: {e}"))?; let db = sled::open(root.join("index")).map_err(|e| format!("open sled: {e}"))?; - Ok(Self { root, db, lock: Mutex::new(()) }) + Ok(Self { + root, + db, + lock: Mutex::new(()), + }) } fn entry_dir(&self, key: &str) -> PathBuf { @@ -50,7 +53,8 @@ impl DiskStore { } let meta_bytes = fs::read(&meta_path).map_err(|e| format!("read meta: {e}"))?; - let meta: StoredMeta = serde_json::from_slice(&meta_bytes).map_err(|e| format!("parse meta: {e}"))?; + let meta: StoredMeta = + serde_json::from_slice(&meta_bytes).map_err(|e| format!("parse meta: {e}"))?; let body = fs::read(&body_path).map_err(|e| format!("read body: {e}"))?; Ok(Some((meta, Bytes::from(body)))) } @@ -76,7 +80,9 @@ impl DiskStore { .unwrap_or_default(); set.insert(key.to_string()); let enc = bincode::serialize(&set).map_err(|e| format!("bincode: {e}"))?; - self.db.insert(k.as_bytes(), enc).map_err(|e| format!("sled insert: {e}"))?; + self.db + .insert(k.as_bytes(), enc) + .map_err(|e| format!("sled insert: {e}"))?; } self.db.flush().map_err(|e| format!("sled flush: {e}"))?; @@ -96,13 +102,19 @@ impl DiskStore { for tag in meta.tags { let k = format!("tag:{tag}"); if let Some(v) = self.db.get(&k).map_err(|e| format!("sled get: {e}"))? { - let mut set: std::collections::BTreeSet = bincode::deserialize(&v).unwrap_or_default(); + let mut set: std::collections::BTreeSet = + bincode::deserialize(&v).unwrap_or_default(); set.remove(key); if set.is_empty() { - self.db.remove(k.as_bytes()).map_err(|e| format!("sled remove: {e}"))?; + self.db + .remove(k.as_bytes()) + .map_err(|e| format!("sled remove: {e}"))?; } else { - let enc = bincode::serialize(&set).map_err(|e| format!("bincode: {e}"))?; - self.db.insert(k.as_bytes(), enc).map_err(|e| format!("sled insert: {e}"))?; + let enc = + bincode::serialize(&set).map_err(|e| format!("bincode: {e}"))?; + self.db + .insert(k.as_bytes(), enc) + .map_err(|e| format!("sled insert: {e}"))?; } } } @@ -121,7 +133,8 @@ impl DiskStore { for tag in tags { let k = format!("tag:{tag}"); if let Some(v) = self.db.get(&k).map_err(|e| format!("sled get: {e}"))? { - let set: std::collections::BTreeSet = bincode::deserialize(&v).unwrap_or_default(); + let set: std::collections::BTreeSet = + bincode::deserialize(&v).unwrap_or_default(); keys.extend(set); } } @@ -147,7 +160,10 @@ pub fn headers_to_pairs(headers: &HeaderMap) -> Vec<(String, String)> { pub fn pairs_to_headers(pairs: &[(String, String)]) -> HeaderMap { let mut out = HeaderMap::new(); for (k, v) in pairs { - if let (Ok(name), Ok(val)) = (http::header::HeaderName::from_bytes(k.as_bytes()), http::HeaderValue::from_str(v)) { + if let (Ok(name), Ok(val)) = ( + http::header::HeaderName::from_bytes(k.as_bytes()), + http::HeaderValue::from_str(v), + ) { out.insert(name, val); } } @@ -155,7 +171,10 @@ pub fn pairs_to_headers(pairs: &[(String, String)]) -> HeaderMap { } pub fn now_ms() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::from_secs(0)).as_millis() as u64 + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::from_secs(0)) + .as_millis() as u64 } fn remove_dir_all_best_effort(path: &Path) { diff --git a/src/main.rs b/src/main.rs index 621aaef..72deaf1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,14 +26,21 @@ async fn main() { let state = AppState { cfg, cache, client }; - let app = Router::new().route("/*path", any(handle)).with_state(state.clone()); + let app = Router::new() + .route("/*path", any(handle)) + .with_state(state.clone()); - let listener = tokio::net::TcpListener::bind(&state.cfg.listen).await.expect("bind"); + let listener = tokio::net::TcpListener::bind(&state.cfg.listen) + .await + .expect("bind"); info!(listen = %state.cfg.listen, origin = %state.cfg.origin, cache_dir = %state.cfg.cache_dir, "CodyCache listening"); - axum::serve(listener, app.into_make_service_with_connect_info::()) - .await - .expect("serve"); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .expect("serve"); } async fn handle( @@ -45,7 +52,9 @@ async fn handle( let uri = req.uri().clone(); // PURGE/BAN handling - if method == Method::from_bytes(b"PURGE").unwrap() || method == Method::from_bytes(b"BAN").unwrap() { + if method == Method::from_bytes(b"PURGE").unwrap() + || method == Method::from_bytes(b"BAN").unwrap() + { if !purge::is_purger_allowed(peer.ip(), &state.cfg.purgers) { return (StatusCode::FORBIDDEN, "Forbidden").into_response(); } @@ -73,10 +82,10 @@ async fn handle( } // Special-case widgets checkout info - if uri.path() == "/widgets/checkout/info" { - if normalize::should_short_circuit_widgets_checkout_info(&req) { - return (StatusCode::NO_CONTENT, "").into_response(); - } + if uri.path() == "/widgets/checkout/info" + && normalize::should_short_circuit_widgets_checkout_info(&req) + { + return (StatusCode::NO_CONTENT, "").into_response(); } match cache::handle_cached(state.clone(), peer, req).await { @@ -84,12 +93,24 @@ async fn handle( Err(e) => { warn!(error = %e, %uri, "cache handler error; proxying"); // fall back to a direct proxy using the original URI - proxy_only(state, peer, Request::builder().uri(uri).body(axum::body::Body::empty()).unwrap()).await + proxy_only( + state, + peer, + Request::builder() + .uri(uri) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await } } } -async fn proxy_only(state: AppState, peer: SocketAddr, req: Request) -> axum::response::Response { +async fn proxy_only( + state: AppState, + peer: SocketAddr, + req: Request, +) -> axum::response::Response { let mut headers = req.headers().clone(); normalize::apply_forwarded_for(&mut headers, peer.ip()); headers.insert( @@ -101,7 +122,9 @@ async fn proxy_only(state: AppState, peer: SocketAddr, req: Request bool { } pub fn apply_forwarded_for(headers: &mut HeaderMap, ip: std::net::IpAddr) { - let xff = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()).unwrap_or(""); + let xff = headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); let new_val = if xff.is_empty() { ip.to_string() } else { @@ -43,7 +46,10 @@ pub fn should_short_circuit_widgets_checkout_info(req: &http::Request short-circuit - let sw_cache_hash = headers.get("sw-cache-hash").and_then(|v| v.to_str().ok()).unwrap_or(""); + let sw_cache_hash = headers + .get("sw-cache-hash") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); if sw_cache_hash.is_empty() { // We might still have a cookie, but VCL sets header from cookie only in vcl_recv. // In our implementation, we treat missing header as "missing" to keep behavior explicit. @@ -52,7 +58,10 @@ pub fn should_short_circuit_widgets_checkout_info(req: &http::Request Uri { // drop tracking params listed in VCL regex let drop_prefixes = [ - "pk_campaign", "piwik_campaign", "pk_kwd", "piwik_kwd", "pk_keyword", - "pixelId", "kwid", "kw", "adid", "chl", "dv", "nk", "pa", "camid", - "adgid", "cx", "ie", "cof", "siteurl", - "_ga", "gclid", + "pk_campaign", + "piwik_campaign", + "pk_kwd", + "piwik_kwd", + "pk_keyword", + "pixelId", + "kwid", + "kw", + "adid", + "chl", + "dv", + "nk", + "pa", + "camid", + "adgid", + "cx", + "ie", + "cof", + "siteurl", + "_ga", + "gclid", ]; let mut pairs: Vec<(String, String)> = url @@ -94,7 +120,9 @@ pub fn normalize_uri(uri: &Uri) -> Uri { pairs.sort(); - url.query_pairs_mut().clear().extend_pairs(pairs.iter().map(|(k, v)| (&k[..], &v[..]))); + url.query_pairs_mut() + .clear() + .extend_pairs(pairs.iter().map(|(k, v)| (&k[..], &v[..]))); // Rebuild Uri let mut out = url.path().to_string(); @@ -119,7 +147,10 @@ pub fn strip_internal_headers(headers: &mut HeaderMap) { pub fn apply_client_cache_policy(uri: &Uri, headers: &mut HeaderMap) { let path = uri.path(); - let cache_control = headers.get(http::header::CACHE_CONTROL).and_then(|v| v.to_str().ok()).unwrap_or(""); + let cache_control = headers + .get(http::header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); // VCL: if not private and not assets/store-api => no-store let is_asset = path.starts_with("/theme/") @@ -131,10 +162,78 @@ pub fn apply_client_cache_policy(uri: &Uri, headers: &mut HeaderMap) { if !cache_control.contains("private") && !is_asset { headers.insert("pragma", HeaderValue::from_static("no-cache")); headers.insert("expires", HeaderValue::from_static("-1")); - headers.insert("cache-control", HeaderValue::from_static("no-store, no-cache, must-revalidate, max-age=0")); + headers.insert( + "cache-control", + HeaderValue::from_static("no-store, no-cache, must-revalidate, max-age=0"), + ); } } pub fn synth(code: StatusCode, body: &'static str) -> impl IntoResponse { (code, body) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_uri_drops_tracking_and_sorts_query() { + let uri: Uri = "/search?utm_source=x&_ga=1&b=2&a=1&gclid=zzz" + .parse() + .unwrap(); + let out = normalize_uri(&uri); + // utm_* / _ga / gclid are removed + assert_eq!(out.to_string(), "/search?a=1&b=2"); + } + + #[test] + fn pass_paths_match_shopware_rules() { + assert!(is_pass_path("/checkout")); + assert!(is_pass_path("/checkout/confirm")); + assert!(is_pass_path("/account")); + assert!(is_pass_path("/admin/api")); + assert!(is_pass_path("/api")); + assert!(is_pass_path("/api/foo")); + + assert!(!is_pass_path("/")); + assert!(!is_pass_path("/listing")); + assert!(!is_pass_path("/store-api/search")); + } + + #[test] + fn widgets_checkout_info_short_circuit_rules() { + // missing header => short circuit + let req = http::Request::builder() + .uri("/widgets/checkout/info") + .body(axum::body::Body::empty()) + .unwrap(); + assert!(should_short_circuit_widgets_checkout_info(&req)); + + // header present and no sw-states cookie => do not short circuit + let req = http::Request::builder() + .uri("/widgets/checkout/info") + .header("sw-cache-hash", "abc") + .body(axum::body::Body::empty()) + .unwrap(); + assert!(!should_short_circuit_widgets_checkout_info(&req)); + + // header present and sw-states exists but missing cart-filled => short circuit + let req = http::Request::builder() + .uri("/widgets/checkout/info") + .header("sw-cache-hash", "abc") + .header(http::header::COOKIE, "sw-states=logged-in") + .body(axum::body::Body::empty()) + .unwrap(); + assert!(should_short_circuit_widgets_checkout_info(&req)); + + // header present and sw-states includes cart-filled => do not short circuit + let req = http::Request::builder() + .uri("/widgets/checkout/info") + .header("sw-cache-hash", "abc") + .header(http::header::COOKIE, "sw-states=logged-in,cart-filled") + .body(axum::body::Body::empty()) + .unwrap(); + assert!(!should_short_circuit_widgets_checkout_info(&req)); + } +} diff --git a/src/purge.rs b/src/purge.rs index fa0ddce..4a2c0f0 100644 --- a/src/purge.rs +++ b/src/purge.rs @@ -7,7 +7,11 @@ pub fn is_purger_allowed(ip: std::net::IpAddr, purgers: &[IpNet]) -> bool { purgers.iter().any(|net| net.contains(&ip)) } -pub fn handle_purge(cache: std::sync::Arc, uri: &Uri, headers: &HeaderMap) -> (StatusCode, String) { +pub fn handle_purge( + cache: std::sync::Arc, + uri: &Uri, + headers: &HeaderMap, +) -> (StatusCode, String) { if let Some(xkey) = headers.get("xkey").and_then(|v| v.to_str().ok()) { let tags: Vec = xkey.split_whitespace().map(|s| s.to_string()).collect(); match cache.purge_tags(&tags) { @@ -17,11 +21,52 @@ pub fn handle_purge(cache: std::sync::Arc, uri: &Uri, headers: &HeaderMap } // TODO: implement URL->keys index (respect variants). For now: return 501 so it isn't misleading. - (StatusCode::NOT_IMPLEMENTED, format!("PURGE-by-URL not implemented (requested {})", uri.path())) + ( + StatusCode::NOT_IMPLEMENTED, + format!("PURGE-by-URL not implemented (requested {})", uri.path()), + ) } pub fn handle_ban(_cache: std::sync::Arc, uri: &Uri) -> (StatusCode, String) { // Varnish BAN is pattern-based. We can implement later with a ban list evaluated at lookup. // For now, return 501 so users know it's not implemented. - (StatusCode::NOT_IMPLEMENTED, format!("BAN not implemented (requested ban on URLs containing {})", uri.path())) + ( + StatusCode::NOT_IMPLEMENTED, + format!( + "BAN not implemented (requested ban on URLs containing {})", + uri.path() + ), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn purge_with_xkey_is_ok_even_if_no_objects_match() { + let dir = tempfile::tempdir().unwrap(); + let cache = std::sync::Arc::new(Cache::new(dir.path().to_str().unwrap()).unwrap()); + + let uri: Uri = "/foo".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("xkey", http::HeaderValue::from_static("a b c")); + + let (status, body) = handle_purge(cache, &uri, &headers); + assert_eq!(status, StatusCode::OK); + assert!(body.contains("Invalidated")); + } + + #[test] + fn purge_without_xkey_returns_not_implemented() { + let dir = tempfile::tempdir().unwrap(); + let cache = std::sync::Arc::new(Cache::new(dir.path().to_str().unwrap()).unwrap()); + + let uri: Uri = "/foo".parse().unwrap(); + let headers = HeaderMap::new(); + + let (status, body) = handle_purge(cache, &uri, &headers); + assert_eq!(status, StatusCode::NOT_IMPLEMENTED); + assert!(body.contains("PURGE-by-URL not implemented")); + } }