mod tests { use super::*; #[test] fn identity_decode_borrows_the_collected_body() { let body = b"const value = 1;"; let decoded = decode(body, None, 1024).unwrap(); assert!(matches!(decoded, Cow::Borrowed(_))); } #[test] fn transform_memory_budget_accounts_for_identity_and_decoded_buffers() { assert_eq!( transform_memory_required_bytes(2 * 1024 * 1024, None, 2 * 1024 * 1024), Some(4 * 1024 * 1024) ); assert_eq!( transform_memory_required_bytes(2 * 1024 * 1024, Some("gzip"), 2 * 1024 * 1024), Some(6 * 1024 * 1024) ); assert_eq!( transform_memory_required_bytes( DEFAULT_MEMORY_BUDGET_BYTES, Some("gzip"), DEFAULT_MEMORY_BUDGET_BYTES ), Some(3 * DEFAULT_MEMORY_BUDGET_BYTES) ); } #[test] fn transform_memory_reservation_obeys_runtime_budget() { let counter = AtomicUsize::new(0); let required = 4 * 1024 * 1024; let reservation = try_reserve_memory_from(&counter, required, required).unwrap(); assert!(try_reserve_memory_from(&counter, required, required).is_none()); drop(reservation); assert!(try_reserve_memory_from(&counter, required, required).is_some()); } #[test] fn transform_inflight_reservation_obeys_runtime_limit() { let counter = AtomicUsize::new(0); let slot = try_reserve_slot_from(&counter, 1).unwrap(); assert!(try_reserve_slot_from(&counter, 1).is_none()); drop(slot); assert!(try_reserve_slot_from(&counter, 1).is_some()); } #[test] fn resource_usage_exposes_configured_limits() { let settings = Settings { memory_budget_bytes: 512 * 1024 * 1024, max_inflight: 64, ..Default::default() }; let usage = resource_usage(&settings); assert_eq!(usage.memory_budget_bytes, 512 * 1024 * 1024); assert_eq!(usage.max_inflight, 64); } #[test] fn resource_preset_metadata_round_trips() { let mut settings = Settings::default(); settings.preset = Some(Preset::Uber); settings.memory_budget_bytes = 1024 * 1024 * 1024; settings.max_body_bytes = 32 * 1024 * 1024; settings.max_inflight = 128; let encoded = serde_json::to_value(&settings).unwrap(); assert_eq!(encoded["preset"], "uber"); let decoded: Settings = serde_json::from_value(encoded).unwrap(); assert_eq!(decoded.preset, Some(Preset::Uber)); assert_eq!(decoded.memory_budget_bytes, 1024 * 1024 * 1024); assert_eq!(decoded.max_inflight, 128); } #[test] fn legacy_transform_settings_receive_resource_defaults() { let decoded: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap(); assert!(decoded.enabled); assert_eq!(decoded.preset, None); assert_eq!(decoded.memory_budget_bytes, DEFAULT_MEMORY_BUDGET_BYTES); assert_eq!(decoded.max_body_bytes, DEFAULT_MAX_BODY_BYTES); assert_eq!(decoded.max_inflight, DEFAULT_MAX_INFLIGHT); } #[test] fn bounded_decoder_rejects_expansion_past_limit() { use std::io::Write as _; let identity = vec![b'x'; 128 * 1024]; let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder.write_all(&identity).unwrap(); let encoded = encoder.finish().unwrap(); assert!(decode(&encoded, Some("gzip"), 64 * 1024).is_err()); } #[tokio::test] async fn aggregation_is_bounded_even_when_content_length_is_false() { let settings = Settings { enabled: true, json: true, max_body_bytes: 1024, ..Default::default() }; let mut response = Response::new(Body::from(vec![b' '; 2048])); response .headers_mut() .insert("content-type", HeaderValue::from_static("application/json")); response .headers_mut() .insert("content-length", HeaderValue::from_static("128")); assert!(apply(response, "/data.json", &settings, false) .await .is_err()); } #[test] fn metrics_track_applied_skipped_and_cached_transformed_responses() { let metrics = Metrics::default(); let mut applied = Response::new(Body::empty()); applied.extensions_mut().insert(Applied { kind: Kind::Css, original_bytes: 100, transformed_bytes: 70, }); metrics.record_response("app", &applied, Duration::from_micros(500)); let mut skipped = Response::new(Body::empty()); skipped.extensions_mut().insert(Skipped { kind: Some(Kind::Css), reason: "response_no_transform", }); metrics.record_response("app", &skipped, Duration::from_micros(100)); metrics.record_cache_hit("app"); let total = metrics.total(); assert_eq!(total.applied, 1); assert_eq!(total.cache_hits, 1); let view = metrics.view(); assert_eq!(view.total.examined, 2); assert_eq!(view.total.applied, 1); assert_eq!(view.total.skipped, 1); assert_eq!(view.total.cache_hits, 1); assert_eq!(view.total.saved_bytes, 30); assert_eq!(view.total.by_kind.get("css"), Some(&1)); assert_eq!(view.total.reasons.get("response_no_transform"), Some(&1)); assert_eq!(view.scopes.len(), 1); assert_eq!(view.scopes[0].vhost, "app"); } #[test] fn per_vhost_is_opt_in_even_when_global_is_enabled() { let global = Settings { enabled: true, ..Default::default() }; assert!(!Overrides::default().resolve(&global).unwrap().enabled); let enabled = Overrides { enabled: true, ..Default::default() }; assert!(enabled.resolve(&global).unwrap().enabled); } #[tokio::test] async fn json_is_compacted_and_gets_a_new_validator() { let settings = Settings { enabled: true, json: true, ..Default::default() }; let mut response = Response::new(Body::from("{ \"a\": 1, \"b\": [ true ] }")); response .headers_mut() .insert("content-type", HeaderValue::from_static("application/json")); response .headers_mut() .insert("content-length", HeaderValue::from_static("30")); response .headers_mut() .insert("etag", HeaderValue::from_static("\"origin\"")); response .headers_mut() .insert("vary", HeaderValue::from_static("Accept-Encoding")); let response = apply(response, "/data.json", &settings, false) .await .unwrap(); assert!(response.headers()["etag"] .to_str() .unwrap() .starts_with("\"rustproxy-")); assert!(!response.headers().contains_key("vary")); assert!(response.extensions().get::().is_some()); let validators = response.extensions().get::().unwrap(); assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\""); let body = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&body[..], br#"{"a":1,"b":[true]}"#); } #[tokio::test] async fn unchanged_body_keeps_origin_validator() { let settings = 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_static("7")); response .headers_mut() .insert("etag", HeaderValue::from_static("\"origin\"")); response .headers_mut() .insert("vary", HeaderValue::from_static("Accept-Encoding, X-Theme")); let response = apply(response, "/data.json", &settings, false) .await .unwrap(); assert_eq!(response.headers()["etag"], "\"origin\""); assert_eq!(response.headers()["vary"], "X-Theme"); assert!(response.extensions().get::().is_some()); assert!(response.extensions().get::().is_none()); } #[tokio::test] async fn unchanged_compressed_body_is_canonicalized_to_identity() { use std::io::Write as _; let settings = Settings { enabled: true, json: true, ..Default::default() }; let identity = br#"{"a":1}"#; let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder.write_all(identity).unwrap(); let encoded = encoder.finish().unwrap(); let mut response = Response::new(Body::from(encoded.clone())); response .headers_mut() .insert("content-type", HeaderValue::from_static("application/json")); response.headers_mut().insert( "content-length", HeaderValue::from_str(&encoded.len().to_string()).unwrap(), ); response .headers_mut() .insert("content-encoding", HeaderValue::from_static("gzip")); response .headers_mut() .insert("etag", HeaderValue::from_static("\"origin\"")); let response = apply(response, "/data.json", &settings, false) .await .unwrap(); assert!(!response.headers().contains_key("content-encoding")); assert_eq!( response.headers()["content-length"].to_str().unwrap(), identity.len().to_string() ); assert!(response.headers()["etag"] .to_str() .unwrap() .starts_with("\"rustproxy-")); let validators = response.extensions().get::().unwrap(); assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\""); let applied = response.extensions().get::().unwrap(); assert_eq!(applied.original_bytes, identity.len()); assert_eq!(applied.transformed_bytes, identity.len()); let body = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&body[..], identity); } #[tokio::test] async fn origin_no_transform_blocks_body_changes_and_keeps_validator() { let settings = 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 = apply(response, "/data.json", &settings, true) .await .unwrap(); assert_eq!(response.headers()["etag"], "\"origin\""); assert!(response.extensions().get::().is_none()); let received = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&received[..], body); } #[tokio::test] async fn final_no_transform_blocks_body_changes() { let settings = 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( "cache-control", HeaderValue::from_static("public, max-age=60, no-transform"), ); let response = apply(response, "/data.json", &settings, false) .await .unwrap(); assert!(response.extensions().get::().is_none()); let received = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&received[..], body); } #[tokio::test] async fn empty_head_body_with_origin_length_keeps_origin_representation_headers() { let settings = Settings { enabled: true, json: true, ..Default::default() }; let mut response = Response::new(Body::empty()); response .headers_mut() .insert("content-type", HeaderValue::from_static("application/json")); response .headers_mut() .insert("content-length", HeaderValue::from_static("76")); response .headers_mut() .insert("etag", HeaderValue::from_static("\"origin-head\"")); let response = apply(response, "/data.json", &settings, false) .await .unwrap(); assert_eq!(response.headers()["content-length"], "76"); assert_eq!(response.headers()["etag"], "\"origin-head\""); assert!(response.extensions().get::().is_none()); assert!(response.extensions().get::().is_none()); let body = response.into_body().collect().await.unwrap().to_bytes(); assert!(body.is_empty()); } #[tokio::test] async fn compressed_no_transform_preserves_origin_encoding_and_bytes() { use std::io::Write as _; let settings = Settings { enabled: true, json: true, ..Default::default() }; let identity = br#"{ "a": 1 }"#; let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder.write_all(identity).unwrap(); let encoded = encoder.finish().unwrap(); let mut response = Response::new(Body::from(encoded.clone())); response .headers_mut() .insert("content-type", HeaderValue::from_static("application/json")); response.headers_mut().insert( "content-length", HeaderValue::from_str(&encoded.len().to_string()).unwrap(), ); response .headers_mut() .insert("content-encoding", HeaderValue::from_static("gzip")); response.headers_mut().insert( "cache-control", HeaderValue::from_static("public, max-age=60, no-transform"), ); response .headers_mut() .insert("vary", HeaderValue::from_static("Accept-Encoding")); response .headers_mut() .insert("etag", HeaderValue::from_static("\"origin\"")); let response = apply(response, "/data.json", &settings, false) .await .unwrap(); assert_eq!(response.headers()["content-encoding"], "gzip"); assert_eq!(response.headers()["etag"], "\"origin\""); assert_eq!(response.headers()["vary"], "Accept-Encoding"); assert!(response.extensions().get::().is_none()); let received = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&received[..], encoded.as_slice()); } #[test] fn css_minifier_preserves_descendant_pseudoclasses() { let source = r#"html:is([data-corner-style="square"], [data-corner-style="rounded"]) :is(.stat, .card) { border-radius: var(--rp-radius-surface) !important; } .menu :hover { color: red; } .menu :not(.hidden) { display: block; } .menu:hover { color: blue; } body { color: red; }"#; let result = css::minify(source.as_bytes()).expect("minify CSS"); let output = std::str::from_utf8(&result).expect("UTF-8 CSS"); assert!(output.contains("html:is([data-corner-style=\"square\"],[data-corner-style=\"rounded\"]) :is(.stat,.card)"), "{output}"); assert!(output.contains(".menu :hover{"), "{output}"); assert!(output.contains(".menu :not(.hidden){"), "{output}"); assert!(output.contains(".menu:hover{"), "{output}"); assert!(output.contains("body{color:red;}"), "{output}"); } #[test] fn css_minifier_preserves_dashboard_corner_selector() { let input = include_str!("../admin/web/style.css"); let result = css::minify(input.as_bytes()).expect("minify panel stylesheet"); let output = std::str::from_utf8(&result).expect("UTF-8 CSS"); assert!(output.contains("html:is([data-corner-style=square],[data-corner-style=subtle],[data-corner-style=rounded],[data-corner-style=extra]) :is("), "lost descendant pseudo-class combinator"); assert!(output.contains(".card,.stat,dialog,.login-card,"), "lost card selector list"); assert!(output.contains("border-radius:var(--rp-radius-surface) !important"), "lost radius declaration"); } }