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"), (StatusCode::BAD_GATEWAY, "502 - Bad gateway"), ] { let page = builtin_gateway_error(code).unwrap(); assert!(page.contains(label)); assert!(page.contains("background: #131615")); } 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, // Overrides are valid for standalone error pages, not an origin // response whose HTTP status must remain 404. 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"); // Outside interception the configured status override still applies. 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")); } }