use std::sync::Arc;
use hyper::{Response, StatusCode};
use mini_serve::{
body, handler, CorsConfigBuilder, Handler, Middleware, PathSegments, RouteBuilder, ServeError,
State,
};
fn text_handler(text: &'static str) -> Handler<()> {
handler(move |_req, _state| async move {
Ok::<_, ServeError>(Response::new(body(text.into())))
})
}
async fn app_with_fallback() -> u16 {
RouteBuilder::stateless()
.get("/api/health", text_handler("API"))
.with_fallback(text_handler("FALLBACK"))
.seal()
.bind_ephemeral()
.await
.unwrap()
}
#[tokio::test]
async fn a_registered_route_still_wins() {
let port = app_with_fallback().await;
let body = reqwest::get(format!("http://127.0.0.1:{port}/api/health"))
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "API", "the fallback shadowed a registered route");
}
#[tokio::test]
async fn an_unmatched_path_reaches_the_fallback() {
let port = app_with_fallback().await;
let response = reqwest::get(format!("http://127.0.0.1:{port}/assets/site.css"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().await.unwrap(), "FALLBACK");
}
#[tokio::test]
async fn a_miss_without_a_fallback_is_still_a_404() {
let port = RouteBuilder::stateless()
.get("/api/health", text_handler("API"))
.seal()
.bind_ephemeral()
.await
.unwrap();
let response = reqwest::get(format!("http://127.0.0.1:{port}/nope")).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_wrong_method_on_a_real_route_is_405_not_the_fallback() {
let port = app_with_fallback().await;
let response = reqwest::Client::new()
.post(format!("http://127.0.0.1:{port}/api/health"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
assert!(response.headers().contains_key("allow"));
}
#[tokio::test]
async fn middleware_wraps_the_fallback() {
let guard: Middleware<()> = Arc::new(|next: Handler<()>| {
let next = Arc::clone(&next);
handler(move |req, state: State<()>| {
let next = Arc::clone(&next);
async move {
if req.uri().path().starts_with("/admin/") {
return Ok(Response::builder()
.status(StatusCode::FORBIDDEN)
.body(body("GUARDED".into()))
.unwrap());
}
next(req, state).await
}
})
});
let port = RouteBuilder::stateless()
.wrap(guard)
.get("/api/health", text_handler("API"))
.with_fallback(text_handler("FALLBACK"))
.seal()
.bind_ephemeral()
.await
.unwrap();
let response = reqwest::get(format!("http://127.0.0.1:{port}/admin/secret.txt"))
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"the fallback served a guarded path without the middleware running"
);
assert_eq!(response.text().await.unwrap(), "GUARDED");
}
#[tokio::test]
async fn a_fallback_response_carries_nosniff() {
let port = app_with_fallback().await;
let response = reqwest::get(format!("http://127.0.0.1:{port}/anything")).await.unwrap();
assert_eq!(
response.headers().get("x-content-type-options").map(|v| v.to_str().unwrap()),
Some("nosniff")
);
}
#[tokio::test]
async fn a_fallback_response_carries_cors_headers() {
let cors = CorsConfigBuilder::default()
.allow_origin("https://example.com")
.build()
.unwrap();
let port = RouteBuilder::stateless()
.with_cors(cors)
.get("/api/health", text_handler("API"))
.with_fallback(text_handler("FALLBACK"))
.seal()
.bind_ephemeral()
.await
.unwrap();
let response = reqwest::Client::new()
.get(format!("http://127.0.0.1:{port}/assets/site.css"))
.header("Origin", "https://example.com")
.send()
.await
.unwrap();
assert_eq!(
response.headers().get("access-control-allow-origin").map(|v| v.to_str().unwrap()),
Some("https://example.com"),
"a fallback response skipped the CORS exit"
);
}
#[tokio::test]
async fn a_fallback_5xx_does_not_leak_internals() {
let port = RouteBuilder::stateless()
.with_fallback(handler(|_req, _state| async {
Err::<Response<mini_serve::ResponseBody>, _>(ServeError::new(
500,
"replica set primary unreachable",
))
}))
.seal()
.bind_ephemeral()
.await
.unwrap();
let response = reqwest::get(format!("http://127.0.0.1:{port}/boom")).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = response.text().await.unwrap();
assert!(
!body.contains("replica set"),
"a fallback leaked an internal message: {body}"
);
}
#[tokio::test]
async fn a_head_to_the_fallback_reports_the_length_of_its_get() {
let port = app_with_fallback().await;
let client = reqwest::Client::new();
let url = format!("http://127.0.0.1:{port}/assets/site.css");
let get = client.get(&url).send().await.unwrap();
let get_len = get.headers().get("content-length").cloned();
let head = client.head(&url).send().await.unwrap();
assert_eq!(head.headers().get("content-length"), get_len.as_ref());
assert!(head.text().await.unwrap().is_empty(), "HEAD carried a body");
}
#[tokio::test]
async fn a_preflight_for_a_fallback_path_is_not_masked_by_a_204() {
let cors = CorsConfigBuilder::default()
.allow_origin("https://example.com")
.build()
.unwrap();
let port = RouteBuilder::stateless()
.with_cors(cors)
.get("/api/health", text_handler("API"))
.with_fallback(text_handler("FALLBACK"))
.seal()
.bind_ephemeral()
.await
.unwrap();
let response = reqwest::Client::new()
.request(reqwest::Method::OPTIONS, format!("http://127.0.0.1:{port}/assets/site.css"))
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "GET")
.send()
.await
.unwrap();
assert_ne!(
response.status(),
StatusCode::NO_CONTENT,
"a preflight was answered for a path with no registered route"
);
}
#[tokio::test]
async fn the_fallback_receives_the_routers_own_segments() {
let port = RouteBuilder::stateless()
.with_fallback(handler(|req, _state| async move {
let segments = req
.extensions()
.get::<PathSegments>()
.expect("the fallback was not given the router's segments");
Ok::<_, ServeError>(hyper::Response::new(body(segments.0.join("|").into())))
}))
.seal()
.bind_ephemeral()
.await
.unwrap();
let body = reqwest::get(format!("http://127.0.0.1:{port}/admin%2Fconfig"))
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "admin/config", "an encoded slash was treated as a separator");
let body = reqwest::get(format!("http://127.0.0.1:{port}/admin/config"))
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "admin|config", "a real separator was not one");
}