use std::sync::Arc;
use acme_proxy_store::db::Database;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::Response;
use serde_json::json;
use tower::ServiceExt;
mod common;
use common::{EcSigner, TestSigner, body_json, fetch_nonce, p, test_app_with_db};
const BASE: &str = common::BASE;
const NEW_ACCOUNT_URL: &str = "http://localhost:3000/profile/default/newAccount";
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: &EcSigner) -> String {
let nonce = fetch_nonce(app).await;
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true }),
);
let res = post(app, &p("/newAccount"), body).await;
assert_eq!(res.status(), StatusCode::CREATED);
res.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string()
}
async fn assert_500_after_pool_close<F, Fut>(name: &str, prepare: F)
where
F: FnOnce(Router, EcSigner, Arc<Database>) -> Fut,
Fut: Future<Output = (Router, Arc<Database>, String, String)>,
{
let (app, db) = test_app_with_db().await;
let signer = EcSigner::new();
let (app, db, path, body) = prepare(app, signer, db).await;
db.close().await;
let res = post(&app, &path, body).await;
assert_eq!(
res.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"{name} should be a 500"
);
let problem = body_json(res).await;
assert_eq!(
problem["type"], "urn:ietf:params:acme:error:serverInternal",
"{name}"
);
}
async fn assert_500_after_sabotage<F, Fut>(name: &str, sabotage: &[&'static str], prepare: F)
where
F: FnOnce(Router, EcSigner, Arc<Database>) -> Fut,
Fut: Future<Output = (Router, Arc<Database>, String, String)>,
{
let (app, db) = test_app_with_db().await;
let signer = EcSigner::new();
let (app, db, path, body) = prepare(app, signer, db).await;
for statement in sabotage {
sqlx::query(*statement)
.execute(db.raw_pool())
.await
.unwrap_or_else(|error| panic!("{name}: sabotage `{statement}` failed: {error}"));
}
let res = post(&app, &path, body).await;
assert_eq!(
res.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"{name} should be a 500"
);
let problem = body_json(res).await;
assert_eq!(
problem["type"], "urn:ietf:params:acme:error:serverInternal",
"{name}"
);
}
const REFUSE_ACCOUNT_INSERT: &str = "CREATE TRIGGER refuse_account_insert BEFORE INSERT ON \
accounts BEGIN SELECT RAISE(ABORT, 'sabotage'); END;";
const REFUSE_ACCOUNT_UPDATE: &str = "CREATE TRIGGER refuse_account_update BEFORE UPDATE ON \
accounts BEGIN SELECT RAISE(ABORT, 'sabotage'); END;";
const REFUSE_ORDER_INSERT: &str = "CREATE TRIGGER refuse_order_insert BEFORE INSERT ON orders \
BEGIN SELECT RAISE(ABORT, 'sabotage'); END;";
const REFUSE_CHALLENGE_UPDATE: &str = "CREATE TRIGGER refuse_challenge_update BEFORE UPDATE ON \
challenges BEGIN SELECT RAISE(ABORT, 'sabotage'); END;";
const REFUSE_AUTHZ_UPDATE: &str = "CREATE TRIGGER refuse_authz_update BEFORE UPDATE ON \
authorizations BEGIN SELECT RAISE(ABORT, 'sabotage'); END;";
const REFUSE_JOB_INSERT: &str = "CREATE TRIGGER refuse_job_insert BEFORE INSERT ON jobs \
BEGIN SELECT RAISE(ABORT, 'sabotage'); END;";
#[tokio::test]
async fn account_creation_db_error_returns_500() {
assert_500_after_sabotage(
"newAccount persistence",
&[REFUSE_ACCOUNT_INSERT],
|app, signer, db| async move {
let nonce = fetch_nonce(&app).await;
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true }),
);
(app, db, p("/newAccount"), body)
},
)
.await;
}
#[tokio::test]
async fn only_return_existing_lookup_db_error_returns_500() {
assert_500_after_sabotage(
"newAccount onlyReturnExisting lookup",
&["DROP TABLE accounts;"],
|app, signer, db| async move {
let nonce = fetch_nonce(&app).await;
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "onlyReturnExisting": true }),
);
(app, db, p("/newAccount"), body)
},
)
.await;
}
#[tokio::test]
async fn order_transaction_db_error_returns_500() {
assert_500_after_sabotage(
"newOrder transaction",
&[REFUSE_ORDER_INSERT],
|app, signer, db| async move {
let account_url = register(&app, &signer).await;
let nonce = fetch_nonce(&app).await;
let payload = json!({ "identifiers": [{ "type": "dns", "value": "example.com" }] });
let body = signer.sign_kid(&account_url, &format!("{BASE}/newOrder"), &nonce, &payload);
(app, db, p("/newOrder"), body)
},
)
.await;
}
#[tokio::test]
async fn key_change_persist_db_error_returns_500() {
assert_500_after_sabotage(
"keyChange persistence",
&[REFUSE_ACCOUNT_UPDATE],
|app, signer, db| async move {
let account_url = register(&app, &signer).await;
let new_key = EcSigner::new();
let url = format!("{BASE}/keyChange");
let inner_payload =
json!({ "account": account_url, "oldKey": TestSigner::jwk(&signer) });
let inner: serde_json::Value =
serde_json::from_str(&new_key.sign_inner(&url, &inner_payload)).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(&account_url, &url, &nonce, &inner);
(app, db, p("/keyChange"), body)
},
)
.await;
}
#[tokio::test]
async fn challenge_list_db_error_returns_500() {
assert_500_after_sabotage(
"authorization challenge list",
&["DROP TABLE challenges;"],
|app, signer, db| async move {
let account_url = register(&app, &signer).await;
let (authz_url, _challenge_url) =
order_with_challenge(&app, &signer, &account_url).await;
let nonce = fetch_nonce(&app).await;
let path = authz_url.strip_prefix(common::HOST).unwrap().to_string();
let body = signer.sign_kid_empty(&account_url, &authz_url, &nonce);
(app, db, path, body)
},
)
.await;
}
#[tokio::test]
async fn nonce_verification_db_error_returns_500() {
assert_500_after_pool_close(
"newAccount nonce verification",
|app, signer, db| async move {
let nonce = fetch_nonce(&app).await;
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true }),
);
(app, db, p("/newAccount"), body)
},
)
.await;
}
#[tokio::test]
async fn account_update_kid_lookup_db_error_returns_500() {
assert_500_after_pool_close("kid account lookup", |app, signer, db| async move {
let account_url = register(&app, &signer).await;
let path = account_url.strip_prefix(common::HOST).unwrap().to_string();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(
&account_url,
&account_url,
&nonce,
&json!({ "contact": [] }),
);
(app, db, path, body)
})
.await;
}
#[tokio::test]
async fn new_account_nonce_db_error_returns_500() {
assert_500_after_pool_close("newAccount nonce", |app, signer, db| async move {
let nonce = fetch_nonce(&app).await;
let body = signer.sign(
&format!("{BASE}/newAccount"),
&nonce,
&json!({ "termsOfServiceAgreed": true, "contact": [] }),
);
(app, db, p("/newAccount"), body)
})
.await;
}
#[tokio::test]
async fn order_persistence_db_error_returns_500() {
assert_500_after_pool_close("newOrder persistence", |app, signer, db| async move {
let account_url = register(&app, &signer).await;
let nonce = fetch_nonce(&app).await;
let payload = json!({ "identifiers": [{ "type": "dns", "value": "example.com" }] });
let body = signer.sign_kid(&account_url, &format!("{BASE}/newOrder"), &nonce, &payload);
(app, db, p("/newOrder"), body)
})
.await;
}
#[tokio::test]
async fn middleware_drops_replay_nonce_when_db_unavailable() {
let (app, db) = test_app_with_db().await;
db.close().await;
let res = app
.clone()
.oneshot(Request::get(p("/newNonce")).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
assert!(!res.headers().contains_key("replay-nonce"));
}
async fn order_with_challenge(
app: &Router,
signer: &EcSigner,
account_url: &str,
) -> (String, String) {
let nonce = fetch_nonce(app).await;
let payload = json!({ "identifiers": [{ "type": "dns", "value": "example.com" }] });
let res = post(
app,
&p("/newOrder"),
signer.sign_kid(account_url, &format!("{BASE}/newOrder"), &nonce, &payload),
)
.await;
assert_eq!(res.status(), StatusCode::CREATED);
let authz_url = body_json(res).await["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);
let challenge_url = body_json(res).await["challenges"][0]["url"]
.as_str()
.unwrap()
.to_string();
(authz_url, challenge_url)
}
#[tokio::test]
async fn challenge_trigger_db_error_returns_500() {
assert_500_after_sabotage(
"challenge claim",
&[REFUSE_CHALLENGE_UPDATE],
|app, signer, db| async move {
let account_url = register(&app, &signer).await;
let (_authz_url, challenge_url) =
order_with_challenge(&app, &signer, &account_url).await;
let nonce = fetch_nonce(&app).await;
let path = challenge_url
.strip_prefix(common::HOST)
.unwrap()
.to_string();
let body = signer.sign_kid(&account_url, &challenge_url, &nonce, &json!({}));
(app, db, path, body)
},
)
.await;
}
#[tokio::test]
async fn challenge_lookup_db_error_returns_500() {
assert_500_after_sabotage(
"challenge lookup",
&["DROP TABLE challenges;"],
|app, signer, db| async move {
let account_url = register(&app, &signer).await;
let (_authz_url, challenge_url) =
order_with_challenge(&app, &signer, &account_url).await;
let nonce = fetch_nonce(&app).await;
let path = challenge_url
.strip_prefix(common::HOST)
.unwrap()
.to_string();
let body = signer.sign_kid(&account_url, &challenge_url, &nonce, &json!({}));
(app, db, path, body)
},
)
.await;
}
#[tokio::test]
async fn authorization_lookup_db_error_returns_500() {
assert_500_after_sabotage(
"authorization lookup",
&["DROP TABLE authorizations;"],
|app, signer, db| async move {
let account_url = register(&app, &signer).await;
let (authz_url, _challenge_url) =
order_with_challenge(&app, &signer, &account_url).await;
let nonce = fetch_nonce(&app).await;
let path = authz_url.strip_prefix(common::HOST).unwrap().to_string();
let body = signer.sign_kid_empty(&account_url, &authz_url, &nonce);
(app, db, path, body)
},
)
.await;
}
#[tokio::test]
async fn authorization_deactivation_db_error_returns_500() {
assert_500_after_sabotage(
"authz deactivation",
&[REFUSE_AUTHZ_UPDATE],
|app, signer, db| async move {
let account_url = register(&app, &signer).await;
let (authz_url, _challenge_url) =
order_with_challenge(&app, &signer, &account_url).await;
let nonce = fetch_nonce(&app).await;
let path = authz_url.strip_prefix(common::HOST).unwrap().to_string();
let body = signer.sign_kid(
&account_url,
&authz_url,
&nonce,
&json!({ "status": "deactivated" }),
);
(app, db, path, body)
},
)
.await;
}
#[tokio::test]
async fn a_challenge_whose_validation_cannot_be_queued_is_released() {
let (app, db) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let (_authz_url, challenge_url) = order_with_challenge(&app, &signer, &account_url).await;
sqlx::query(REFUSE_JOB_INSERT)
.execute(db.raw_pool())
.await
.unwrap();
let res = common::acme::trigger(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:serverInternal");
sqlx::query("DROP TRIGGER refuse_job_insert;")
.execute(db.raw_pool())
.await
.unwrap();
let challenge =
common::acme::trigger_and_settle(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(
challenge["status"], "valid",
"the claim must have been released: {challenge}"
);
}
#[tokio::test]
async fn key_change_db_error_returns_500() {
assert_500_after_pool_close("keyChange", |app, signer, db| async move {
let account_url = register(&app, &signer).await;
let new_key = EcSigner::new();
let key_change_url = format!("{BASE}/keyChange");
let inner = new_key.sign(
&key_change_url,
"",
&json!({ "account": account_url, "oldKey": signer.jwk() }),
);
let inner: serde_json::Value = serde_json::from_str(&inner).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(&account_url, &key_change_url, &nonce, &inner);
(app, db, p("/keyChange"), body)
})
.await;
}
#[tokio::test]
async fn certificate_retrieval_db_error_returns_500() {
assert_500_after_sabotage(
"certificate retrieval",
&["DROP TABLE orders;"],
|app, signer, db| async move {
let (account_url, order_url, _order) =
common::acme::ready_order(&app, &signer, &["example.com"]).await;
let order_id = order_url.rsplit('/').next().unwrap().to_string();
let certificate_url = format!("{BASE}/certificate/{order_id}");
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &certificate_url, &nonce);
(app, db, p(&format!("/certificate/{order_id}")), body)
},
)
.await;
}
#[tokio::test]
async fn renewal_info_lookup_db_error_returns_500() {
let (app, db) = test_app_with_db().await;
let signer = EcSigner::new();
let (_account_url, _order_url, pem) =
common::acme::issue_certificate(&app, &signer, &["example.com"]).await;
let leaf = acme_proxy_core::cert::leaf_der_from_chain(&pem).unwrap();
let cert_id = acme_proxy_core::cert::ari_cert_id(&leaf).unwrap();
sqlx::query("DROP TABLE orders;")
.execute(db.raw_pool())
.await
.unwrap();
let res = app
.clone()
.oneshot(
Request::get(p(&format!("/renewalInfo/{cert_id}")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:serverInternal");
}
#[tokio::test]
async fn replaces_lookup_db_error_returns_500() {
assert_500_after_pool_close("newOrder replaces lookup", |app, signer, db| async move {
let (account_url, _order_url, pem) =
common::acme::issue_certificate(&app, &signer, &["example.com"]).await;
let leaf = acme_proxy_core::cert::leaf_der_from_chain(&pem).unwrap();
let cert_id = acme_proxy_core::cert::ari_cert_id(&leaf).unwrap();
let new_order_url = format!("{BASE}/newOrder");
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(
&account_url,
&new_order_url,
&nonce,
&json!({
"identifiers": [{ "type": "dns", "value": "example.com" }],
"replaces": cert_id,
}),
);
(app, db, p("/newOrder"), body)
})
.await;
}