434 lines
17 KiB
Rust
434 lines
17 KiB
Rust
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::<CanonicalIdentity>().is_some());
|
|
let validators = response.extensions().get::<OriginValidators>().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::<CanonicalIdentity>().is_some());
|
|
assert!(response.extensions().get::<Applied>().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::<OriginValidators>().unwrap();
|
|
assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\"");
|
|
let applied = response.extensions().get::<Applied>().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::<Applied>().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::<Applied>().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::<Applied>().is_none());
|
|
assert!(response.extensions().get::<CanonicalIdentity>().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::<CanonicalIdentity>().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");
|
|
}
|
|
}
|