use std::sync::Arc;
use std::sync::atomic::Ordering;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::Response;
use serde_json::{Value, json};
use tower::ServiceExt;
mod common;
use common::{
BlockingValidator, EcSigner, RecordingValidator, StubValidator, TestSigner, body_json,
bypassing_challenges, challenges_with, fetch_nonce, make_csr, p, test_app,
test_app_with_challenges,
};
use acme_proxy_core::config::Config;
use acme_proxy_core::jws::signature::jwk_thumbprint;
const NEW_ACCOUNT_URL: &str = "http://localhost:3000/profile/default/newAccount";
const NEW_ORDER_URL: &str = "http://localhost:3000/profile/default/newOrder";
async fn post(app: &Router, path: &str, body: String) -> Response {
app.clone()
.oneshot(
Request::post(path)
.header("content-type", "application/jose+json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
async fn register(app: &Router, signer: &impl TestSigner) -> String {
let nonce = fetch_nonce(app).await;
let res = post(
app,
&p("/newAccount"),
signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true }),
),
)
.await;
assert_eq!(res.status(), StatusCode::CREATED);
res.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.expect("newAccount must set a Location header")
.to_string()
}
async fn new_order(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
names: &[&str],
) -> Response {
let identifiers: Vec<Value> = names
.iter()
.map(|name| json!({ "type": "dns", "value": name }))
.collect();
let nonce = fetch_nonce(app).await;
post(
app,
&p("/newOrder"),
signer.sign_kid(
account_url,
NEW_ORDER_URL,
&nonce,
&json!({ "identifiers": identifiers }),
),
)
.await
}
async fn read(app: &Router, signer: &impl TestSigner, account_url: &str, url: &str) -> Value {
let nonce = fetch_nonce(app).await;
let path = url
.strip_prefix(common::HOST)
.expect("URL must be under the base");
let res = post(app, path, signer.sign_kid_empty(account_url, url, &nonce)).await;
assert_eq!(res.status(), StatusCode::OK, "POST-as-GET of {url}");
body_json(res).await
}
async fn trigger(app: &Router, signer: &impl TestSigner, account_url: &str, url: &str) -> Response {
let nonce = fetch_nonce(app).await;
let path = url.strip_prefix(common::HOST).unwrap();
post(
app,
path,
signer.sign_kid(account_url, url, &nonce, &json!({})),
)
.await
}
async fn settle(app: &Router, signer: &impl TestSigner, account_url: &str, url: &str) -> Value {
let res = trigger(app, signer, account_url, url).await;
assert_eq!(res.status(), StatusCode::OK, "trigger of {url}");
await_decided(app, signer, account_url, url).await
}
async fn await_decided(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
url: &str,
) -> Value {
for _ in 0..600 {
let challenge = body_json(trigger(app, signer, account_url, url).await).await;
if challenge["status"] != "processing" {
return challenge;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
panic!("`{url}` never left `processing`");
}
fn challenge_url_of_type(authz: &Value, typ: &str) -> String {
authz["challenges"]
.as_array()
.expect("challenges must be an array")
.iter()
.find(|challenge| challenge["type"] == typ)
.unwrap_or_else(|| panic!("no {typ} challenge in {authz}"))["url"]
.as_str()
.expect("a challenge must have a URL")
.to_string()
}
#[tokio::test]
async fn the_default_configuration_offers_one_bypassing_http_01_challenge() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order = body_json(new_order(&app, &signer, &account_url, &["example.com"]).await).await;
let authz = read(
&app,
&signer,
&account_url,
order["authorizations"][0].as_str().unwrap(),
)
.await;
assert_eq!(authz["challenges"].as_array().unwrap().len(), 1);
assert_eq!(authz["challenges"][0]["type"], "http-01");
assert!(authz.get("wildcard").is_none());
let challenge_url = challenge_url_of_type(&authz, "http-01");
let challenge = settle(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(challenge["status"], "valid");
}
#[tokio::test]
async fn every_enabled_type_is_offered_with_its_own_token() {
let (app, _db) = test_app_with_challenges(
Config::default(),
bypassing_challenges(&["http-01", "dns-01", "tls-alpn-01"]),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order = body_json(new_order(&app, &signer, &account_url, &["example.com"]).await).await;
let authz = read(
&app,
&signer,
&account_url,
order["authorizations"][0].as_str().unwrap(),
)
.await;
let challenges = authz["challenges"].as_array().unwrap();
assert_eq!(challenges.len(), 3);
let types: Vec<_> = challenges
.iter()
.map(|c| c["type"].as_str().unwrap())
.collect();
assert_eq!(types, ["http-01", "dns-01", "tls-alpn-01"]);
let tokens: std::collections::BTreeSet<_> = challenges
.iter()
.map(|c| c["token"].as_str().unwrap())
.collect();
assert_eq!(tokens.len(), 3, "each challenge needs its own token");
let urls: std::collections::BTreeSet<_> = challenges
.iter()
.map(|c| c["url"].as_str().unwrap())
.collect();
assert_eq!(urls.len(), 3);
}
#[tokio::test]
async fn a_passing_validator_carries_the_order_through_to_a_certificate() {
let validator = StubValidator::passing("http-01");
let calls = validator.counter();
let (app, _db) = test_app_with_challenges(
Config::default(),
challenges_with(&["http-01"], vec![Arc::new(validator)]),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(&app, &signer, &account_url, &["example.com"]).await;
let order_url = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
let order = body_json(res).await;
let authz_url = order["authorizations"][0].as_str().unwrap().to_string();
let authz = read(&app, &signer, &account_url, &authz_url).await;
let challenge_url = challenge_url_of_type(&authz, "http-01");
let challenge = settle(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(challenge["status"], "valid");
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(
read(&app, &signer, &account_url, &authz_url).await["status"],
"valid"
);
assert_eq!(
read(&app, &signer, &account_url, &order_url).await["status"],
"ready"
);
let finalize_url = format!("{order_url}/finalize");
let nonce = fetch_nonce(&app).await;
let res = post(
&app,
finalize_url.strip_prefix(common::HOST).unwrap(),
signer.sign_kid(
&account_url,
&finalize_url,
&nonce,
&json!({ "csr": make_csr("example.com") }),
),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_json(res).await["status"], "processing");
assert_eq!(
common::acme::await_order(&app, &signer, &account_url, &order_url).await["status"],
"valid"
);
}
#[tokio::test]
async fn a_pending_authorization_and_challenge_carry_a_retry_after() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(&app, &signer, &account_url, &["example.com"]).await;
let order = body_json(res).await;
let authz_url = order["authorizations"][0].as_str().unwrap().to_string();
let nonce = fetch_nonce(&app).await;
let path = authz_url.strip_prefix(common::HOST).unwrap();
let res = post(
&app,
path,
signer.sign_kid_empty(&account_url, &authz_url, &nonce),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok()),
Some("5"),
"a pending authorization should pace the client"
);
let authz = body_json(res).await;
assert_eq!(authz["status"], "pending");
let challenge_url = challenge_url_of_type(&authz, "http-01");
let res = trigger(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok()),
Some("5"),
"a processing challenge must pace the client (RFC 8555 §8.2)"
);
assert_eq!(body_json(res).await["status"], "processing");
assert_eq!(
await_decided(&app, &signer, &account_url, &challenge_url).await["status"],
"valid"
);
let nonce = fetch_nonce(&app).await;
let res = post(
&app,
path,
signer.sign_kid_empty(&account_url, &authz_url, &nonce),
)
.await;
assert!(
res.headers().get("retry-after").is_none(),
"a decided authorization has nothing to come back for"
);
assert_eq!(body_json(res).await["status"], "valid");
}
#[tokio::test]
async fn a_failing_validator_reports_the_reason_in_the_challenge_object() {
let validator = StubValidator::failing("http-01", "served the wrong body");
let (app, _db) = test_app_with_challenges(
Config::default(),
challenges_with(&["http-01"], vec![Arc::new(validator)]),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(&app, &signer, &account_url, &["example.com"]).await;
let order_url = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
let order = body_json(res).await;
let authz_url = order["authorizations"][0].as_str().unwrap().to_string();
let authz = read(&app, &signer, &account_url, &authz_url).await;
let challenge_url = challenge_url_of_type(&authz, "http-01");
let res = trigger(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(
res.status(),
StatusCode::OK,
"a failed challenge is still a 200"
);
let links: Vec<&str> = res
.headers()
.get_all("link")
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert!(
links.iter().any(|link| link.contains("rel=\"up\"")),
"the up link must survive: {links:?}"
);
assert!(
links.iter().any(|link| link.contains("rel=\"index\"")),
"the index link must be present too: {links:?}"
);
let challenge = await_decided(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(challenge["status"], "invalid");
assert_eq!(
challenge["error"]["type"],
"urn:ietf:params:acme:error:incorrectResponse"
);
assert_eq!(challenge["error"]["detail"], "served the wrong body");
assert!(challenge.get("validated").is_none());
assert_eq!(
read(&app, &signer, &account_url, &authz_url).await["status"],
"invalid"
);
let order = read(&app, &signer, &account_url, &order_url).await;
assert_eq!(order["status"], "invalid");
assert_eq!(order["error"], challenge["error"]);
let finalize_url = format!("{order_url}/finalize");
let nonce = fetch_nonce(&app).await;
let res = post(
&app,
finalize_url.strip_prefix(common::HOST).unwrap(),
signer.sign_kid(
&account_url,
&finalize_url,
&nonce,
&json!({ "csr": make_csr("example.com") }),
),
)
.await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
assert_eq!(
body_json(res).await["type"],
"urn:ietf:params:acme:error:orderNotReady"
);
}
#[tokio::test]
async fn re_triggering_a_failed_challenge_does_not_revalidate() {
let validator = StubValidator::failing("http-01", "still wrong");
let calls = validator.counter();
let (app, _db) = test_app_with_challenges(
Config::default(),
challenges_with(&["http-01"], vec![Arc::new(validator)]),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order = body_json(new_order(&app, &signer, &account_url, &["example.com"]).await).await;
let authz = read(
&app,
&signer,
&account_url,
order["authorizations"][0].as_str().unwrap(),
)
.await;
let challenge_url = challenge_url_of_type(&authz, "http-01");
let response = trigger(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_json(response).await["status"], "processing");
let settled = await_decided(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(settled["status"], "invalid");
let again = body_json(trigger(&app, &signer, &account_url, &challenge_url).await).await;
assert_eq!(again, settled, "the stored object, unchanged");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"a decided challenge is not re-run"
);
}
#[tokio::test]
async fn a_sibling_challenge_is_not_run_once_the_authorization_is_valid() {
let passing = StubValidator::passing("http-01");
let failing = StubValidator::failing("dns-01", "no TXT record");
let dns_calls = failing.counter();
let (app, _db) = test_app_with_challenges(
Config::default(),
challenges_with(
&["http-01", "dns-01"],
vec![Arc::new(passing), Arc::new(failing)],
),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order = body_json(new_order(&app, &signer, &account_url, &["example.com"]).await).await;
let authz_url = order["authorizations"][0].as_str().unwrap().to_string();
let authz = read(&app, &signer, &account_url, &authz_url).await;
let http_url = challenge_url_of_type(&authz, "http-01");
assert_eq!(
settle(&app, &signer, &account_url, &http_url).await["status"],
"valid"
);
let dns_url = challenge_url_of_type(&authz, "dns-01");
let challenge = body_json(trigger(&app, &signer, &account_url, &dns_url).await).await;
assert_eq!(challenge["status"], "pending", "the sibling is left alone");
assert_eq!(dns_calls.load(Ordering::SeqCst), 0);
assert_eq!(
read(&app, &signer, &account_url, &authz_url).await["status"],
"valid"
);
}
#[tokio::test]
async fn an_order_is_ready_only_once_every_authorization_is_valid() {
let (app, _db) =
test_app_with_challenges(Config::default(), bypassing_challenges(&["http-01"])).await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(
&app,
&signer,
&account_url,
&["a.example.com", "b.example.com"],
)
.await;
let order_url = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
let order = body_json(res).await;
let authz_urls: Vec<String> = order["authorizations"]
.as_array()
.unwrap()
.iter()
.map(|u| u.as_str().unwrap().to_string())
.collect();
assert_eq!(authz_urls.len(), 2);
let first = read(&app, &signer, &account_url, &authz_urls[0]).await;
settle(
&app,
&signer,
&account_url,
&challenge_url_of_type(&first, "http-01"),
)
.await;
assert_eq!(
read(&app, &signer, &account_url, &order_url).await["status"],
"pending",
"one authorization out of two is not enough"
);
let second = read(&app, &signer, &account_url, &authz_urls[1]).await;
settle(
&app,
&signer,
&account_url,
&challenge_url_of_type(&second, "http-01"),
)
.await;
assert_eq!(
read(&app, &signer, &account_url, &order_url).await["status"],
"ready"
);
}
#[tokio::test]
async fn the_key_authorization_matches_what_a_client_would_compute() {
let validator = RecordingValidator::new("http-01");
let seen = validator.seen();
let (app, db) = test_app_with_challenges(
Config::default(),
challenges_with(&["http-01"], vec![Arc::new(validator)]),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order = body_json(new_order(&app, &signer, &account_url, &["example.com"]).await).await;
let authz = read(
&app,
&signer,
&account_url,
order["authorizations"][0].as_str().unwrap(),
)
.await;
let token = authz["challenges"][0]["token"]
.as_str()
.unwrap()
.to_string();
settle(
&app,
&signer,
&account_url,
&challenge_url_of_type(&authz, "http-01"),
)
.await;
let account_id = account_url.rsplit('/').next().unwrap();
let account = acme_proxy_store::account::Account::find_by_id(common::PROFILE, account_id, &db)
.await
.unwrap()
.expect("the account must exist");
let expected = format!("{token}.{}", jwk_thumbprint(&account.pubkey).unwrap());
let recorded = seen.lock().unwrap();
assert_eq!(recorded.len(), 1);
let parts: Vec<&str> = recorded[0].split('|').collect();
assert_eq!(parts[0], "example.com");
assert_eq!(parts[1], "false");
assert_eq!(parts[2], token);
assert_eq!(parts[3], expected);
}
#[tokio::test]
async fn a_wildcard_order_offers_dns_01_on_the_base_name() {
let validator = RecordingValidator::new("dns-01");
let seen = validator.seen();
let (app, _db) = test_app_with_challenges(
Config::default(),
challenges_with(&["http-01", "dns-01"], vec![Arc::new(validator)]),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(&app, &signer, &account_url, &["*.example.com"]).await;
assert_eq!(res.status(), StatusCode::CREATED);
let order_url = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
let order = body_json(res).await;
assert_eq!(order["identifiers"][0]["value"], "*.example.com");
let authz_url = order["authorizations"][0].as_str().unwrap().to_string();
let authz = read(&app, &signer, &account_url, &authz_url).await;
assert_eq!(
authz["identifier"],
json!({ "type": "dns", "value": "example.com" })
);
assert_eq!(authz["wildcard"], true);
let challenges = authz["challenges"].as_array().unwrap();
assert_eq!(challenges.len(), 1);
assert_eq!(challenges[0]["type"], "dns-01");
settle(
&app,
&signer,
&account_url,
&challenge_url_of_type(&authz, "dns-01"),
)
.await;
assert_eq!(
read(&app, &signer, &account_url, &order_url).await["status"],
"ready"
);
let recorded = seen.lock().unwrap();
let parts: Vec<&str> = recorded[0].split('|').collect();
assert_eq!(parts[0], "example.com");
assert_eq!(parts[1], "true");
}
#[tokio::test]
async fn a_name_and_its_wildcard_get_separate_authorizations() {
let (app, _db) = test_app_with_challenges(
Config::default(),
bypassing_challenges(&["http-01", "dns-01"]),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(
&app,
&signer,
&account_url,
&["example.com", "*.example.com"],
)
.await;
assert_eq!(
res.status(),
StatusCode::CREATED,
"the two must not collide"
);
let order_url = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
let order = body_json(res).await;
let authz_urls: Vec<String> = order["authorizations"]
.as_array()
.unwrap()
.iter()
.map(|u| u.as_str().unwrap().to_string())
.collect();
assert_eq!(authz_urls.len(), 2);
let plain = read(&app, &signer, &account_url, &authz_urls[0]).await;
let wildcard = read(&app, &signer, &account_url, &authz_urls[1]).await;
assert_eq!(plain["identifier"]["value"], "example.com");
assert!(plain.get("wildcard").is_none());
assert_eq!(plain["challenges"].as_array().unwrap().len(), 2);
assert_eq!(wildcard["identifier"]["value"], "example.com");
assert_eq!(wildcard["wildcard"], true);
assert_eq!(wildcard["challenges"].as_array().unwrap().len(), 1);
settle(
&app,
&signer,
&account_url,
&challenge_url_of_type(&plain, "http-01"),
)
.await;
assert_eq!(
read(&app, &signer, &account_url, &order_url).await["status"],
"pending"
);
settle(
&app,
&signer,
&account_url,
&challenge_url_of_type(&wildcard, "dns-01"),
)
.await;
assert_eq!(
read(&app, &signer, &account_url, &order_url).await["status"],
"ready"
);
}
#[tokio::test]
async fn a_wildcard_order_issues_a_wildcard_certificate() {
let (app, _db) =
test_app_with_challenges(Config::default(), bypassing_challenges(&["dns-01"])).await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(&app, &signer, &account_url, &["*.example.com"]).await;
let order_url = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
let order = body_json(res).await;
let authz = read(
&app,
&signer,
&account_url,
order["authorizations"][0].as_str().unwrap(),
)
.await;
settle(
&app,
&signer,
&account_url,
&challenge_url_of_type(&authz, "dns-01"),
)
.await;
let finalize_url = format!("{order_url}/finalize");
let nonce = fetch_nonce(&app).await;
let res = post(
&app,
finalize_url.strip_prefix(common::HOST).unwrap(),
signer.sign_kid(
&account_url,
&finalize_url,
&nonce,
&json!({ "csr": make_csr("*.example.com") }),
),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
let cert_url =
common::acme::await_order(&app, &signer, &account_url, &order_url).await["certificate"]
.as_str()
.expect("an issued order has a certificate URL")
.to_string();
let nonce = fetch_nonce(&app).await;
let res = post(
&app,
cert_url.strip_prefix(common::HOST).unwrap(),
signer.sign_kid_empty(&account_url, &cert_url, &nonce),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
let pem = String::from_utf8(
http_body_util::BodyExt::collect(res.into_body())
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
let leaf = pem
.split("-----END CERTIFICATE-----")
.next()
.and_then(|block| block.split("-----BEGIN CERTIFICATE-----").nth(1))
.map(|body| {
use base64::prelude::*;
BASE64_STANDARD
.decode(body.replace(['\n', '\r'], ""))
.unwrap()
})
.expect("a leaf certificate");
let (_, parsed) = x509_parser::parse_x509_certificate(&leaf).unwrap();
let sans = &parsed
.subject_alternative_name()
.unwrap()
.unwrap()
.value
.general_names;
assert!(
matches!(&sans[0], x509_parser::extensions::GeneralName::DNSName(name)
if *name == "*.example.com"),
"{sans:?}"
);
}
#[tokio::test]
async fn a_wildcard_is_refused_when_dns_01_is_not_enabled() {
let (app, _db) = test_app_with_challenges(
Config::default(),
bypassing_challenges(&["http-01", "tls-alpn-01"]),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(&app, &signer, &account_url, &["*.example.com"]).await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
let problem = body_json(res).await;
assert_eq!(
problem["type"],
"urn:ietf:params:acme:error:rejectedIdentifier"
);
assert!(problem["detail"].as_str().unwrap().contains("dns-01"));
}
#[tokio::test]
async fn a_malformed_wildcard_is_a_400_even_with_dns_01_enabled() {
let (app, _db) =
test_app_with_challenges(Config::default(), bypassing_challenges(&["dns-01"])).await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
for value in ["*example.com", "*.*.example.com", "a.*.example.com", "*"] {
let res = new_order(&app, &signer, &account_url, &[value]).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "{value}");
assert_eq!(
body_json(res).await["type"],
"urn:ietf:params:acme:error:malformed",
"{value}"
);
}
}
#[tokio::test]
async fn two_overlapping_triggers_validate_once() {
let validator = BlockingValidator::gating_the_first("http-01");
let (calls, gate, entered) = validator.handles();
let (app, _db) = test_app_with_challenges(
Config::default(),
challenges_with(&["http-01"], vec![Arc::new(validator)]),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order = body_json(new_order(&app, &signer, &account_url, &["example.com"]).await).await;
let authz = read(
&app,
&signer,
&account_url,
order["authorizations"][0].as_str().unwrap(),
)
.await;
let challenge_url = challenge_url_of_type(&authz, "http-01");
let path = challenge_url
.strip_prefix(common::HOST)
.unwrap()
.to_string();
let nonce_a = fetch_nonce(&app).await;
let body_a = signer.sign_kid(&account_url, &challenge_url, &nonce_a, &json!({}));
let nonce_b = fetch_nonce(&app).await;
let body_b = signer.sign_kid(&account_url, &challenge_url, &nonce_b, &json!({}));
let first = tokio::spawn({
let (app, path) = (app.clone(), path.clone());
async move { post(&app, &path, body_a).await }
});
let _ = entered.acquire().await.unwrap();
let second = post(&app, &path, body_b).await;
assert_eq!(second.status(), StatusCode::OK);
gate.add_permits(1);
assert_eq!(first.await.unwrap().status(), StatusCode::OK);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"an overlapping trigger must not start a second validation"
);
}
#[tokio::test]
async fn a_trigger_over_the_accounts_validation_cap_is_rate_limited() {
let validator = BlockingValidator::gating_the_first("http-01");
let (_calls, gate, entered) = validator.handles();
let (app, _db) = test_app_with_challenges(
Config::default(),
Arc::new(
Arc::try_unwrap(challenges_with(&["http-01"], vec![Arc::new(validator)]))
.expect("the registry is not shared yet")
.with_max_in_flight_per_account(1),
),
)
.await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let mut challenge_paths = Vec::new();
for name in ["a.example.com", "b.example.com"] {
let order = body_json(new_order(&app, &signer, &account_url, &[name]).await).await;
let authz = read(
&app,
&signer,
&account_url,
order["authorizations"][0].as_str().unwrap(),
)
.await;
challenge_paths.push(challenge_url_of_type(&authz, "http-01"));
}
let first_url = challenge_paths[0].clone();
let second_url = challenge_paths[1].clone();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(&account_url, &first_url, &nonce, &json!({}));
let first = tokio::spawn({
let (app, path) = (
app.clone(),
first_url.strip_prefix(common::HOST).unwrap().to_string(),
);
async move { post(&app, &path, body).await }
});
let _ = entered.acquire().await.unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(&account_url, &second_url, &nonce, &json!({}));
let res = post(&app, second_url.strip_prefix(common::HOST).unwrap(), body).await;
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers().contains_key("retry-after"));
assert_eq!(
body_json(res).await["type"],
"urn:ietf:params:acme:error:rateLimited"
);
gate.add_permits(1);
assert_eq!(first.await.unwrap().status(), StatusCode::OK);
let decided = await_decided(&app, &signer, &account_url, &first_url).await;
assert_eq!(decided["status"], "valid");
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(&account_url, &second_url, &nonce, &json!({}));
let res = post(&app, second_url.strip_prefix(common::HOST).unwrap(), body).await;
assert_eq!(res.status(), StatusCode::OK);
}