use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::Response;
use base64::prelude::*;
use serde_json::json;
use tower::ServiceExt;
mod common;
use common::{BASE, EcSigner, TestSigner, body_json, fetch_nonce, flattened_jws, p, test_app};
async fn post(app: &axum::Router, path: &str, body: String) -> Response {
app.clone()
.oneshot(
Request::post(p(path))
.header("content-type", "application/jose+json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
fn post_as_get(signer: &EcSigner, url: &str, nonce: &str) -> String {
let protected = json!({
"alg": signer.alg(),
"jwk": signer.jwk(),
"nonce": nonce,
"url": url,
});
let protected_b64 = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&protected).unwrap());
let sig = signer.sign_input(format!("{protected_b64}.").as_bytes());
flattened_jws(&protected_b64, "", &sig)
}
#[tokio::test]
async fn health_returns_ok_json() {
let res = test_app()
.await
.oneshot(Request::get("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_json(res).await["healthy"], true);
}
#[tokio::test]
async fn directory_lists_only_routed_endpoints() {
let res = test_app()
.await
.oneshot(Request::get(p("/directory")).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let json = body_json(res).await;
for key in [
"newNonce",
"newAccount",
"newOrder",
"revokeCert",
"keyChange",
"renewalInfo",
] {
assert!(json.get(key).is_some(), "directory is missing `{key}`");
}
for key in ["crl", "ca.pem", "caChain"] {
assert!(
json.get(key).is_none(),
"directory should not advertise unrouted `{key}`"
);
}
}
#[tokio::test]
async fn every_advertised_endpoint_is_actually_mounted() {
let app = test_app().await;
let directory = body_json(
app.clone()
.oneshot(Request::get(p("/directory")).body(Body::empty()).unwrap())
.await
.unwrap(),
)
.await;
for (key, value) in directory.as_object().expect("the directory is an object") {
let Some(url) = value.as_str() else { continue };
let path = url
.strip_prefix(common::HOST)
.unwrap_or_else(|| panic!("`{key}` is not under this server: {url}"));
let path = if key == "renewalInfo" {
format!("{path}/aYhfVA.AAAA")
} else {
path.to_string()
};
let response = app
.clone()
.oneshot(Request::get(&path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_ne!(
response.status(),
StatusCode::NOT_FOUND,
"`{key}` is advertised at {path} but nothing is mounted there"
);
}
}
#[tokio::test]
async fn root_redirects_to_health() {
let res = test_app()
.await
.oneshot(Request::get("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
res.headers().get("location").and_then(|v| v.to_str().ok()),
Some("/health"),
);
}
#[tokio::test]
async fn new_nonce_answers_and_forbids_caching() {
for (request, expected) in [
(Request::get(p("/newNonce")), StatusCode::NO_CONTENT),
(Request::head(p("/newNonce")), StatusCode::OK),
] {
let method = request.method_ref().unwrap().clone();
let res = test_app()
.await
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), expected, "{method} /newNonce");
assert_eq!(
res.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("no-store"),
"{method} /newNonce must not be cacheable"
);
assert!(
res.headers().contains_key("replay-nonce"),
"{method} /newNonce must hand out a nonce"
);
}
}
#[tokio::test]
async fn directory_and_new_nonce_answer_post_as_get() {
let app = test_app().await;
let signer = EcSigner::new();
let nonce = fetch_nonce(&app).await;
let res = post(
&app,
"/directory",
post_as_get(&signer, &format!("{BASE}/directory"), &nonce),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
let json = body_json(res).await;
assert_eq!(json["newOrder"], format!("{BASE}/newOrder"));
let nonce = fetch_nonce(&app).await;
let res = post(
&app,
"/newNonce",
post_as_get(&signer, &format!("{BASE}/newNonce"), &nonce),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("no-store"),
);
assert!(res.headers().contains_key("replay-nonce"));
let nonce = fetch_nonce(&app).await;
let body = post_as_get(&signer, &format!("{BASE}/newNonce"), &nonce);
assert_eq!(
post(&app, "/newNonce", body.clone()).await.status(),
StatusCode::OK
);
let replayed = post(&app, "/newNonce", body).await;
assert_eq!(replayed.status(), StatusCode::BAD_REQUEST);
assert_eq!(
body_json(replayed).await["type"],
"urn:ietf:params:acme:error:badNonce"
);
}
#[tokio::test]
async fn a_get_of_a_post_only_resource_is_405_malformed() {
let app = test_app().await;
for path in ["/newAccount", "/newOrder", "/revokeCert", "/keyChange"] {
let res = app
.clone()
.oneshot(Request::get(p(path)).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED, "GET {path}");
assert_eq!(
res.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("application/problem+json"),
"GET {path} must carry a problem document, not an empty body"
);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:malformed");
assert_eq!(problem["status"], 405);
}
let res = app
.oneshot(Request::get(p("/nope")).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert_eq!(
body_json(res).await["type"],
"urn:ietf:params:acme:error:malformed"
);
}
#[tokio::test]
async fn every_resource_but_the_directory_links_to_the_index() {
let app = test_app().await;
for path in ["/newNonce", "/newAccount", "/nope"] {
let res = app
.clone()
.oneshot(Request::get(p(path)).body(Body::empty()).unwrap())
.await
.unwrap();
let links: Vec<&str> = res
.headers()
.get_all("link")
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert!(
links.contains(&format!("<{BASE}/directory>;rel=\"index\"").as_str()),
"{path} should carry the index link, got {links:?}"
);
}
let res = app
.oneshot(Request::get(p("/directory")).body(Body::empty()).unwrap())
.await
.unwrap();
assert!(
!res.headers().contains_key("link"),
"the directory itself must not carry an index link"
);
}
#[tokio::test]
async fn only_the_responses_rfc8555_asks_for_carry_a_replay_nonce() {
let app = test_app().await;
let has_nonce = |res: axum::response::Response| res.headers().contains_key("replay-nonce");
for request in [
Request::get(p("/newNonce")).body(Body::empty()).unwrap(),
Request::head(p("/newNonce")).body(Body::empty()).unwrap(),
] {
let res = app.clone().oneshot(request).await.unwrap();
assert!(has_nonce(res), "newNonce must always mint a nonce");
}
let res = app
.clone()
.oneshot(
Request::post(p("/newAccount"))
.header("content-type", "application/jose+json")
.body(Body::from("not a jws"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
assert!(
has_nonce(res),
"an error response to a POST must still carry a nonce (§6.5)"
);
for path in [
p("/directory"),
p("/crl"),
p("/ca.pem"),
p("/no-such-resource"),
] {
let res = app
.clone()
.oneshot(Request::get(&path).body(Body::empty()).unwrap())
.await
.unwrap();
assert!(
!has_nonce(res),
"GET {path} must not mint a nonce nobody asked for"
);
}
let res = app
.clone()
.oneshot(Request::get("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert!(
!has_nonce(res),
"/health is a server route, not an ACME one: it must not mint nonces"
);
}