Files
RustProxy/src/cache/cache_regression_tests.rs
T
Claude d40c30d0e8 v0.35.1: performance, AGPL licensing and documentation overhaul
- Web Server reads static files inline; proxy middleware chain flattened
- About window with license summary and project links; AGPL-3.0-or-later
- Rewritten documentation: feature list, screenshots, performance guide
- Fix false spikes in per-Vhost request-rate charts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMsgSXoK4Bph2vU6fSBkAH
2026-11-05 18:29:56 +01:00

1825 lines
66 KiB
Rust

mod tests {
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
}
// Legacy policy; varnish-compatible behavior is tested separately.
fn settings() -> Settings {
Settings {
enabled: true,
varnish_compatible: false,
..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=invalid",
"max-age=60,max-age=90",
"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());
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());
}
#[tokio::test]
async fn zero_freshness_is_retained_but_never_served_as_an_unconditional_hit() {
for control in ["max-age=0", "public", "max-age=60,s-maxage=0"] {
let cache = Cache::new(1);
let settings = settings();
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::from("zero-freshness"));
*response.headers_mut() = headers(control);
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.expect("valid zero freshness is distinct from a no-store response");
assert_eq!(plan.ttl, 0, "{control}");
cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.view().entries, 1, "{control}");
assert!(
cache.get(&ticket).is_none(),
"zero-TTL {control} cannot be an unconditional HIT"
);
}
}
#[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);
}
#[tokio::test]
async fn every_enabled_transform_kind_can_fill_and_reuse_the_cached_representation() {
let cases: [(&str, &str, &[u8], &str); 5] = [
(
"/app.js",
"application/javascript",
&b"const value = 1;\n"[..],
"javascript",
),
(
"/app.css",
"text/css",
&b"body { color: red; }\n"[..],
"css",
),
(
"/page.html",
"text/html",
&b"<div><!--drop--><b>x</b></div>"[..],
"html",
),
(
"/icon.svg",
"image/svg+xml",
&b"<svg><!--drop--><path d=\"M0 0\"/></svg>"[..],
"svg",
),
(
"/data.json",
"application/json",
&br#"{ "a": 1 }"#[..],
"json",
),
];
for (path, content_type, origin_body, kind) in cases {
let cache = Cache::new(1);
let cache_settings = settings();
let request_headers = HeaderMap::new();
let ticket = cache
.ticket(1, "http", "example.test", "app", path, &request_headers)
.unwrap();
let mut response = Response::new(Body::from(origin_body));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
response
.headers_mut()
.insert("content-type", HeaderValue::from_str(content_type).unwrap());
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&origin_body.len().to_string()).unwrap(),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin\""));
let origin_plan = plan(
StatusCode::OK,
response.headers(),
&cache_settings,
Duration::ZERO,
)
.unwrap();
let mut transform_settings = crate::transform::Settings {
enabled: true,
javascript: false,
css: false,
html: false,
svg: false,
json: false,
..Default::default()
};
match kind {
"javascript" => transform_settings.javascript = true,
"css" => transform_settings.css = true,
"html" => transform_settings.html = true,
"svg" => transform_settings.svg = true,
"json" => transform_settings.json = true,
_ => unreachable!(),
}
let transformed = crate::transform::apply(response, path, &transform_settings, false)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&cache_settings,
Duration::ZERO,
)
.unwrap();
let expected_etag = transformed.headers()["etag"].clone();
let expected_length = transformed.headers()["content-length"].clone();
let wrapped = cache.wrap(
transformed,
ticket.clone(),
origin_plan.restrict(transformed_plan),
&cache_settings,
);
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert!(!first.is_empty(), "{kind}");
assert_ne!(
first.as_ref(),
origin_body,
"{kind} should change the chosen fixture"
);
let hit = cache
.get(&ticket)
.expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}");
assert_eq!(
hit.response.headers()["content-length"],
expected_length,
"{kind}"
);
let second = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(second, first, "{kind}");
}
}
#[test]
fn conditional_hit_uses_weak_etag_and_last_modified() {
let mut response = HeaderMap::new();
response.insert("etag", HeaderValue::from_static("W/\"abc\""));
response.insert(
"last-modified",
HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"),
);
let mut request = HeaderMap::new();
request.insert("if-none-match", HeaderValue::from_static("\"abc\""));
assert!(conditional_not_modified(&request, &response));
request.insert("if-none-match", HeaderValue::from_static("\"other\""));
assert!(!conditional_not_modified(&request, &response));
request.remove("if-none-match");
request.insert(
"if-modified-since",
HeaderValue::from_static("Tue, 27 Jan 2026 00:00:00 GMT"),
);
assert!(conditional_not_modified(&request, &response));
}
#[test]
fn query_strings_remain_distinct_and_bypass_query_is_only_a_request_policy() {
let cache = Cache::new(1);
let headers = HeaderMap::new();
let v1 = cache
.ticket(1, "http", "example.test", "app", "/file.css?v=1", &headers)
.unwrap();
let v2 = cache
.ticket(1, "http", "example.test", "app", "/file.css?v=2", &headers)
.unwrap();
let flag = cache
.ticket(1, "http", "example.test", "app", "/file.css?F", &headers)
.unwrap();
assert_ne!(v1.key, v2.key);
assert_ne!(v1.key, flag.key);
assert_ne!(v2.key, flag.key);
let mut cache_settings = settings();
assert_eq!(
request_policy(
&Method::GET,
&headers,
true,
"/file.css",
true,
&cache_settings,
),
RequestPolicy::Use
);
cache_settings.bypass_query = true;
assert_eq!(
request_policy(
&Method::GET,
&headers,
true,
"/file.css",
true,
&cache_settings,
),
RequestPolicy::Bypass("query_excluded")
);
}
#[tokio::test]
async fn transformed_client_conditionals_use_the_representation_etag_not_the_origin_etag() {
let transform_settings = crate::transform::Settings {
enabled: true,
json: true,
..Default::default()
};
let body = br#"{ "a": 1 }"#;
let mut response = Response::new(Body::from(Bytes::from_static(body)));
response
.headers_mut()
.insert("content-type", HeaderValue::from_static("application/json"));
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&body.len().to_string()).unwrap(),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin\""));
let response = crate::transform::apply(response, "/data.json", &transform_settings, false)
.await
.unwrap();
let representation_etag = response.headers()["etag"].clone();
assert_ne!(representation_etag, HeaderValue::from_static("\"origin\""));
let mut request = HeaderMap::new();
request.insert("if-none-match", representation_etag.clone());
assert!(conditional_not_modified(&request, response.headers()));
request.insert("if-none-match", HeaderValue::from_static("\"origin\""));
assert!(!conditional_not_modified(&request, response.headers()));
let validators = response
.extensions()
.get::<crate::transform::OriginValidators>()
.unwrap();
assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\"");
}
#[test]
fn cache_key_separates_frontend_scheme() {
let cache = Cache::new(1);
let headers = HeaderMap::new();
let http = cache
.ticket(1, "http", "example.test", "app", "/asset", &headers)
.unwrap();
let https = cache
.ticket(1, "https", "example.test", "app", "/asset", &headers)
.unwrap();
assert_ne!(http.key, https.key);
}
#[test]
fn cache_key_ignores_revalidation_headers() {
let cache = Cache::new(1);
let base = ticket(&cache, &HeaderMap::new());
for (name, value) in [
("cache-control", "no-cache"),
("pragma", "no-cache"),
("if-none-match", "\"origin\""),
("if-modified-since", "Mon, 26 Jan 2026 23:11:40 GMT"),
] {
let mut headers = HeaderMap::new();
headers.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(value).unwrap(),
);
assert_eq!(base.key, ticket(&cache, &headers).key, "{name}");
}
}
#[test]
fn cache_memory_defaults_are_bounded_and_sane() {
let s = Settings::default();
assert_eq!(s.memory_preset, None);
assert_eq!(s.cold_preset, None);
assert_eq!(s.max_bytes, 256 * 1024 * 1024);
assert_eq!(s.max_object_bytes, 16 * 1024 * 1024);
assert_eq!(s.transient_max_bytes, 64 * 1024 * 1024);
assert_eq!(s.cold_queue_max_bytes, 32 * 1024 * 1024);
assert_eq!(s.hot_eviction_max_objects, 64);
assert!(s.validate().is_ok());
assert!(Cache::fill_headroom_bytes(8 * 1024 * 1024) <= 128 * 1024);
assert!(Cache::fill_headroom_bytes(s.max_bytes) <= 1024 * 1024);
}
#[test]
fn cache_preset_intent_round_trips_with_configuration() {
let settings = Settings {
memory_preset: Some(MemoryPreset::Recommended),
cold_preset: Some(ColdPreset::Recommended),
..Default::default()
};
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 s = Settings {
transient_max_bytes: 128 * 1024,
cold_queue_max_bytes: 64 * 1024,
..Default::default()
};
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 s = Settings {
transient_max_bytes: 8 * 1024 * 1024,
cold_queue_max_bytes: 16 * 1024 * 1024,
..Default::default()
};
assert!(s.validate().is_err());
}
#[cfg(target_pointer_width = "64")]
#[test]
fn cache_limits_allow_explicit_multi_gibibyte_budgets() {
let mut s = Settings {
max_bytes: 4usize * 1024 * 1024 * 1024,
max_object_bytes: 2usize * 1024 * 1024 * 1024,
transient_max_bytes: 512usize * 1024 * 1024,
cold_queue_max_bytes: 256usize * 1024 * 1024,
..Default::default()
};
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);
}
#[tokio::test]
async fn vary_selects_variants_without_fragmenting_the_primary_hash() {
let cache = Cache::new(1);
let s = settings();
let mut en = HeaderMap::new();
en.insert("accept-language", "en".parse().unwrap());
let mut pl = en.clone();
pl.insert("accept-language", "pl".parse().unwrap());
let en_ticket = ticket(&cache, &en);
let pl_ticket = ticket(&cache, &pl);
assert_eq!(en_ticket.key, pl_ticket.key);
let mut h = headers("max-age=60");
h.insert("vary", HeaderValue::from_static("Accept-Language"));
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("hello"));
*response.headers_mut() = h.clone();
cache
.wrap(response, en_ticket.clone(), p, &s)
.into_body()
.collect()
.await
.unwrap();
assert!(cache.get(&en_ticket).is_some());
assert!(cache.get(&pl_ticket).is_none());
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("czesc"));
*response.headers_mut() = h;
cache
.wrap(response, pl_ticket.clone(), p, &s)
.into_body()
.collect()
.await
.unwrap();
let en_hit = cache.get(&en_ticket).expect("English Vary variant");
let pl_hit = cache.get(&pl_ticket).expect("Polish Vary variant");
assert_eq!(
en_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"hello")
);
assert_eq!(
pl_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"czesc")
);
let mut star = headers("max-age=60");
star.insert("vary", HeaderValue::from_static("*"));
assert!(plan(StatusCode::OK, &star, &s, Duration::ZERO).is_none());
for vary in [
"x-request-id",
"cache-control",
"pragma",
"if-none-match",
"if-modified-since",
] {
let mut h = headers("max-age=60");
h.insert("vary", vary.parse().unwrap());
assert!(
plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some(),
"valid Vary field must remain cacheable: {vary}"
);
}
}
#[tokio::test]
async fn canonical_identity_drops_accept_encoding_as_a_cache_variant() {
let cache = Cache::new(1);
let s = settings();
let mut gzip = HeaderMap::new();
gzip.insert("accept-encoding", HeaderValue::from_static("gzip"));
let mut brotli = HeaderMap::new();
brotli.insert("accept-encoding", HeaderValue::from_static("br"));
let gzip_ticket = ticket(&cache, &gzip);
let brotli_ticket = ticket(&cache, &brotli);
let mut origin_headers = headers("public, max-age=60");
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding"));
origin_headers.insert("content-length", HeaderValue::from_static("7"));
let origin_plan =
plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("content-length", HeaderValue::from_static("7"));
let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
.expect("transformed plan");
let cache_plan = origin_plan
.without_vary("accept-encoding")
.restrict(transformed_plan);
assert!(cache_plan.vary.is_empty());
let mut response = Response::new(Body::from("payload"));
*response.headers_mut() = transformed_headers;
cache
.wrap(response, gzip_ticket.clone(), cache_plan, &s)
.into_body()
.collect()
.await
.unwrap();
let hit = cache
.get(&brotli_ticket)
.expect("canonical identity must be shared across client encodings");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"payload")
);
}
#[tokio::test]
async fn canonical_identity_preserves_non_encoding_vary_dimensions() {
let cache = Cache::new(1);
let s = settings();
let mut light = HeaderMap::new();
light.insert("accept-encoding", HeaderValue::from_static("gzip"));
light.insert("x-theme", HeaderValue::from_static("light"));
let mut dark = HeaderMap::new();
dark.insert("accept-encoding", HeaderValue::from_static("br"));
dark.insert("x-theme", HeaderValue::from_static("dark"));
let mut origin_headers = headers("public, max-age=60");
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding, X-Theme"));
let origin_plan =
plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("vary", HeaderValue::from_static("X-Theme"));
let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
.expect("transformed plan");
let cache_plan = origin_plan
.without_vary("accept-encoding")
.restrict(transformed_plan);
assert_eq!(cache_plan.vary, vec!["x-theme".to_owned()]);
let mut response = Response::new(Body::from("light"));
*response.headers_mut() = transformed_headers;
cache
.wrap(response, ticket(&cache, &light), cache_plan, &s)
.into_body()
.collect()
.await
.unwrap();
assert!(cache.get(&ticket(&cache, &light)).is_some());
assert!(cache.get(&ticket(&cache, &dark)).is_none());
}
#[tokio::test]
async fn vary_schema_change_removes_incompatible_fallbacks() {
let cache = Cache::new(1);
let s = settings();
let mut en = HeaderMap::new();
en.insert("accept-language", HeaderValue::from_static("en"));
let mut pl = HeaderMap::new();
pl.insert("accept-language", HeaderValue::from_static("pl"));
let en_ticket = ticket(&cache, &en);
let pl_ticket = ticket(&cache, &pl);
fill(&cache, en_ticket.clone(), "shared-old", &s).await;
assert!(cache.get(&pl_ticket).is_some());
let mut varied_headers = headers("max-age=60");
varied_headers.insert("vary", HeaderValue::from_static("Accept-Language"));
let varied_plan = plan(StatusCode::OK, &varied_headers, &s, Duration::ZERO).unwrap();
let mut varied = Response::new(Body::from("english-new"));
*varied.headers_mut() = varied_headers;
cache
.wrap(varied, en_ticket.clone(), varied_plan, &s)
.into_body()
.collect()
.await
.unwrap();
assert!(cache.get(&en_ticket).is_some());
assert!(
cache.get(&pl_ticket).is_none(),
"an older no-Vary object must not shadow a newer Vary schema"
);
fill(&cache, pl_ticket.clone(), "shared-new", &s).await;
let hit = cache
.get(&en_ticket)
.expect("new no-Vary schema should replace old variants");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"shared-new")
);
}
async fn fill(cache: &Arc<Cache>, ticket: Ticket, data: &'static str, settings: &Settings) {
let h = headers("max-age=60");
let p = plan(StatusCode::OK, &h, settings, Duration::ZERO).unwrap();
let mut r = Response::new(Body::from(data));
*r.headers_mut() = h;
cache
.wrap(r, ticket, p, settings)
.into_body()
.collect()
.await
.unwrap();
}
#[tokio::test]
async fn max_stale_can_reuse_an_object_after_normal_ttl_expiry() {
let cache = Cache::new(1);
let s = settings();
let initial = ticket(&cache, &HeaderMap::new());
let response_headers = headers("public, max-age=1");
let p = plan(StatusCode::OK, &response_headers, &s, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("stale-payload"));
*response.headers_mut() = response_headers;
cache
.wrap(response, initial.clone(), p, &s)
.into_body()
.collect()
.await
.unwrap();
{
let mut store = cache.store.lock().unwrap();
let entry = store.entries.values_mut().next().expect("stored object");
entry.received = Instant::now() - Duration::from_secs(2);
}
assert!(
cache.get(&initial).is_none(),
"normal request must not receive stale data"
);
assert_eq!(
cache.view().entries,
1,
"freshness expiry must not physically evict the object"
);
let stale = ticket(&cache, &headers("max-stale"));
let hit = cache
.get(&stale)
.expect("explicit max-stale may reuse retained stale data");
assert!(hit.stale);
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"stale-payload")
);
}
#[tokio::test]
async fn response_no_cache_is_stored_only_for_validation() {
let cache = Cache::new(1);
let t = ticket(&cache, &HeaderMap::new());
let s = settings();
let mut h = headers("no-cache");
h.insert("etag", HeaderValue::from_static("\"origin\""));
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
assert!(p.requires_revalidation);
let mut response = Response::new(Body::from("payload"));
*response.headers_mut() = h;
cache
.wrap(response, t.clone(), p, &s)
.into_body()
.collect()
.await
.unwrap();
assert!(
cache.get(&t).is_none(),
"no-cache must not be reused without validation"
);
let candidate = cache.revalidation(&t).expect("stored validator/body");
assert!(!candidate.reusable_without_validation());
let mut validation = HeaderMap::new();
validation.insert("cache-control", HeaderValue::from_static("no-cache"));
validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation);
let hit = cache
.publish_revalidated(
t.clone(),
&candidate,
merged,
&validation,
&s,
Duration::ZERO,
)
.expect("validated body can be delivered");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"payload")
);
assert!(
cache.get(&t).is_none(),
"stored no-cache response still requires validation next time"
);
}
#[tokio::test]
async fn resident_age_survives_successful_revalidation() {
let cache = Cache::new(1);
let t = ticket(&cache, &HeaderMap::new());
let s = settings();
let mut h = headers("no-cache");
h.insert("etag", HeaderValue::from_static("\"origin\""));
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("payload"));
*response.headers_mut() = h;
cache
.wrap(response, t.clone(), p, &s)
.into_body()
.collect()
.await
.unwrap();
{
let mut store = cache.store.lock().unwrap();
let entry = store.entries.values_mut().next().expect("stored object");
entry.resident_age_base = Duration::from_secs(30);
entry.resident_since = Instant::now();
}
let candidate = cache.revalidation(&t).expect("revalidation candidate");
assert!(candidate.resident_age_at_lookup >= Duration::from_secs(30));
let mut validation = HeaderMap::new();
validation.insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation);
let hit = cache
.publish_revalidated(
t,
&candidate,
merged,
&validation,
&s,
Duration::from_secs(2),
)
.expect("validated object");
assert!(hit.resident_age_seconds >= 32);
}
#[tokio::test]
async fn complete_body_hit_age_and_purge() {
let cache = Cache::new(1);
let t = ticket(&cache, &HeaderMap::new());
assert!(cache.get(&t).is_none());
fill(&cache, t.clone(), "payload", &settings()).await;
let view = cache.view();
assert_eq!(view.hot_logical_bytes, b"payload".len());
assert_eq!(view.hot_allocated_bytes, view.resident_bytes);
assert!(view.hot_allocated_bytes > view.hot_logical_bytes);
assert!(view.hot_allocated_bytes <= view.hot_limit_bytes);
let hit = cache.get(&t).unwrap();
assert_eq!(hit.hits, 1);
assert!(hit.response.headers().contains_key("age"));
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"payload")
);
assert_eq!(cache.purge(None), 1);
assert!(cache.get(&t).is_none());
}
#[tokio::test]
async fn cache_hit_and_dashboard_age_advance_from_storage_clock() {
let cache = Cache::new(1);
let t = ticket(&cache, &HeaderMap::new());
fill(&cache, t.clone(), "payload", &settings()).await;
{
let mut store = cache.store.lock().unwrap_or_else(|e| e.into_inner());
let entry = store.entries.values_mut().next().expect("stored entry");
entry.received = Instant::now() - Duration::from_secs(5);
}
let hit = cache.get(&t).expect("fresh cached response");
assert!(
hit.response.headers()["age"]
.to_str()
.unwrap()
.parse::<u64>()
.unwrap()
>= 5
);
assert!(cache.dashboard(None, 0, 50).objects[0].age_seconds >= 5);
}
#[test]
fn defaults_enable_static_cookie_boost_for_common_assets() {
let settings = Settings::default();
assert!(settings.enabled);
assert!(settings.static_ignore_cookies);
for path in [
"/asset.jpg",
"/asset.jpeg",
"/asset.png",
"/app.css",
"/app.js",
"/font.woff2",
"/audio.mp3",
"/note.txt",
] {
assert!(settings.ignore_cookie_for_path(path), "{path}");
}
assert!(!settings.ignore_cookie_for_path("/account"));
assert!(!settings.ignore_cookie_for_path("/api/profile.json"));
}
#[test]
fn static_cookie_bypass_is_limited_to_configured_extensions() {
let mut settings = settings();
settings.static_ignore_cookies = true;
let mut h = HeaderMap::new();
h.insert("cookie", HeaderValue::from_static("session=secret"));
assert_eq!(
request_reason(&Method::GET, &h, true, "/asset.jpg", false, &settings),
None
);
assert_eq!(
request_reason(&Method::GET, &h, true, "/account", false, &settings),
Some("request_cookie")
);
}
#[test]
fn age_uses_corrected_received_age_plus_response_delay_without_rounding_up() {
let settings = settings();
let mut h = headers("max-age=120");
h.insert(
"date",
httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10))
.parse()
.unwrap(),
);
h.insert("age", HeaderValue::from_static("3"));
let age_plan = plan(StatusCode::OK, &h, &settings, Duration::from_millis(250)).unwrap();
assert!(age_plan.initial_age >= Duration::from_secs(10));
assert!(age_plan.initial_age < Duration::from_secs(12));
let fresh = plan(
StatusCode::OK,
&headers("max-age=60"),
&settings,
Duration::from_millis(250),
)
.unwrap();
assert_eq!(fresh.initial_age.as_secs(), 0);
}
#[test]
fn aged_upstream_freshness_caps_remaining_not_total_age() {
let settings = settings();
let mut headers = headers("public, max-age=2592000");
headers.insert(
"date",
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
);
headers.insert("age", HeaderValue::from_static("1553483"));
headers.insert("etag", HeaderValue::from_static("\"origin-etag\""));
let original = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap();
assert_eq!(original.initial_age.as_secs(), 1553483);
assert_eq!(original.remaining_ttl(), settings.max_ttl_seconds);
assert_eq!(original.ttl, 1553483 + settings.max_ttl_seconds);
headers.insert("age", HeaderValue::from_static("2591990"));
let almost_expired = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap();
assert_eq!(almost_expired.remaining_ttl(), 10);
headers.insert("age", HeaderValue::from_static("2592010"));
assert_eq!(
plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap()
.remaining_ttl(),
0
);
headers.insert(
"cache-control",
HeaderValue::from_static("public, no-cache, max-age=2592000"),
);
assert!(
plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap()
.requires_revalidation
);
headers.insert(
"cache-control",
HeaderValue::from_static("private, max-age=2592000"),
);
assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO).is_none());
}
#[test]
fn age_uses_rfc9111_max_of_apparent_and_corrected_age() {
let s = settings();
let mut h = headers("public, max-age=3600");
h.insert(
"date",
httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10))
.parse()
.unwrap(),
);
h.insert("age", HeaderValue::from_static("3"));
let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap();
assert!(p.initial_age < Duration::from_secs(12));
assert!(p.initial_age >= Duration::from_secs(10));
h.insert("age", HeaderValue::from_static("40"));
let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap();
assert_eq!(p.initial_age.as_secs(), 42);
}
#[tokio::test]
async fn aged_transformed_css_remains_hot_and_visible_in_dashboard() {
let cache = Cache::new(1);
let settings = settings();
let ticket = ticket(&cache, &HeaderMap::new());
let original_body = b"body { color: red; margin: 0px; }";
let mut upstream = Response::new(Body::from(Bytes::from_static(original_body)));
*upstream.headers_mut() = headers("public, max-age=2592000");
upstream.headers_mut().insert(
"date",
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
);
upstream
.headers_mut()
.insert("age", HeaderValue::from_static("1553483"));
upstream
.headers_mut()
.insert("content-type", HeaderValue::from_static("text/css"));
upstream.headers_mut().insert(
"content-length",
HeaderValue::from_str(&original_body.len().to_string()).unwrap(),
);
upstream
.headers_mut()
.insert("etag", HeaderValue::from_static("\"upstream\""));
let origin_plan = plan(
StatusCode::OK,
upstream.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let transformed = crate::transform::apply(
upstream,
"/theme.css",
&crate::transform::Settings {
enabled: true,
css: true,
..Default::default()
},
false,
)
.await
.unwrap();
assert!(transformed
.extensions()
.get::<crate::transform::Applied>()
.is_some());
assert_eq!(transformed.headers()["age"], "1553483");
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let combined = origin_plan.restrict(transformed_plan);
assert!(combined.remaining_ttl() > 86_000);
let first = cache
.wrap(transformed, ticket.clone(), combined, &settings)
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
let hit = cache
.get(&ticket)
.expect("aged, fresh CSS should be cached after transform");
assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000);
assert!(
hit.response.headers()["age"]
.to_str()
.unwrap()
.parse::<u64>()
.unwrap()
>= 1553483
);
let second = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(first, second);
let dash = cache.dashboard(None, 0, 10);
assert_eq!(dash.matching_objects, 1);
assert!(dash.objects[0].transformed);
assert!(dash.objects[0].age_seconds >= 1_553_483);
assert!(dash.objects[0].resident_age_seconds < 10);
assert!(dash.objects[0].remaining_ttl_seconds >= 86_000);
assert_eq!(dash.objects[0].hits, 1);
}
#[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);
}
#[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_hit_updates_lru_without_replacing_the_entry_or_losing_hit_counter() {
let cache = Cache::new(1);
let settings = Settings {
max_entries: 2,
..settings()
};
let h = HeaderMap::new();
let a = cache
.ticket(1, "http", "example.test", "app", "/a", &h)
.unwrap();
let b = cache
.ticket(1, "http", "example.test", "app", "/b", &h)
.unwrap();
let c = cache
.ticket(1, "http", "example.test", "app", "/c", &h)
.unwrap();
fill(&cache, a.clone(), "payload-a", &settings).await;
fill(&cache, b.clone(), "payload-b", &settings).await;
assert_eq!(cache.get(&a).unwrap().hits, 1);
assert_eq!(cache.get(&a).unwrap().hits, 2);
fill(&cache, c.clone(), "payload-c", &settings).await;
assert!(
cache.get(&b).is_none(),
"the least recently used object is evicted"
);
assert_eq!(
cache.get(&a).unwrap().hits,
3,
"HOT hits do not reset on LRU refresh"
);
assert_eq!(cache.get(&c).unwrap().hits, 1);
assert_eq!(cache.view().evictions, 1);
assert_eq!(cache.view().entries, 2);
}
#[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_member = cache
.ticket(
1,
"http",
"example.test",
"pool/member-b",
"/asset.js?x=1",
&b,
)
.unwrap();
assert_eq!(base.key, other_member.key);
let other_url = cache
.ticket(
1,
"http",
"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);
}
}
#[test]
fn empty_complete_body_is_cached_without_requiring_a_consumer_poll() {
let cache = Cache::new(1);
let settings = settings();
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("0"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let response = cache.wrap(response, ticket.clone(), plan, &settings);
assert!(response.body().is_end_stream());
drop(response);
assert!(cache.get(&ticket).is_some());
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().fills_active, 0);
assert_eq!(cache.view().rejected_fills, 0);
}
#[test]
fn empty_body_with_nonzero_content_length_cannot_publish_a_fill() {
let cache = Cache::new(1);
let settings = settings();
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("6"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
drop(cache.wrap(response, ticket.clone(), plan, &settings));
assert!(cache.get(&ticket).is_none());
assert_eq!(cache.view().fills_active, 0);
assert_eq!(cache.view().rejected_fills, 1);
}
#[test]
fn empty_body_completion_still_obeys_purge_barrier() {
let cache = Cache::new(1);
let settings = settings();
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("0"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache.purge(None);
drop(cache.wrap(response, ticket.clone(), plan, &settings));
assert!(cache.get(&ticket).is_none());
assert_eq!(cache.view().entries, 0);
assert_eq!(cache.view().fills_active, 0);
}
}