use axum::{
body::Body,
http::{Request, StatusCode},
};
use tower::util::ServiceExt;
use crate::api::{
advertised_routes, create_router_with_config, AppState, RouterConfig,
};
fn all_configs() -> Vec<RouterConfig> {
let mut configs = Vec::new();
for openai_api in [true, false] {
for metrics in [true, false] {
configs.push(RouterConfig {
openai_api,
cors: true,
metrics,
});
}
}
configs
}
fn router(config: &RouterConfig) -> axum::Router {
create_router_with_config(AppState::with_cache(10), config.clone())
}
async fn body_string(response: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("read body");
String::from_utf8_lossy(&bytes).into_owned()
}
async fn probe(
config: &RouterConfig,
method: &str,
path: &str,
content_type: Option<&str>,
body: &str,
) -> (StatusCode, Option<String>, String) {
let mut request = Request::builder().method(method).uri(path);
if let Some(content_type) = content_type {
request = request.header("content-type", content_type);
}
let response = router(config)
.oneshot(
request
.body(Body::from(body.to_string()))
.expect("build request"),
)
.await
.expect("dispatch");
let status = response.status();
let content_type = response
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
(status, content_type, body_string(response).await)
}
async fn advertised_over_http(config: &RouterConfig) -> Vec<String> {
let (status, _, body) = probe(config, "GET", "/no/such/route", None, "").await;
assert_eq!(status, StatusCode::NOT_FOUND);
let parsed: serde_json::Value = serde_json::from_str(&body).expect("json 404 body");
parsed["routes"]
.as_array()
.expect("routes array in 404 body")
.iter()
.map(|r| r.as_str().unwrap_or_default().to_string())
.collect()
}
fn concrete(path: &str) -> String {
path.replace(":request_id", "not-a-uuid")
}
#[tokio::test]
async fn advertised_routes_answer_under_every_config() {
for config in all_configs() {
for route in advertised_over_http(&config).await {
let (method, path) = route.split_once(' ').expect("METHOD /path");
let (status, _, _) = probe(
&config,
method,
&concrete(path),
Some("application/json"),
"{}",
)
.await;
assert_ne!(
status,
StatusCode::NOT_FOUND,
"openai_api={} metrics={}: `{route}` is advertised to clients but does not answer",
config.openai_api,
config.metrics,
);
}
}
}
#[tokio::test]
async fn unadvertised_routes_do_not_answer() {
let universe: std::collections::BTreeSet<String> = all_configs()
.iter()
.flat_map(advertised_routes)
.collect();
for config in all_configs() {
let advertised: std::collections::BTreeSet<String> =
advertised_over_http(&config).await.into_iter().collect();
for route in &universe {
if advertised.contains(route) {
continue;
}
let (method, path) = route.split_once(' ').expect("METHOD /path");
let (status, _, _) = probe(
&config,
method,
&concrete(path),
Some("application/json"),
"{}",
)
.await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"openai_api={} metrics={}: `{route}` answers but is advertised to nobody",
config.openai_api,
config.metrics,
);
}
}
}
#[tokio::test]
async fn no_metrics_stops_advertising_metrics() {
let config = RouterConfig {
openai_api: true,
cors: true,
metrics: false,
};
let advertised = advertised_over_http(&config).await;
assert!(
!advertised.iter().any(|r| r == "GET /metrics"),
"a server that unmounted /metrics must not advertise it: {advertised:?}"
);
let config = RouterConfig {
metrics: true,
..config
};
let advertised = advertised_over_http(&config).await;
assert!(
advertised.iter().any(|r| r == "GET /metrics"),
"a server serving /metrics must advertise it: {advertised:?}"
);
}
#[tokio::test]
async fn banner_source_agrees_with_live_server() {
for config in all_configs() {
assert_eq!(
advertised_routes(&config),
advertised_over_http(&config).await,
"openai_api={} metrics={}: the banner list and the 404 list disagree",
config.openai_api,
config.metrics,
);
}
}
const MALFORMED: &[(&str, Option<&str>, &str)] = &[
("POST", Some("application/json"), "{not json"),
("POST", Some("application/json"), "{\"model\":"),
("POST", None, "{\"prompt\":\"hi\",\"max_tokens\":2}"),
(
"POST",
Some("application/json"),
"{\"prompt\":[1,2,3],\"max_tokens\":\"lots\"}",
),
];
const BODY_ROUTES: &[&str] = &[
"/generate",
"/tokenize",
"/batch/generate",
"/batch/tokenize",
"/stream/generate",
"/realize/embed",
"/v1/completions",
"/v1/chat/completions",
"/v1/predict",
"/api/generate",
];
#[tokio::test]
async fn every_error_body_is_json() {
let config = RouterConfig::default();
for path in BODY_ROUTES {
for (method, content_type, body) in MALFORMED {
let (status, response_type, response_body) =
probe(&config, method, path, *content_type, body).await;
if status.is_success() {
continue;
}
let response_type = response_type.unwrap_or_default();
assert!(
response_type.starts_with("application/json"),
"{method} {path} answered {status} as `{response_type}`, not JSON: {response_body}"
);
let parsed: serde_json::Value = serde_json::from_str(&response_body)
.unwrap_or_else(|e| panic!("{method} {path} {status}: body is not JSON ({e}): {response_body}"));
assert!(
parsed.get("error").is_some(),
"{method} {path} {status}: JSON error body must carry an `error` field: {response_body}"
);
}
}
}
#[tokio::test]
async fn no_error_body_leaks_internals() {
const LEAKS: &[&str] = &[
"at line 1 column", "EOF while parsing", "AppState", "::demo()", ".rs:", ];
let config = RouterConfig::default();
let mut cases: Vec<(&str, &str, Option<&str>, &str)> = Vec::new();
for path in BODY_ROUTES {
for (method, content_type, body) in MALFORMED {
cases.push((method, path, *content_type, body));
}
}
cases.push((
"POST",
"/v1/predict",
Some("application/json"),
"{\"features\":[1.0,2.0]}",
));
for (method, path, content_type, body) in cases {
let (status, _, response_body) = probe(&config, method, path, content_type, body).await;
if status.is_success() {
continue;
}
for leak in LEAKS {
assert!(
!response_body.contains(leak),
"{method} {path} {status} leaked `{leak}` to the client: {response_body}"
);
}
}
}
#[tokio::test]
async fn handler_diagnostics_survive_the_envelope() {
let (status, content_type, body) = probe(
&RouterConfig::default(),
"POST",
"/v1/predict",
Some("application/json"),
"{\"features\":[]}",
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
content_type
.unwrap_or_default()
.starts_with("application/json"),
"handler errors are JSON too"
);
assert!(
body.contains("features"),
"the caller must still be told WHICH field they got wrong, got: {body}"
);
let (other_status, _, other_body) = probe(
&RouterConfig::default(),
"POST",
"/v1/predict",
Some("application/json"),
"{\"features\":[1.0,2.0]}",
)
.await;
assert_ne!(
(status, body.clone()),
(other_status, other_body.clone()),
"two different rejections must not collapse to one message: {body}"
);
}
#[tokio::test]
async fn route_list_survives_the_envelope() {
let config = RouterConfig::default();
let (status, content_type, body) = probe(&config, "GET", "/no/such/route", None, "").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(
content_type.unwrap_or_default().starts_with("application/json"),
"404 must stay JSON"
);
let parsed: serde_json::Value = serde_json::from_str(&body).expect("json 404 body");
assert!(
!parsed["routes"]
.as_array()
.expect("routes array")
.is_empty(),
"the 404 must still list the routes it promises: {body}"
);
}
#[test]
fn every_mounted_route_comes_from_the_route_table() {
let src = include_str!("../router.rs");
const HAND_MOUNTED: &[&str] = &["/"];
let table_rows = ["(\"GET\", \"/", "(\"POST\", \"/"]
.iter()
.map(|pat| src.matches(pat).count())
.sum::<usize>();
assert!(
table_rows > 30,
"found only {table_rows} route-table rows — this test is parsing the wrong \
thing, or the table was dismantled. Fix the parser, not this number."
);
let literal_mounts: Vec<&str> = src
.match_indices(".route(")
.filter_map(|(i, m)| {
let rest = &src[i + m.len()..];
let arg = rest.trim_start();
let arg = arg.strip_prefix('"')?;
let end = arg.find('"')?;
Some(&arg[..end])
})
.collect();
for path in &literal_mounts {
assert!(
HAND_MOUNTED.contains(path),
"`{path}` is mounted by a hand-written .route() call rather than from \
route_table(), so it is advertised to nobody — neither the 404 body nor \
the `apr serve` startup banner will name it. Add it to a *_routes() table."
);
}
assert!(
literal_mounts.contains(&"/"),
"`GET /` is no longer mounted directly; the allowlist is stale: {literal_mounts:?}"
);
}
#[tokio::test]
async fn ollama_discovery_routes_answer() {
let config = RouterConfig::default();
for (method, path) in [
("GET", "/api/tags"),
("POST", "/api/show"),
("GET", "/api/version"),
] {
let (status, _, _) = probe(&config, method, path, Some("application/json"), "{}").await;
assert_ne!(
status,
StatusCode::NOT_FOUND,
"{method} {path} does not answer — an Ollama client probes this before it \
will chat, so the drop-in replacement claim is dead without it"
);
}
}