use hyper::{Response, StatusCode};
use hyper::body::Bytes;
use mini_serve::{handler, body, CorsConfigBuilder, RouteBuilder, ServeError};
#[tokio::test]
async fn cors_preflight_allows_the_requested_headers_and_method() {
let app = RouteBuilder::stateless()
.with_cors(
CorsConfigBuilder::default()
.allow_origin("https://example.com")
.build()
.unwrap(),
)
.post("/api/ingest", handler(|_, _| async {
Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
}))
.seal();
let port = app.bind_ephemeral().await.unwrap();
let resp = reqwest::Client::new()
.request(reqwest::Method::OPTIONS, format!("http://127.0.0.1:{}/api/ingest", port))
.header("origin", "https://example.com")
.header("access-control-request-method", "POST")
.header("access-control-request-headers", "content-type")
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(
resp.headers().get("access-control-allow-headers").map(|v| v.to_str().unwrap()),
Some("content-type"),
"preflight must allow the header the real request will send, or the browser blocks it"
);
let allow_methods = resp
.headers()
.get("access-control-allow-methods")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
assert!(
allow_methods.contains("POST"),
"preflight must list POST as an allowed method for this route, got: {allow_methods:?}"
);
}
#[tokio::test]
async fn cors_preflight_only_for_registered_routes() {
let app = RouteBuilder::stateless()
.with_cors(
CorsConfigBuilder::default()
.allow_origin("https://example.com")
.build()
.unwrap(),
)
.get("/api/users", handler(|_, _| async {
Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
}))
.seal();
let port = app.bind_ephemeral().await.unwrap();
let existing_resp = reqwest::Client::new()
.request(reqwest::Method::OPTIONS, format!("http://127.0.0.1:{}/api/users", port))
.header("origin", "https://example.com")
.send()
.await
.unwrap();
assert_eq!(
existing_resp.status(),
StatusCode::NO_CONTENT,
"preflight for existing route should return 204"
);
let nonexistent_resp = reqwest::Client::new()
.request(reqwest::Method::OPTIONS, format!("http://127.0.0.1:{}/api/nonexistent", port))
.header("origin", "https://example.com")
.send()
.await
.unwrap();
assert_eq!(
nonexistent_resp.status(),
StatusCode::NOT_FOUND,
"preflight for non-existent route should return 404"
);
}
#[tokio::test]
async fn error_responses_carry_cors_headers_too() {
let app = RouteBuilder::stateless()
.with_cors(
CorsConfigBuilder::default()
.allow_origin("https://example.com")
.build()
.unwrap(),
)
.get("/exists", handler(|_, _| async {
Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
}))
.get("/boom", handler(|_, _| async {
Err::<Response<mini_serve::ResponseBody>, _>(ServeError::new(500, "internal detail"))
}))
.seal();
let port = app.bind_ephemeral().await.unwrap();
let client = reqwest::Client::new();
for (path, expected) in [
("/exists", StatusCode::OK),
("/missing", StatusCode::NOT_FOUND),
("/boom", StatusCode::INTERNAL_SERVER_ERROR),
] {
let resp = client
.get(format!("http://127.0.0.1:{port}{path}"))
.header("origin", "https://example.com")
.send()
.await
.unwrap();
assert_eq!(resp.status(), expected, "{path}");
assert_eq!(
resp.headers()
.get("access-control-allow-origin")
.map(|v| v.to_str().unwrap()),
Some("https://example.com"),
"{path} must carry CORS headers or the browser hides its status from the caller"
);
}
let resp = client
.post(format!("http://127.0.0.1:{port}/exists"))
.header("origin", "https://example.com")
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert!(
resp.headers().contains_key("access-control-allow-origin"),
"a 405 must carry CORS headers too"
);
}
#[tokio::test]
async fn a_patch_route_is_advertised_by_the_cors_preflight() {
let app = RouteBuilder::stateless()
.with_cors(
CorsConfigBuilder::default()
.allow_origin("https://example.com")
.build()
.unwrap(),
)
.patch("/api/resource", handler(|_, _| async {
Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
}))
.seal();
let port = app.bind_ephemeral().await.unwrap();
let resp = reqwest::Client::new()
.request(reqwest::Method::OPTIONS, format!("http://127.0.0.1:{port}/api/resource"))
.header("origin", "https://example.com")
.header("access-control-request-method", "PATCH")
.send()
.await
.unwrap();
let allowed = resp
.headers()
.get("access-control-allow-methods")
.expect("a preflight must advertise the methods it allows")
.to_str()
.unwrap()
.to_string();
assert!(
allowed.contains("PATCH"),
"the preflight did not advertise PATCH: {allowed}"
);
}
#[tokio::test]
async fn a_second_origin_header_is_ignored_on_every_exit_path() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let app = RouteBuilder::stateless()
.with_cors(
CorsConfigBuilder::default()
.allow_origin("https://first.example")
.allow_origin("https://second.example")
.build()
.unwrap(),
)
.get("/exists", handler(|_, _| async {
Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
}))
.seal();
let port = app.bind_ephemeral().await.unwrap();
for (what, request) in [
(
"a normal response",
"GET /exists HTTP/1.1\r\nHost: x\r\nOrigin: https://first.example\r\n\
Origin: https://second.example\r\nConnection: close\r\n\r\n",
),
(
"a preflight",
"OPTIONS /exists HTTP/1.1\r\nHost: x\r\nOrigin: https://first.example\r\n\
Origin: https://second.example\r\nAccess-Control-Request-Method: GET\r\n\
Connection: close\r\n\r\n",
),
] {
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
.await
.unwrap();
stream.write_all(request.as_bytes()).await.unwrap();
let mut buf = Vec::new();
let _ = tokio::time::timeout(
std::time::Duration::from_secs(3),
stream.read_to_end(&mut buf),
)
.await;
let response = String::from_utf8_lossy(&buf).to_lowercase();
assert!(
response.contains("access-control-allow-origin: https://first.example"),
"{what} must reflect the first Origin; got:\n{response}"
);
assert!(
!response.contains("access-control-allow-origin: https://second.example"),
"{what} reflected the second Origin — the two reads have drifted apart:\n{response}"
);
}
}