mod tests {
use super::*;
use http_body_util::BodyExt;
#[tokio::test]
async fn error_replacement_drops_encoding_and_retains_challenge() {
let mut response = Response::new(Body::from("compressed bytes"));
*response.status_mut() = StatusCode::UNAUTHORIZED;
response.headers_mut().insert(
"www-authenticate",
HeaderValue::from_static("Basic realm=\"app\""),
);
response
.headers_mut()
.insert("content-encoding", HeaderValue::from_static("gzip"));
response
.headers_mut()
.insert("set-cookie", HeaderValue::from_static("secret=1"));
replace_error(
&mut response,
&Page {
kind: Kind::Html,
html: "
Sign in
".into(),
status: None,
},
);
assert_eq!(response.status(), 401);
assert!(response.headers().contains_key("www-authenticate"));
assert!(!response.headers().contains_key("content-encoding"));
assert!(!response.headers().contains_key("set-cookie"));
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
"Sign in
"
);
}
#[test]
fn local_page_method_rejection_is_still_marked_non_cacheable() {
let page = Page::default();
let response = page.serve(&Method::POST, StatusCode::NOT_FOUND);
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
assert!(response.extensions().get::().is_some());
}
#[tokio::test]
async fn static_vhost_page_can_serve_html_with_503_status() {
let page = Page {
kind: Kind::Html,
html: "Maintenance
".into(),
status: Some(503),
};
page.validate().unwrap();
let response = page.serve(&Method::GET, StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
"Maintenance
"
);
}
#[test]
fn builtin_gateway_pages_cover_edge_policy_and_gateway_errors() {
assert!(builtin_gateway_error(StatusCode::UNAUTHORIZED)
.unwrap()
.contains("401 - Authentication required"));
assert!(builtin_gateway_error(StatusCode::FORBIDDEN)
.unwrap()
.contains("403 - Access denied"));
assert!(builtin_gateway_error(StatusCode::NOT_FOUND)
.unwrap()
.contains("404 - Not found"));
assert!(builtin_gateway_error(StatusCode::TOO_MANY_REQUESTS)
.unwrap()
.contains("429 - Too many requests"));
assert!(builtin_gateway_error(StatusCode::SERVICE_UNAVAILABLE)
.unwrap()
.contains("503 - Service unavailable"));
assert!(builtin_gateway_error(StatusCode::GATEWAY_TIMEOUT)
.unwrap()
.contains("504 - Gateway timeout"));
for (code, label) in [
(StatusCode::BAD_REQUEST, "400 - Bad request"),
(StatusCode::REQUEST_TIMEOUT, "408 - Request timeout"),
(StatusCode::GONE, "410 - Gone"),
(StatusCode::PAYLOAD_TOO_LARGE, "413 - Payload too large"),
(
StatusCode::INTERNAL_SERVER_ERROR,
"500 - Internal server error",
),
] {
let page = builtin_gateway_error(code).unwrap();
assert!(page.contains(label));
assert!(page.contains("background: #131615"));
}
let gateway_502 = builtin_gateway_error(StatusCode::BAD_GATEWAY).unwrap();
assert!(gateway_502.contains("502 - Bad gateway
"));
assert!(gateway_502.contains("The upstream service returned an invalid response."));
assert!(gateway_502.contains("RustProxy"));
assert!(gateway_502.contains("#131615"));
assert!(!gateway_502.contains("Tinyauth"));
// The standard HTTP error registry and its stock pages predate the
// Tinyauth-specific diagnostic pages and must remain unchanged.
assert_eq!(
ERROR_CODES,
[400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]
);
assert_eq!(
BUILTIN_ERROR_CODES,
[400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504]
);
assert!(builtin_gateway_error(StatusCode::OK).is_none());
assert!(builtin_application_page(StatusCode::OK).contains("200 - OK"));
assert!(builtin_application_page(StatusCode::CREATED).contains("background: #131615"));
}
#[test]
fn validated_bounded_pages_and_ip_detection() {
assert!(Page {
html: "x".repeat(MAX_HTML_BYTES + 1),
..Page::default()
}
.validate()
.is_err());
assert!(Page {
status: Some(204),
..Page::default()
}
.validate()
.is_err());
assert!(Page {
kind: Kind::Empty,
status: Some(204),
html: String::new()
}
.validate()
.is_ok());
assert!(Page {
status: Some(302),
..Page::default()
}
.validate()
.is_err());
for authority in ["127.0.0.1:8080", "[::1]:8443", "10.0.0.1"] {
assert!(literal_ip_authority(authority));
}
for authority in ["app.test:80", "evil@127.0.0.1", "not-an-ip"] {
assert!(!literal_ip_authority(authority));
}
}
#[test]
fn local_response_accepts_full_final_status_range_with_bodyless_guards() {
assert!(Page {
kind: Kind::Html,
status: Some(418),
html: "teapot".into(),
}
.validate_local_response()
.is_ok());
assert!(Page {
kind: Kind::Empty,
status: Some(599),
html: String::new(),
}
.validate_local_response()
.is_ok());
assert!(Page {
kind: Kind::Html,
status: Some(304),
html: "not allowed".into(),
}
.validate_local_response()
.is_err());
assert!(Page {
kind: Kind::Empty,
status: Some(600),
html: String::new(),
}
.validate_local_response()
.is_err());
}
#[test]
fn builtin_welcome_is_minimal_and_never_cacheable() {
let page = Page::default();
let response = page.render(StatusCode::OK);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
assert!(!response.headers().contains_key("x-content-type-options"));
assert!(response.extensions().get::().is_some());
}
#[test]
fn static_pages_do_not_accept_writes() {
let page = Page::default();
assert_eq!(page.serve(&Method::POST, StatusCode::OK).status(), 405);
assert_eq!(page.serve(&Method::GET, StatusCode::OK).status(), 200);
}
#[tokio::test]
async fn origin_error_interception_is_opt_in_and_keeps_http_status() {
let mk_origin = || {
let mut response = Response::new(Body::from("secret application error"));
*response.status_mut() = StatusCode::NOT_FOUND;
response
.headers_mut()
.insert("content-type", HeaderValue::from_static("text/html"));
response
};
let defaults: VhostPages = serde_json::from_str("{}").unwrap();
assert!(defaults.intercept_statuses.is_empty());
let unchanged =
intercept_application_error(mk_origin(), &defaults, &GlobalPages::default());
assert_eq!(unchanged.status(), StatusCode::NOT_FOUND);
assert_eq!(
unchanged.into_body().collect().await.unwrap().to_bytes(),
"secret application error"
);
let configured = VhostPages {
intercept_statuses: vec![404],
..VhostPages::default()
};
configured.validate().unwrap();
let replaced =
intercept_application_error(mk_origin(), &configured, &GlobalPages::default());
assert_eq!(replaced.status(), StatusCode::NOT_FOUND);
assert_eq!(replaced.headers().get("cache-control").unwrap(), "no-store");
assert!(replaced.extensions().get::().is_some());
let bytes = replaced.into_body().collect().await.unwrap().to_bytes();
assert!(String::from_utf8_lossy(&bytes).contains("404"));
assert!(!String::from_utf8_lossy(&bytes).contains("secret application error"));
}
#[tokio::test]
async fn interception_replaces_only_the_body_even_when_page_has_a_status_override() {
let mut vhost = VhostPages {
intercept_statuses: vec![404],
..VhostPages::default()
};
vhost.errors.insert(
404,
Page {
kind: Kind::Html,
status: Some(503),
html: "custom 404".into(),
},
);
let mut origin = Response::new(Body::from("application 404"));
*origin.status_mut() = StatusCode::NOT_FOUND;
let intercepted = intercept_application_error(origin, &vhost, &GlobalPages::default());
assert_eq!(intercepted.status(), StatusCode::NOT_FOUND);
assert_eq!(
intercepted.headers().get("cache-control").unwrap(),
"no-store"
);
assert_eq!(
intercepted.into_body().collect().await.unwrap().to_bytes(),
"custom 404"
);
assert_eq!(
vhost
.errors
.get(&404)
.unwrap()
.render(StatusCode::NOT_FOUND)
.status(),
StatusCode::SERVICE_UNAVAILABLE
);
}
#[tokio::test]
async fn interception_uses_vhost_then_global_page_and_preserves_challenge() {
let mut global = GlobalPages::default();
global.errors.insert(
401,
Page {
kind: Kind::Html,
html: "global denied".into(),
status: None,
},
);
let mut vhost = VhostPages {
intercept_statuses: vec![401],
..VhostPages::default()
};
let mut response = Response::new(Body::from("application denied"));
*response.status_mut() = StatusCode::UNAUTHORIZED;
response.headers_mut().insert(
"www-authenticate",
HeaderValue::from_static("Basic realm=\"app\""),
);
response
.headers_mut()
.insert("set-cookie", HeaderValue::from_static("secret=1"));
response
.headers_mut()
.insert("content-encoding", HeaderValue::from_static("gzip"));
let global_response = intercept_application_error(response, &vhost, &global);
assert_eq!(
global_response.headers().get("www-authenticate").unwrap(),
"Basic realm=\"app\""
);
assert!(!global_response.headers().contains_key("set-cookie"));
assert!(!global_response.headers().contains_key("content-encoding"));
assert_eq!(
global_response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
"global denied"
);
vhost.errors.insert(
401,
Page {
kind: Kind::Html,
html: "local denied".into(),
status: None,
},
);
let mut response = Response::new(Body::from("application denied"));
*response.status_mut() = StatusCode::UNAUTHORIZED;
let local = intercept_application_error(response, &vhost, &global);
assert_eq!(
local.into_body().collect().await.unwrap().to_bytes(),
"local denied"
);
}
#[tokio::test]
async fn interception_skips_nonmatching_and_already_served_responses() {
let vhost = VhostPages {
intercept_statuses: vec![404],
..VhostPages::default()
};
let mut response = Response::new(Body::from("ordinary ok"));
*response.status_mut() = StatusCode::OK;
let untouched = intercept_application_error(response, &vhost, &GlobalPages::default());
assert_eq!(
untouched.into_body().collect().await.unwrap().to_bytes(),
"ordinary ok"
);
let served = Page {
kind: Kind::Html,
status: Some(404),
html: "internal fallback".into(),
}
.render(StatusCode::NOT_FOUND);
let preserved = intercept_application_error(served, &vhost, &GlobalPages::default());
assert_eq!(
preserved.into_body().collect().await.unwrap().to_bytes(),
"internal fallback"
);
}
#[test]
fn intercepted_error_code_configuration_is_validated_and_backwards_compatible() {
let mut pages: VhostPages = serde_json::from_str("{\"response\": null}").unwrap();
assert!(pages.intercept_statuses.is_empty());
pages.intercept_statuses = vec![404, 503];
pages.validate().unwrap();
let json = serde_json::to_string(&pages).unwrap();
assert!(json.contains("\"intercept_statuses\":[404,503]"));
pages.intercept_statuses = vec![404, 404];
assert!(pages.validate().is_err());
pages.intercept_statuses = vec![302];
assert!(pages.validate().is_err());
pages.intercept_statuses.clear();
pages.validate().unwrap();
assert!(!serde_json::to_string(&pages)
.unwrap()
.contains("intercept_statuses"));
}
}