mod common;
use acme_proxy::admin::password::PasswordContext;
use acme_proxy::audit::{Actor, AuditEvent, AuditRecord, ClientContext};
use acme_proxy::sqlite::admin_session::AdminSession;
use acme_proxy::sqlite::audit::AuditEntry;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use common::*;
use serde_json::json;
#[tokio::test]
async fn login_sets_a_hardened_cookie_and_returns_a_csrf_token() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database,
)
.await
.unwrap();
let response = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let set_cookie = response
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(set_cookie.starts_with("__Host-acme_admin_session="));
assert!(set_cookie.contains("HttpOnly"));
assert!(set_cookie.contains("Secure"));
assert!(set_cookie.contains("SameSite=Strict"));
assert!(set_cookie.contains("Path=/"));
assert!(!set_cookie.contains("Domain"));
let body = json_body(response).await;
assert_eq!(body["user"]["username"], "alice");
assert!(body["csrfToken"].as_str().is_some_and(|t| t.len() > 20));
assert!(body["expiresAt"].as_str().is_some());
assert!(!body.to_string().contains("pbkdf2"));
}
#[tokio::test]
async fn every_login_failure_is_indistinguishable_to_the_client() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
acme_proxy::admin::users::create_user(
"bob",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
acme_proxy::admin::users::set_status(
"bob",
acme_proxy::sqlite::admin_user::AdminStatus::Disabled,
database,
)
.await
.unwrap();
let mut bodies = Vec::new();
for (username, password) in [
("alice", "the-wrong-password"),
("nobody-at-all", ADMIN_PASSWORD),
("bob", ADMIN_PASSWORD),
] {
let response = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": username, "password": password })),
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"for {username}"
);
assert!(
session_cookie_token(&response).is_none(),
"a failed login must not set a session cookie"
);
bodies.push(json_body(response).await);
}
assert_eq!(bodies[0], bodies[1]);
assert_eq!(bodies[1], bodies[2]);
assert_eq!(bodies[0]["error"], "invalid_credentials");
}
#[tokio::test]
async fn login_is_rate_limited_before_the_password_hash_runs() {
let mut config = admin_config();
config.admin.login_max_attempts = 2;
let (app, database) = test_admin_app(config).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database,
)
.await
.unwrap();
let attempt = |password: &'static str| {
let app = app.clone();
async move {
admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": password })),
)
.await
}
};
for _ in 0..2 {
assert_eq!(attempt("wrong").await.status(), StatusCode::UNAUTHORIZED);
}
let limited = attempt("wrong").await;
assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(limited.headers().contains_key(header::RETRY_AFTER));
assert_eq!(json_body(limited).await["error"], "rate_limited");
assert_eq!(
attempt(ADMIN_PASSWORD).await.status(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[tokio::test]
async fn a_second_login_without_a_cookie_is_a_second_independent_session() {
let (app, _database, first) = test_admin_app_logged_in(admin_config()).await;
let second = admin_login(&app, "alice", ADMIN_PASSWORD).await;
assert_ne!(
first.cookie, second.cookie,
"each login mints its own token"
);
assert_ne!(first.csrf, second.csrf, "and its own CSRF token");
for session in [&first, &second] {
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(session), None)
.await
.status(),
StatusCode::OK
);
}
}
#[tokio::test]
async fn logging_in_with_a_cookie_already_set_destroys_that_session() {
let (app, _database, planted) = test_admin_app_logged_in(admin_config()).await;
let response = admin_request(
&app,
Method::POST,
"/api/session",
Some(&planted),
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let fresh_cookie = session_cookie_token(&response).expect("a new cookie must be set");
assert_ne!(fresh_cookie, planted.cookie, "the token must rotate");
let refused = admin_request(&app, Method::GET, "/api/session", Some(&planted), None).await;
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(refused).await["error"], "session_invalid");
}
#[tokio::test]
async fn whoami_needs_a_session_and_reports_the_operator() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let anonymous = admin_request(&app, Method::GET, "/api/session", None, None).await;
assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(anonymous).await["error"], "session_invalid");
let body =
json_body(admin_request(&app, Method::GET, "/api/session", Some(&session), None).await)
.await;
assert_eq!(body["user"]["username"], "alice");
assert_eq!(body["csrfToken"], session.csrf);
}
#[tokio::test]
async fn logout_clears_the_cookie_and_the_session_stops_working() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let response = admin_request(&app, Method::DELETE, "/api/session", Some(&session), None).await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let set_cookie = response
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(set_cookie.contains("Max-Age=0"));
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&session), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn a_session_deleted_out_from_under_the_request_stops_authenticating() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let user = acme_proxy::sqlite::admin_user::AdminUser::find_by_username("alice", &database)
.await
.unwrap()
.unwrap();
acme_proxy::sqlite::admin_session::AdminSession::delete_for_user(user.id, &database)
.await
.unwrap();
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&session), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn an_expired_session_is_refused_and_swept() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let hash = acme_proxy::webadmin::session::hash_token(&session.cookie);
sqlx::query("UPDATE admin_sessions SET expires_at = 1 WHERE token_hash = ?;")
.bind(&hash)
.execute(&database.pool)
.await
.unwrap();
let response = admin_request(&app, Method::GET, "/api/session", Some(&session), None).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(response).await["error"], "session_expired");
assert!(
acme_proxy::sqlite::admin_session::AdminSession::find_by_token_hash(&hash, &database)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn an_idle_session_is_refused_with_its_own_code() {
let mut config = admin_config();
config.admin.session_idle_timeout_seconds = 60;
let (app, database, session) = test_admin_app_logged_in(config).await;
let hash = acme_proxy::webadmin::session::hash_token(&session.cookie);
sqlx::query(
"UPDATE admin_sessions SET last_seen_at = last_seen_at - 600 WHERE token_hash = ?;",
)
.bind(&hash)
.execute(&database.pool)
.await
.unwrap();
let response = admin_request(&app, Method::GET, "/api/session", Some(&session), None).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(response).await["error"], "session_idle");
}
#[tokio::test]
async fn disabling_an_operator_stops_their_live_session() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&session), None)
.await
.status(),
StatusCode::OK
);
acme_proxy::admin::users::set_status(
"alice",
acme_proxy::sqlite::admin_user::AdminStatus::Disabled,
database,
)
.await
.unwrap();
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&session), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn a_factorless_login_is_completely_unchanged() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database,
)
.await
.unwrap();
let response = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let cookie = session_cookie_token(&response).expect("login must set the session cookie");
let body = json_body(response).await;
assert_eq!(body["user"]["username"], "alice");
assert!(body["csrfToken"].as_str().is_some());
assert!(
body.get("mfaRequired").is_none(),
"an operator with no factor must see the pre-MFA response shape: {body}"
);
let session = AdminSessionHandle {
cookie,
csrf: body["csrfToken"].as_str().unwrap().to_string(),
};
assert_eq!(
admin_request(&app, Method::GET, "/api/accounts", Some(&session), None)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn a_factor_bearing_login_stops_half_way_and_says_so() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
enrol_totp(database, "alice").await;
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle { cookie, csrf };
let response = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
let body = json_body(response).await;
assert_eq!(body["mfaRequired"], true);
assert_eq!(body["step"], "verify");
assert!(
body.get("user").is_none(),
"a pending session must not read operator metadata: {body}"
);
for path in ["/api/session", "/api/accounts", "/api/orders", "/api/eab"] {
assert_eq!(
admin_request(&app, Method::GET, path, Some(&pending), None)
.await
.status(),
StatusCode::UNAUTHORIZED,
"{path} must refuse a half-authenticated session"
);
}
let step = admin_request(&app, Method::GET, "/api/session/mfa", Some(&pending), None).await;
assert_eq!(step.status(), StatusCode::OK);
assert_eq!(json_body(step).await["step"], "verify");
}
#[tokio::test]
async fn a_valid_code_promotes_the_session_onto_a_brand_new_token() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let secret = enrol_totp(database.clone(), "alice").await;
sqlx::query("UPDATE admin_users SET totp_last_step = NULL WHERE username = 'alice';")
.execute(&database.pool)
.await
.unwrap();
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle {
cookie: cookie.clone(),
csrf,
};
let response = admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(&pending),
Some(json!({ "code": totp_code(&secret, 0) })),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let promoted = session_cookie_token(&response).expect("promotion must set a new cookie");
assert_ne!(
promoted, cookie,
"the pending token crossed the wire before authentication finished; \
its privilege level changing means its value changes"
);
let body = json_body(response).await;
assert_eq!(body["user"]["username"], "alice");
let active = AdminSessionHandle {
cookie: promoted,
csrf: body["csrfToken"].as_str().unwrap().to_string(),
};
assert_ne!(active.csrf, pending.csrf, "the CSRF token rotates with it");
assert_eq!(
admin_request(&app, Method::GET, "/api/accounts", Some(&active), None)
.await
.status(),
StatusCode::OK
);
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&pending), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn a_code_cannot_be_spent_twice() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let secret = enrol_totp(database.clone(), "alice").await;
sqlx::query("UPDATE admin_users SET totp_last_step = NULL WHERE username = 'alice';")
.execute(&database.pool)
.await
.unwrap();
let code = totp_code(&secret, 0);
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let first = AdminSessionHandle { cookie, csrf };
assert_eq!(
admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(&first),
Some(json!({ "code": code.clone() })),
)
.await
.status(),
StatusCode::OK
);
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let second = AdminSessionHandle { cookie, csrf };
let replayed = admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(&second),
Some(json!({ "code": code })),
)
.await;
assert_eq!(replayed.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(replayed).await["error"], "invalid_credentials");
}
#[tokio::test]
async fn a_wrong_code_is_indistinguishable_from_a_wrong_password() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
enrol_totp(database, "alice").await;
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle { cookie, csrf };
let wrong_password = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": "not-the-password" })),
)
.await;
let password_status = wrong_password.status();
let password_body = json_body(wrong_password).await;
for code in ["000000", "12345", "abcdef", "ZZZZZZZZZZ"] {
let response = admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(&pending),
Some(json!({ "code": code })),
)
.await;
assert_eq!(response.status(), password_status, "code {code:?}");
assert_eq!(json_body(response).await, password_body, "code {code:?}");
}
}
#[tokio::test]
async fn the_code_step_shares_the_login_limiter() {
let mut config = admin_config();
config.admin.login_max_attempts = 3;
let (app, database) = test_admin_app(config).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let secret = enrol_totp(database, "alice").await;
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let first = AdminSessionHandle { cookie, csrf };
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let second = AdminSessionHandle { cookie, csrf };
for pending in [&first, &first, &second] {
assert_eq!(
admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(pending),
Some(json!({ "code": "000000" })),
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
let refused = admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(&second),
Some(json!({ "code": totp_code(&secret, 1) })),
)
.await;
assert_eq!(refused.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(refused.headers().contains_key(header::RETRY_AFTER));
assert_eq!(
admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await
.status(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[tokio::test]
async fn the_code_step_is_bounded_per_session_and_not_only_per_address() {
let mut config = admin_config();
config.admin.login_max_attempts = 3;
let (app, database) = test_admin_app(config).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let secret = enrol_totp(database.clone(), "alice").await;
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle { cookie, csrf };
let token_hash = acme_proxy::webadmin::session::hash_token(&pending.cookie);
for attempt in 0..3 {
let response = admin_request_from(
&app,
Method::POST,
"/api/session/mfa",
Some(&pending),
Some(json!({ "code": "000000" })),
&format!("203.0.113.{}:40000", attempt + 1),
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"attempt {attempt} must be a plain refusal, never a rate-limit"
);
}
assert!(
AdminSession::find_by_token_hash(&token_hash, &database)
.await
.unwrap()
.is_none(),
"past the cap the half-authenticated row is gone, not merely refused"
);
let response = admin_request_from(
&app,
Method::POST,
"/api/session/mfa",
Some(&pending),
Some(json!({ "code": totp_code(&secret, 1) })),
"203.0.113.9:40000",
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn the_password_step_does_not_clear_the_limiter_while_a_factor_is_outstanding() {
let mut config = admin_config();
config.admin.login_max_attempts = 3;
let (app, database) = test_admin_app(config).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
enrol_totp(database, "alice").await;
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle { cookie, csrf };
for _ in 0..3 {
admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(&pending),
Some(json!({ "code": "000000" })),
)
.await;
admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
}
assert_eq!(
admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await
.status(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[tokio::test]
async fn last_login_is_stamped_at_promotion_not_at_the_password() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let secret = enrol_totp(database.clone(), "alice").await;
sqlx::query("UPDATE admin_users SET totp_last_step = NULL WHERE username = 'alice';")
.execute(&database.pool)
.await
.unwrap();
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle { cookie, csrf };
let halfway = acme_proxy::sqlite::admin_user::AdminUser::find_by_username("alice", &database)
.await
.unwrap()
.unwrap();
assert!(
halfway.last_login_at.is_none(),
"a password accepted is not a login completed"
);
admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(&pending),
Some(json!({ "code": totp_code(&secret, 0) })),
)
.await;
let after = acme_proxy::sqlite::admin_user::AdminUser::find_by_username("alice", &database)
.await
.unwrap()
.unwrap();
assert!(after.last_login_at.is_some());
}
#[tokio::test]
async fn a_recovery_code_finishes_a_login_and_is_then_spent() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
enrol_totp(database.clone(), "alice").await;
let user = acme_proxy::sqlite::admin_user::AdminUser::find_by_username("alice", &database)
.await
.unwrap()
.unwrap();
let codes = acme_proxy::admin::mfa::regenerate_recovery_codes(&user, database.clone())
.await
.unwrap();
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle { cookie, csrf };
let response = admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(&pending),
Some(json!({ "code": codes[0].clone() })),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
acme_proxy::admin::mfa::recovery_codes_remaining(user.id, database.clone())
.await
.unwrap(),
9
);
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let second = AdminSessionHandle { cookie, csrf };
assert_eq!(
admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(&second),
Some(json!({ "code": codes[0].clone() })),
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
acme_proxy::admin::mfa::recovery_codes_remaining(user.id, database)
.await
.unwrap(),
9
);
}
#[tokio::test]
async fn enrolment_shows_the_secret_once_and_the_codes_once() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let status =
json_body(admin_request(&app, Method::GET, "/api/mfa", Some(&session), None).await).await;
assert_eq!(status["totpEnabled"], false);
assert_eq!(status["enrolmentPending"], false);
assert_eq!(status["recoveryCodesRemaining"], 0);
let begun = admin_request(
&app,
Method::POST,
"/api/mfa/totp",
Some(&session),
Some(json!({})),
)
.await;
assert_eq!(begun.status(), StatusCode::CREATED);
let enrolment = json_body(begun).await;
let secret_base32 = enrolment["secret"].as_str().unwrap().to_string();
assert_eq!(secret_base32.len(), 32);
assert!(!secret_base32.contains('='));
assert!(
enrolment["uri"]
.as_str()
.unwrap()
.starts_with("otpauth://totp/acme-proxy:alice@")
);
assert_eq!(enrolment["algorithm"], "SHA1");
assert_eq!(enrolment["digits"], 6);
assert_eq!(enrolment["period"], 30);
let status =
json_body(admin_request(&app, Method::GET, "/api/mfa", Some(&session), None).await).await;
assert_eq!(status["totpEnabled"], false);
assert_eq!(status["enrolmentPending"], true);
assert!(
!status.to_string().contains(&secret_base32),
"the secret is shown exactly once: {status}"
);
let refused = admin_request(
&app,
Method::POST,
"/api/mfa/totp/confirm",
Some(&session),
Some(json!({ "code": "000000" })),
)
.await;
assert_eq!(refused.status(), StatusCode::BAD_REQUEST);
let secret = base32_decode(&secret_base32);
let confirmed = admin_request(
&app,
Method::POST,
"/api/mfa/totp/confirm",
Some(&session),
Some(json!({ "code": totp_code(&secret, 0) })),
)
.await;
assert_eq!(confirmed.status(), StatusCode::OK);
assert!(
session_cookie_token(&confirmed).is_none(),
"an already-active session has nothing to promote"
);
let body = json_body(confirmed).await;
let codes = body["recoveryCodes"].as_array().unwrap().clone();
assert_eq!(codes.len(), 10);
let status =
json_body(admin_request(&app, Method::GET, "/api/mfa", Some(&session), None).await).await;
assert_eq!(status["totpEnabled"], true);
assert_eq!(status["enrolmentPending"], false);
assert_eq!(status["recoveryCodesRemaining"], 10);
for code in &codes {
assert!(
!status.to_string().contains(code.as_str().unwrap()),
"recovery codes are shown exactly once"
);
}
}
#[tokio::test]
async fn a_step_up_password_is_rate_limited_and_shares_the_sign_in_budget() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let secret = enrol_totp(database.clone(), "alice").await;
let session = admin_login_mfa(&app, database, "alice", ADMIN_PASSWORD, &secret).await;
let peer = "203.0.113.9:1234";
for _ in 0..5 {
let refused = admin_request_from(
&app,
Method::POST,
"/api/mfa/totp",
Some(&session),
Some(json!({ "password": "not the password" })),
peer,
)
.await;
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(refused).await["error"], "invalid_credentials");
}
let limited = admin_request_from(
&app,
Method::POST,
"/api/mfa/totp",
Some(&session),
Some(json!({ "password": ADMIN_PASSWORD })),
peer,
)
.await;
assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(limited.headers().contains_key("retry-after"));
let login = send_from(
&app,
Request::builder()
.method(Method::POST)
.uri("/api/session")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({ "username": "alice", "password": ADMIN_PASSWORD }).to_string(),
))
.unwrap(),
peer,
)
.await;
assert_eq!(login.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn recovery_codes_can_be_reissued_and_supersede_the_previous_set() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let session = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let refused = admin_request(
&app,
Method::POST,
"/api/mfa/recovery-codes",
Some(&session),
Some(json!({})),
)
.await;
assert_eq!(refused.status(), StatusCode::CONFLICT);
assert_eq!(json_body(refused).await["error"], "mfa_not_enabled");
let secret = enrol_totp(database.clone(), "alice").await;
let session = admin_login_mfa(&app, database, "alice", ADMIN_PASSWORD, &secret).await;
let first = json_body(
admin_request(
&app,
Method::POST,
"/api/mfa/recovery-codes",
Some(&session),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await,
)
.await;
let second = json_body(
admin_request(
&app,
Method::POST,
"/api/mfa/recovery-codes",
Some(&session),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await,
)
.await;
assert_ne!(first["recoveryCodes"], second["recoveryCodes"]);
let stale = first["recoveryCodes"][0].as_str().unwrap().to_string();
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle { cookie, csrf };
assert_eq!(
admin_request(
&app,
Method::POST,
"/api/session/mfa",
Some(&pending),
Some(json!({ "code": stale })),
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn require_mfa_makes_a_factorless_operator_enrol_before_the_session_works() {
let mut config = admin_config();
config.admin.require_mfa = true;
let (app, database) = test_admin_app(config).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database,
)
.await
.unwrap();
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle { cookie, csrf };
let step =
json_body(admin_request(&app, Method::GET, "/api/session/mfa", Some(&pending), None).await)
.await;
assert_eq!(step["step"], "enrol");
assert_eq!(
admin_request(&app, Method::GET, "/api/accounts", Some(&pending), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
let begun = admin_request(
&app,
Method::POST,
"/api/mfa/totp",
Some(&pending),
Some(json!({})),
)
.await;
assert_eq!(begun.status(), StatusCode::CREATED);
let secret = base32_decode(json_body(begun).await["secret"].as_str().unwrap());
let confirmed = admin_request(
&app,
Method::POST,
"/api/mfa/totp/confirm",
Some(&pending),
Some(json!({ "code": totp_code(&secret, 0) })),
)
.await;
assert_eq!(confirmed.status(), StatusCode::OK);
let promoted = session_cookie_token(&confirmed)
.expect("finishing enrolment finishes the login, so the token rotates");
assert_ne!(promoted, pending.cookie);
let body = json_body(confirmed).await;
assert_eq!(body["recoveryCodes"].as_array().unwrap().len(), 10);
let active = AdminSessionHandle {
cookie: promoted,
csrf: String::new(),
};
let whoami = admin_request(&app, Method::GET, "/api/session", Some(&active), None).await;
assert_eq!(whoami.status(), StatusCode::OK);
let whoami = json_body(whoami).await;
assert_eq!(whoami["user"]["username"], "alice");
assert_eq!(whoami["user"]["totpEnabled"], true);
}
#[tokio::test]
async fn a_session_that_owes_a_code_cannot_enrol_its_way_past_it() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
enrol_totp(database, "alice").await;
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle { cookie, csrf };
for (method, path) in [
(Method::POST, "/api/mfa/totp"),
(Method::POST, "/api/mfa/totp/confirm"),
(Method::POST, "/api/mfa/recovery-codes"),
(Method::DELETE, "/api/mfa/totp"),
(Method::GET, "/api/mfa"),
] {
let response = admin_request(
&app,
method.clone(),
path,
Some(&pending),
Some(json!({ "code": "123456" })),
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"{method} {path} must refuse a session that owes a code"
);
}
}
#[tokio::test]
async fn disabling_the_factor_is_refused_while_require_mfa_is_on() {
let mut config = admin_config();
config.admin.require_mfa = true;
let (app, database) = test_admin_app(config).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let secret = enrol_totp(database.clone(), "alice").await;
let session = admin_login_mfa(&app, database, "alice", ADMIN_PASSWORD, &secret).await;
let refused = admin_request(
&app,
Method::DELETE,
"/api/mfa/totp",
Some(&session),
Some(json!({})),
)
.await;
assert_eq!(refused.status(), StatusCode::CONFLICT);
assert_eq!(json_body(refused).await["error"], "mfa_required");
}
#[tokio::test]
async fn disabling_the_factor_clears_the_codes_and_the_other_sessions() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let secret = enrol_totp(database.clone(), "alice").await;
let other = admin_login_mfa(&app, database.clone(), "alice", ADMIN_PASSWORD, &secret).await;
let session = admin_login_mfa(&app, database, "alice", ADMIN_PASSWORD, &secret).await;
assert_eq!(
admin_request(
&app,
Method::DELETE,
"/api/mfa/totp",
Some(&session),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await
.status(),
StatusCode::NO_CONTENT
);
let status =
json_body(admin_request(&app, Method::GET, "/api/mfa", Some(&session), None).await).await;
assert_eq!(status["totpEnabled"], false);
assert_eq!(status["recoveryCodesRemaining"], 0);
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&other), None)
.await
.status(),
StatusCode::UNAUTHORIZED,
"a factor removed that left another session alive is a change in name only"
);
let response = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
assert!(json_body(response).await.get("mfaRequired").is_none());
}
#[tokio::test]
async fn the_mfa_step_routes_refuse_a_completed_session() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
for method in [Method::GET, Method::POST] {
let response = admin_request(
&app,
method.clone(),
"/api/session/mfa",
Some(&session),
Some(json!({ "code": "123456" })),
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"{method} /api/session/mfa must refuse a session that has nothing outstanding"
);
}
}
const NEW_PASSWORD: &str = "a-different-long-password";
#[tokio::test]
async fn password_change_updates_the_hash_keeps_the_session_and_revokes_every_other() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let other = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let session = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let response = admin_request(
&app,
Method::POST,
"/api/account/password",
Some(&session),
Some(json!({
"current_password": ADMIN_PASSWORD,
"new_password": NEW_PASSWORD,
})),
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&session), None)
.await
.status(),
StatusCode::OK,
"the session that made the change must not sign itself out"
);
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&other), None)
.await
.status(),
StatusCode::UNAUTHORIZED,
"a password changed that left another session alive is a change in name only"
);
let login = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": NEW_PASSWORD })),
)
.await;
assert_eq!(login.status(), StatusCode::OK);
let refused = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn password_change_refuses_the_wrong_current_password() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let response = admin_request(
&app,
Method::POST,
"/api/account/password",
Some(&session),
Some(json!({
"current_password": "not the password",
"new_password": NEW_PASSWORD,
})),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(response).await["error"], "invalid_credentials");
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&session), None)
.await
.status(),
StatusCode::OK
);
let refused = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(
refused.status(),
StatusCode::OK,
"the old password must still work: nothing was written"
);
}
#[tokio::test]
async fn password_change_refuses_a_new_password_that_fails_the_policy() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let response = admin_request(
&app,
Method::POST,
"/api/account/password",
Some(&session),
Some(json!({
"current_password": ADMIN_PASSWORD,
"new_password": "short",
})),
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = json_body(response).await;
assert_eq!(body["error"], "bad_request");
assert!(body["message"].as_str().unwrap().contains("at least 12"));
let refused = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(
refused.status(),
StatusCode::OK,
"the old password still works"
);
}
fn mfa_step_endpoints() -> Vec<(Method, &'static str)> {
vec![
(Method::POST, "/api/session/mfa"),
(Method::POST, "/ui/login/mfa"),
]
}
#[tokio::test]
async fn the_mfa_step_endpoints_need_a_pending_session_and_the_origin_gate() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
enrol_totp(database, "alice").await;
for (method, path) in mfa_step_endpoints() {
let anonymous = admin_request(&app, method.clone(), path, None, Some(json!({}))).await;
assert!(
anonymous.status().is_client_error() || anonymous.status().is_redirection(),
"{method} {path} must refuse an anonymous caller, got {}",
anonymous.status()
);
let (cookie, csrf) = admin_login_pending(&app, "alice", ADMIN_PASSWORD).await;
let pending = AdminSessionHandle { cookie, csrf };
let cross_origin = send_from(
&app,
Request::builder()
.method(method.clone())
.uri(path)
.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", pending.cookie),
)
.header(header::ORIGIN, "https://evil.example.com")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(json!({ "code": "123456" }).to_string()))
.unwrap(),
"127.0.0.1:40000",
)
.await;
assert_eq!(
cross_origin.status(),
StatusCode::FORBIDDEN,
"{method} {path} must be covered by the origin gate, which is what \
stands in for the CSRF token it cannot have"
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RequiredTier {
SelfService,
Operator,
Admin,
}
fn mutating_endpoints() -> Vec<(Method, &'static str, RequiredTier)> {
use RequiredTier::{Admin, Operator, SelfService};
vec![
(Method::PATCH, "/api/accounts/some-id", Operator),
(Method::POST, "/api/accounts/some-id/deactivate", Operator),
(Method::DELETE, "/api/accounts/some-id", Operator),
(Method::POST, "/api/orders/some-id/revoke", Operator),
(Method::DELETE, "/api/orders/some-id", Operator),
(Method::POST, "/api/jobs/some-id/cancel", Operator),
(Method::POST, "/api/jobs/some-id/run", Operator),
(Method::POST, "/api/eab", Operator),
(Method::POST, "/api/eab/some-kid/revoke", Operator),
(Method::POST, "/api/nonces/cleanup", Operator),
(Method::DELETE, "/api/session", SelfService),
(Method::POST, "/api/mfa/totp", SelfService),
(Method::POST, "/api/mfa/totp/confirm", SelfService),
(Method::DELETE, "/api/mfa/totp", SelfService),
(Method::POST, "/api/mfa/recovery-codes", SelfService),
(Method::POST, "/api/account/password", SelfService),
(
Method::POST,
"/api/account/sessions/some-id/revoke",
SelfService,
),
(Method::POST, "/api/operators/some-username/disable", Admin),
(Method::POST, "/api/operators/some-username/enable", Admin),
(
Method::POST,
"/api/operators/some-username/totp/reset",
Admin,
),
(
Method::POST,
"/api/operators/some-username/sessions/some-id/revoke",
Admin,
),
]
}
#[tokio::test]
async fn every_mutating_endpoint_refuses_a_missing_csrf_token() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
for (method, path, _tier) in mutating_endpoints() {
let request = axum::http::Request::builder()
.method(method.clone())
.uri(path)
.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", session.cookie),
)
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from("{}"))
.unwrap();
let response = send_from(&app, request, "127.0.0.1:40000").await;
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"{method} {path} must refuse a request with no CSRF token"
);
assert_eq!(json_body(response).await["error"], "csrf_failed");
}
}
#[tokio::test]
async fn every_mutating_endpoint_refuses_a_wrong_or_foreign_csrf_token() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let other = admin_login(&app, "alice", ADMIN_PASSWORD).await;
for (method, path, _tier) in mutating_endpoints() {
for (label, token) in [
("wrong", "not-the-token"),
("another session's", &*other.csrf),
] {
let request = axum::http::Request::builder()
.method(method.clone())
.uri(path)
.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", session.cookie),
)
.header("x-csrf-token", token)
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from("{}"))
.unwrap();
let response = send_from(&app, request, "127.0.0.1:40000").await;
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"{method} {path} must refuse a {label} CSRF token"
);
}
}
}
#[tokio::test]
async fn a_cross_origin_write_is_refused_before_anything_else() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
for (name, value) in [
("origin", "http://evil.example"),
("sec-fetch-site", "cross-site"),
("origin", "http://localhost:8080"),
] {
let request = axum::http::Request::builder()
.method(Method::POST)
.uri("/api/eab")
.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", session.cookie),
)
.header("x-csrf-token", &session.csrf)
.header(name, value)
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from("{}"))
.unwrap();
let response = send_from(&app, request, "127.0.0.1:40000").await;
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"{name}: {value} must be refused even with a valid CSRF token"
);
}
}
#[tokio::test]
async fn a_same_origin_write_with_the_right_token_is_allowed() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let request = axum::http::Request::builder()
.method(Method::POST)
.uri("/api/eab")
.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", session.cookie),
)
.header("x-csrf-token", &session.csrf)
.header("origin", "http://localhost:3001")
.header("sec-fetch-site", "same-origin")
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from("{}"))
.unwrap();
let response = send_from(&app, request, "127.0.0.1:40000").await;
assert_eq!(response.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn reads_need_no_csrf_token() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
for path in [
"/api/session",
"/api/accounts",
"/api/orders",
"/api/audit",
"/api/eab",
"/api/nonces",
"/api/profiles",
] {
let request = axum::http::Request::builder()
.method(Method::GET)
.uri(path)
.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", session.cookie),
)
.body(axum::body::Body::empty())
.unwrap();
let response = send_from(&app, request, "127.0.0.1:40000").await;
assert_eq!(response.status(), StatusCode::OK, "GET {path}");
}
}
#[tokio::test]
async fn every_api_route_needs_a_session() {
let (app, _database, _session) = test_admin_app_logged_in(admin_config()).await;
let mut routes: Vec<(Method, &'static str)> = vec![
(Method::GET, "/api/session"),
(Method::GET, "/api/accounts"),
(Method::GET, "/api/accounts/some-id"),
(Method::GET, "/api/accounts/some-id/orders"),
(Method::GET, "/api/orders"),
(Method::GET, "/api/orders/some-id"),
(Method::GET, "/api/audit"),
(Method::GET, "/api/audit/1"),
(Method::GET, "/api/eab"),
(Method::GET, "/api/eab/some-kid"),
(Method::GET, "/api/nonces"),
(Method::GET, "/api/profiles"),
(Method::GET, "/api/profiles/default/filter"),
(Method::GET, "/api/operators"),
(Method::GET, "/api/operators/some-username"),
(Method::GET, "/api/operators/some-username/sessions"),
(Method::GET, "/api/account/sessions"),
(Method::GET, "/api/jobs"),
(Method::GET, "/api/jobs/some-id"),
(Method::GET, "/api/upstream-orders"),
(Method::GET, "/api/upstream-orders/some-id"),
(Method::GET, "/api/expiring"),
(Method::GET, "/api/session/mfa"),
(Method::GET, "/api/mfa"),
];
routes.extend(
mutating_endpoints()
.into_iter()
.map(|(method, path, _tier)| (method, path)),
);
for (method, path) in routes {
let response = admin_request(&app, method.clone(), path, None, Some(json!({}))).await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"{method} {path} must require a session"
);
}
}
#[tokio::test]
async fn role_gates_every_mutating_endpoint() {
use acme_proxy::sqlite::admin_user::AdminRole;
let (app, database) = test_admin_app(admin_config()).await;
for (name, role) in [
("adam", None),
("olga", Some(AdminRole::Operator)),
("vera", Some(AdminRole::Viewer)),
] {
acme_proxy::admin::users::create_user(
name,
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
if let Some(role) = role {
acme_proxy::admin::users::set_role(name, role, database.clone())
.await
.unwrap()
.unwrap();
}
}
let adam = admin_login(&app, "adam", ADMIN_PASSWORD).await;
let olga = admin_login(&app, "olga", ADMIN_PASSWORD).await;
let vera = admin_login(&app, "vera", ADMIN_PASSWORD).await;
for (method, path, tier) in mutating_endpoints() {
if method == Method::DELETE && path == "/api/session" {
continue;
}
for (who, session, held) in [
("admin", &adam, AdminRole::Admin),
("operator", &olga, AdminRole::Operator),
("viewer", &vera, AdminRole::Viewer),
] {
let response =
admin_request(&app, method.clone(), path, Some(session), Some(json!({}))).await;
let status = response.status();
let refused_for_role = status == StatusCode::FORBIDDEN
&& json_body(response).await["error"] == "insufficient_role";
let expect_refused = match tier {
RequiredTier::SelfService => false,
RequiredTier::Operator => held < AdminRole::Operator,
RequiredTier::Admin => held < AdminRole::Admin,
};
assert_eq!(
refused_for_role, expect_refused,
"{who} {method} {path} (status {status})"
);
}
}
}
#[tokio::test]
async fn the_operators_reads_are_admin_only() {
use acme_proxy::sqlite::admin_user::AdminRole;
let (app, database) = test_admin_app(admin_config()).await;
for (name, role) in [
("adam", AdminRole::Admin),
("olga", AdminRole::Operator),
("vera", AdminRole::Viewer),
] {
acme_proxy::admin::users::create_user(
name,
ADMIN_PASSWORD,
&PasswordContext::empty(),
Some(role),
database.clone(),
)
.await
.unwrap();
}
let adam = admin_login(&app, "adam", ADMIN_PASSWORD).await;
let olga = admin_login(&app, "olga", ADMIN_PASSWORD).await;
let vera = admin_login(&app, "vera", ADMIN_PASSWORD).await;
let reads = [
"/api/operators",
"/api/operators/olga",
"/api/operators/olga/sessions",
];
for path in reads {
let allowed = admin_request(&app, Method::GET, path, Some(&adam), None).await;
assert_eq!(allowed.status(), StatusCode::OK, "an admin may read {path}");
for (who, session) in [("operator", &olga), ("viewer", &vera)] {
let refused = admin_request(&app, Method::GET, path, Some(session), None).await;
assert_eq!(
refused.status(),
StatusCode::FORBIDDEN,
"a {who} must not read {path}"
);
assert_eq!(json_body(refused).await["error"], "insufficient_role");
}
}
for (who, session) in [("operator", &olga), ("viewer", &vera)] {
assert_eq!(
admin_request(
&app,
Method::GET,
"/api/account/sessions",
Some(session),
None
)
.await
.status(),
StatusCode::OK,
"a {who} keeps their own account"
);
}
}
#[tokio::test]
async fn logout_is_reachable_by_every_role() {
use acme_proxy::sqlite::admin_user::AdminRole;
let (app, database) = test_admin_app(admin_config()).await;
for (name, role) in [
("adam", AdminRole::Admin),
("olga", AdminRole::Operator),
("vera", AdminRole::Viewer),
] {
acme_proxy::admin::users::create_user(
name,
ADMIN_PASSWORD,
&PasswordContext::empty(),
Some(role),
database.clone(),
)
.await
.unwrap();
let session = admin_login(&app, name, ADMIN_PASSWORD).await;
let response =
admin_request(&app, Method::DELETE, "/api/session", Some(&session), None).await;
assert_eq!(
response.status(),
StatusCode::NO_CONTENT,
"{name} ({role}) must be able to sign out"
);
}
}
async fn seed(
database: &std::sync::Arc<acme_proxy::sqlite::db::Database>,
count: u8,
) -> Vec<String> {
use acme_proxy::sqlite::account::Account;
use acme_proxy::sqlite::order::{Identifier, Order};
let mut ids = Vec::new();
for index in 0..count {
let (account, _) = Account::find_or_create(
PROFILE,
&[1u8, index],
vec![format!("mailto:user{index}@example.com")],
&ClientContext {
ip: Some(format!("203.0.113.{index}")),
ptr: Some(format!("client{index}.example.net")),
..ClientContext::default()
},
database,
)
.await
.unwrap();
Order::create(
PROFILE,
account.id,
vec![Identifier::dns(format!("host{index}.example.com"))],
2_000_000_000,
None,
None,
database,
)
.await
.unwrap();
ids.push(account.id.to_string());
}
ids
}
#[tokio::test]
async fn accounts_list_pages_and_reports_the_total() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
seed(&database, 5).await;
let body = json_body(
admin_request(
&app,
Method::GET,
"/api/accounts?limit=2",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(body["total"], 5);
assert_eq!(body["limit"], 2);
assert_eq!(body["offset"], 0);
assert_eq!(body["items"].as_array().unwrap().len(), 2);
assert!(body["items"][0]["id"].as_str().is_some());
assert_eq!(body["items"][0]["profile"], PROFILE);
let paged = json_body(
admin_request(
&app,
Method::GET,
"/api/accounts?limit=2&offset=4",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(paged["items"].as_array().unwrap().len(), 1);
assert_eq!(paged["total"], 5);
}
#[tokio::test]
async fn the_eab_list_pages_and_reports_the_total() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let mut minted = Vec::new();
for _ in 0..5 {
minted.push(
acme_proxy::sqlite::eab::Eab::create(None, None, &database)
.await
.unwrap()
.kid
.to_string(),
);
}
let body =
json_body(admin_request(&app, Method::GET, "/api/eab?limit=2", Some(&session), None).await)
.await;
assert_eq!(body["total"], 5);
assert_eq!(body["limit"], 2);
assert_eq!(body["offset"], 0);
assert_eq!(body["items"].as_array().unwrap().len(), 2);
assert!(body["items"][0]["kid"].as_str().is_some());
assert!(body["items"][0].get("hmacKey").is_none());
let paged = json_body(
admin_request(
&app,
Method::GET,
"/api/eab?limit=2&offset=4",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(paged["items"].as_array().unwrap().len(), 1);
assert_eq!(paged["total"], 5, "the total is the table, not the page");
let bare =
json_body(admin_request(&app, Method::GET, "/api/eab", Some(&session), None).await).await;
assert_eq!(bare["limit"], 50);
assert_eq!(bare["offset"], 0);
assert_eq!(bare["total"], 5);
let order: Vec<&str> = bare["items"]
.as_array()
.unwrap()
.iter()
.map(|item| item["kid"].as_str().unwrap())
.collect();
minted.reverse();
assert_eq!(order, minted);
}
#[tokio::test]
async fn a_limit_over_the_ceiling_is_clamped_rather_than_refused() {
let mut config = admin_config();
config.admin.page_size_max = 3;
let (app, database, session) = test_admin_app_logged_in(config).await;
seed(&database, 5).await;
let body = json_body(
admin_request(
&app,
Method::GET,
"/api/accounts?limit=1000",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(body["limit"], 3, "clamped to admin.page_size_max");
assert_eq!(body["items"].as_array().unwrap().len(), 3);
assert_eq!(body["total"], 5);
}
#[tokio::test]
async fn an_account_can_be_read_updated_deactivated_and_deleted() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let id = &ids[0];
let body = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/accounts/{id}"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(body["id"], *id);
assert_eq!(body["status"], "valid");
assert_eq!(body["createdIp"], "203.0.113.0");
assert_eq!(body["createdPtr"], "client0.example.net");
assert_eq!(body["lastSeenIp"], "203.0.113.0");
assert_eq!(body["lastSeenPtr"], "client0.example.net");
assert!(body["lastSeenAt"].as_str().unwrap().ends_with('Z'));
let orders = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/accounts/{id}/orders"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(orders["total"], 1);
let updated = json_body(
admin_request(
&app,
Method::PATCH,
&format!("/api/accounts/{id}"),
Some(&session),
Some(json!({ "contact": ["mailto:new@example.com"] })),
)
.await,
)
.await;
assert_eq!(updated["contact"][0], "mailto:new@example.com");
let deactivated = json_body(
admin_request(
&app,
Method::POST,
&format!("/api/accounts/{id}/deactivate"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(deactivated["status"], "deactivated");
let deleted = json_body(
admin_request(
&app,
Method::DELETE,
&format!("/api/accounts/{id}"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(deleted["deleted"]["orders"], 1);
assert_eq!(
admin_request(
&app,
Method::GET,
&format!("/api/accounts/{id}"),
Some(&session),
None
)
.await
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn patching_an_account_validates_contacts_the_way_new_account_does() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let id = &ids[0];
for bad in [
"https://example.com", "mailto:a@example.com?subject=hi", "mailto:a@example.com,b@example.com", "mailto:not-an-address",
] {
let response = admin_request(
&app,
Method::PATCH,
&format!("/api/accounts/{id}"),
Some(&session),
Some(json!({ "contact": [bad] })),
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "for `{bad}`");
let body = json_body(response).await;
assert_eq!(body["error"], "bad_request");
assert!(body["message"].as_str().unwrap().contains(bad));
}
}
#[tokio::test]
async fn unknown_ids_are_json_404s_and_never_acme_problem_documents() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
for (method, path) in [
(Method::GET, "/api/accounts/nope"),
(Method::GET, "/api/accounts/nope/orders"),
(Method::GET, "/api/orders/nope"),
(Method::GET, "/api/eab/nope"),
(Method::DELETE, "/api/accounts/nope"),
(Method::DELETE, "/api/orders/nope"),
(Method::POST, "/api/eab/nope/revoke"),
] {
let response = admin_request(&app, method.clone(), path, Some(&session), None).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {path}");
assert_eq!(
response.headers()[header::CONTENT_TYPE],
"application/json",
"{method} {path} must not answer application/problem+json"
);
let body = json_body(response).await;
assert_eq!(body["error"], "not_found");
assert!(!body.to_string().contains("urn:ietf:params:acme"));
}
}
#[tokio::test]
async fn an_unrouted_path_and_a_wrong_method_answer_in_the_admin_error_shape() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let missing = admin_request(&app, Method::GET, "/api/nothing-here", Some(&session), None).await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
assert_eq!(json_body(missing).await["error"], "not_found");
let wrong_method = admin_request(
&app,
Method::PUT,
"/api/accounts",
Some(&session),
Some(json!({})),
)
.await;
assert_eq!(wrong_method.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(json_body(wrong_method).await["error"], "method_not_allowed");
}
#[tokio::test]
async fn orders_list_filters_by_account_and_status() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 3).await;
let all =
json_body(admin_request(&app, Method::GET, "/api/orders", Some(&session), None).await)
.await;
assert_eq!(all["total"], 3);
let mine = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/orders?accountId={}", ids[0]),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(mine["total"], 1);
let pending = json_body(
admin_request(
&app,
Method::GET,
"/api/orders?status=pending",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(pending["total"], 3);
let ready = json_body(
admin_request(
&app,
Method::GET,
"/api/orders?status=ready",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(ready["total"], 0);
assert!(ready["items"].as_array().unwrap().is_empty());
let exact = json_body(
admin_request(
&app,
Method::GET,
"/api/orders?identifier=host1.example.com",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(exact["total"], 1);
let fragment = json_body(
admin_request(
&app,
Method::GET,
"/api/orders?identifierContains=example.com",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(fragment["total"], 3);
let none = json_body(
admin_request(
&app,
Method::GET,
"/api/orders?identifier=nothing.example.com",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(none["total"], 0);
let conflict = admin_request(
&app,
Method::GET,
"/api/orders?identifier=a.example.com&identifierContains=example",
Some(&session),
None,
)
.await;
assert_eq!(conflict.status(), StatusCode::BAD_REQUEST);
assert_eq!(
json_body(conflict).await["error"],
"conflicting_identifier_filter"
);
}
#[tokio::test]
async fn orders_list_filters_by_cert_serial() {
use acme_proxy::sqlite::order::Order;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 2).await;
let mut order = Order::find_by_account(ids[0].parse().unwrap(), &database)
.await
.unwrap()
.pop()
.unwrap();
order
.finalize(
"-----BEGIN CERTIFICATE-----\n...".to_string(),
"0a1b2c3d".to_string(),
vec![9, 9, 9],
None,
&database,
)
.await
.unwrap();
let hit = json_body(
admin_request(
&app,
Method::GET,
"/api/orders?certSerial=0a1b2c3d",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(hit["total"], 1);
assert_eq!(hit["items"][0]["certSerial"], "0a1b2c3d");
let miss = json_body(
admin_request(
&app,
Method::GET,
"/api/orders?certSerial=ffffffff",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(miss["total"], 0);
}
#[tokio::test]
async fn an_unknown_order_status_filter_is_refused_rather_than_matching_nothing() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
seed(&database, 2).await;
let response = admin_request(
&app,
Method::GET,
"/api/orders?status=readyy",
Some(&session),
None,
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = json_body(response).await;
assert_eq!(body["error"], "invalid_status");
let message = body["message"].as_str().unwrap();
assert!(message.contains("`readyy`"), "{message}");
assert!(
message.contains("pending, ready, processing, valid, invalid"),
"{message}"
);
}
#[tokio::test]
async fn an_order_detail_carries_its_authorizations() {
use acme_proxy::sqlite::authz::{Authorization, Challenge};
use acme_proxy::sqlite::order::{Identifier, Order};
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let orders = Order::find_by_account(ids[0].parse().unwrap(), &database)
.await
.unwrap();
let order_id = orders[0].id;
let authz = Authorization::create(
order_id,
Identifier::dns("host0.example.com"),
2_000_000_000,
&database,
)
.await
.unwrap();
Challenge::create(authz.id, "http-01", &database)
.await
.unwrap();
let body = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/orders/{order_id}"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(body["order"]["id"], order_id.to_string());
assert_eq!(body["authorizations"].as_array().unwrap().len(), 1);
assert_eq!(
body["authorizations"][0]["challenges"]
.as_array()
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn revoking_an_order_covers_every_outcome() {
use acme_proxy::sqlite::order::Order;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let orders = Order::find_by_account(ids[0].parse().unwrap(), &database)
.await
.unwrap();
let order_id = orders[0].id;
let response = admin_request(
&app,
Method::POST,
&format!("/api/orders/{order_id}/revoke"),
Some(&session),
None,
)
.await;
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(json_body(response).await["error"], "order_not_issued");
assert_eq!(
admin_request(
&app,
Method::POST,
"/api/orders/nope/revoke",
Some(&session),
None
)
.await
.status(),
StatusCode::NOT_FOUND
);
let bad_reason = admin_request(
&app,
Method::POST,
&format!("/api/orders/{order_id}/revoke"),
Some(&session),
Some(json!({ "reason": 99 })),
)
.await;
assert_eq!(bad_reason.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn revoking_an_order_from_an_unmounted_profile_is_a_conflict() {
use acme_proxy::sqlite::account::Account;
use acme_proxy::sqlite::order::{Identifier, Order};
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let (account, _) = Account::find_or_create(
"retired",
&[7u8, 7],
vec![],
&ClientContext::default(),
&database,
)
.await
.unwrap();
let order = Order::create(
"retired",
account.id,
vec![Identifier::dns("old.example.com")],
2_000_000_000,
None,
None,
&database,
)
.await
.unwrap();
let response = admin_request(
&app,
Method::POST,
&format!("/api/orders/{}/revoke", order.id),
Some(&session),
None,
)
.await;
assert_eq!(response.status(), StatusCode::CONFLICT);
let body = json_body(response).await;
assert_eq!(body["error"], "profile_not_mounted");
assert!(body["message"].as_str().unwrap().contains("retired"));
}
#[tokio::test]
async fn an_order_can_be_deleted_and_names_its_cascade() {
use acme_proxy::sqlite::order::Order;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let orders = Order::find_by_account(ids[0].parse().unwrap(), &database)
.await
.unwrap();
let order_id = orders[0].id;
let body = json_body(
admin_request(
&app,
Method::DELETE,
&format!("/api/orders/{order_id}"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(body["deleted"]["authorizations"], 0);
assert!(
Order::find_by_id(&order_id.to_string(), &database)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn an_eab_secret_is_returned_once_and_never_again() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let created = admin_request(
&app,
Method::POST,
"/api/eab",
Some(&session),
Some(json!({ "label": "team-a" })),
)
.await;
assert_eq!(created.status(), StatusCode::CREATED);
let body = json_body(created).await;
let kid = body["kid"].as_str().unwrap().to_string();
let secret = body["hmacKey"].as_str().unwrap().to_string();
assert!(!secret.is_empty());
assert_eq!(body["label"], "team-a");
let shown = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/eab/{kid}"),
Some(&session),
None,
)
.await,
)
.await;
assert!(shown.get("hmacKey").is_none());
assert!(!shown.to_string().contains(&secret));
let listed =
json_body(admin_request(&app, Method::GET, "/api/eab", Some(&session), None).await).await;
assert!(!listed.to_string().contains(&secret));
assert_eq!(
admin_request(
&app,
Method::POST,
&format!("/api/eab/{kid}/revoke"),
Some(&session),
None
)
.await
.status(),
StatusCode::NO_CONTENT
);
let after = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/eab/{kid}"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(after["status"], "revoked");
}
#[tokio::test]
async fn creating_an_eab_for_an_unmounted_profile_is_refused() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let response = admin_request(
&app,
Method::POST,
"/api/eab",
Some(&session),
Some(json!({ "profile": "no-such-profile" })),
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(
json_body(response).await["message"]
.as_str()
.unwrap()
.contains("no-such-profile")
);
}
#[tokio::test]
async fn nonces_can_be_counted_and_swept() {
use acme_proxy::sqlite::nonce::Nonce;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
for _ in 0..3 {
Nonce::new().save(&database).await.unwrap();
}
let counted =
json_body(admin_request(&app, Method::GET, "/api/nonces", Some(&session), None).await)
.await;
assert_eq!(counted["count"], 3);
assert_eq!(counted["ttlSeconds"], 300);
let swept = json_body(
admin_request(
&app,
Method::POST,
"/api/nonces/cleanup",
Some(&session),
Some(json!({ "ttlSeconds": 0 })),
)
.await,
)
.await;
assert_eq!(swept["removed"], 3);
assert_eq!(Nonce::count(&database).await.unwrap(), 0);
}
#[tokio::test]
async fn profiles_reports_the_endpoints_actually_mounted() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let body =
json_body(admin_request(&app, Method::GET, "/api/profiles", Some(&session), None).await)
.await;
let profiles = body.as_array().unwrap();
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0]["name"], PROFILE);
assert_eq!(profiles[0]["baseUrl"], BASE);
assert_eq!(profiles[0]["directory"], format!("{BASE}/directory"));
assert_eq!(
profiles[0],
acme_proxy::admin::render_profile_json(&acme_proxy::admin::ProfileSummary {
name: PROFILE.to_string(),
base_url: BASE.to_string(),
challenge_bypass: true,
eab_enabled: false,
})
);
}
#[tokio::test]
async fn the_filter_policy_api_serves_the_resolved_policy() {
let (app, _database, session) =
test_admin_app_logged_in_with_filter(admin_config(), test_filter_policy()).await;
let body = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/profiles/{PROFILE}/filter"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(body["profile"], PROFILE);
assert_eq!(body["active"], true);
assert_eq!(body["defaultEffect"], "deny");
assert!(body["warning"].is_null());
let checks = body["checks"].as_array().unwrap();
assert_eq!(checks.len(), 2);
assert_eq!(checks[0]["name"], "mgmt-net");
assert_eq!(checks[0]["type"], "allowed_ip");
assert_eq!(checks[0]["stages"], "connection and identifiers");
assert_eq!(checks[1]["name"], "names");
assert_eq!(checks[1]["type"], "identifiers");
let rules = body["rules"].as_array().unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(rules[0]["name"], "mgmt-bypass");
assert_eq!(rules[0]["then"], "allow");
assert_eq!(rules[0]["mode"], "enforce");
assert_eq!(rules[1]["when"], "names or (mgmt-net and names)");
assert_eq!(rules[1]["then"], "deny");
assert_eq!(rules[1]["mode"], "warn");
assert_eq!(rules[1]["stages"], "identifiers only");
assert!(rules[0].get("message").is_none());
let unknown = admin_request(
&app,
Method::GET,
"/api/profiles/nope/filter",
Some(&session),
None,
)
.await;
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
assert_eq!(json_body(unknown).await["error"], "not_found");
assert_eq!(
admin_request(
&app,
Method::GET,
&format!("/api/profiles/{PROFILE}/filter"),
None,
None
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
for method in [Method::POST, Method::DELETE, Method::PATCH, Method::PUT] {
let response = admin_request(
&app,
method.clone(),
&format!("/api/profiles/{PROFILE}/filter"),
Some(&session),
Some(json!({})),
)
.await;
assert!(
response.status() == StatusCode::METHOD_NOT_ALLOWED
|| response.status() == StatusCode::NOT_FOUND,
"{method} /api/profiles/{PROFILE}/filter answered {}",
response.status()
);
}
}
#[tokio::test]
async fn an_endpoint_with_no_rules_says_so_rather_than_answering_404() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let body = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/profiles/{PROFILE}/filter"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(body["active"], false);
assert!(body["defaultEffect"].is_null());
assert!(
body["warning"]
.as_str()
.unwrap()
.contains("filters nothing")
);
assert!(body["checks"].as_array().unwrap().is_empty());
assert!(body["rules"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn every_response_carries_the_hardening_headers() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let response = admin_request(&app, Method::GET, "/api/profiles", Some(&session), None).await;
let headers = response.headers();
assert_eq!(headers[header::CACHE_CONTROL], "no-store");
assert_eq!(headers[header::REFERRER_POLICY], "same-origin");
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
assert_eq!(headers[header::X_FRAME_OPTIONS], "DENY");
assert!(headers.contains_key(header::STRICT_TRANSPORT_SECURITY));
assert!(headers.contains_key("x-request-id"));
}
#[tokio::test]
async fn health_is_served_unauthenticated() {
let (app, _database) = test_admin_app(admin_config()).await;
let response = admin_request(&app, Method::GET, "/health", None, None).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["healthy"], true);
}
#[tokio::test]
async fn the_admin_listener_serves_no_acme() {
let (app, _database) = test_admin_app(admin_config()).await;
for path in [
"/directory",
"/newNonce",
"/profile/default/directory",
"/profile/default/newNonce",
] {
let response = admin_request(&app, Method::GET, path, None, None).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "GET {path}");
assert!(
!response.headers().contains_key("replay-nonce"),
"GET {path}"
);
}
}
#[tokio::test]
async fn revoking_an_issued_order_succeeds_once_and_then_conflicts() {
use acme_proxy::signer::RequestedValidity;
use acme_proxy::sqlite::account::Account;
use acme_proxy::sqlite::order::{Identifier, Order};
let mut config = admin_config();
config.admin.enabled = true;
let (app, database, signer) = test_admin_app_with_signer(config).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let session = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let (account, _) = Account::find_or_create(
PROFILE,
&[4u8, 2],
vec![],
&ClientContext::default(),
&database,
)
.await
.unwrap();
let identifier = Identifier::dns("revoke-me.example.com");
let mut order = Order::create(
PROFILE,
account.id,
vec![identifier.clone()],
2_000_000_000,
None,
None,
&database,
)
.await
.unwrap();
let csr_der = {
let csr = make_csr("revoke-me.example.com");
use base64::prelude::*;
BASE64_URL_SAFE_NO_PAD.decode(csr).unwrap()
};
let issued = signer
.issue(
&order.id.to_string(),
&csr_der,
&[identifier],
RequestedValidity::default(),
)
.await
.expect("the in-memory CA must issue");
let chain = match issued {
acme_proxy::signer::IssueOutcome::Issued(chain) => chain,
other => panic!("expected an inline issuance, got {other:?}"),
};
let (serial, spki) =
acme_proxy::cert::cert_serial_and_spki(&first_certificate(&chain)).unwrap();
let expected_serial = serial.clone();
order
.finalize(chain, serial, spki, None, &database)
.await
.unwrap();
let response = admin_request(
&app,
Method::POST,
&format!("/api/orders/{}/revoke", order.id),
Some(&session),
Some(json!({ "reason": 1 })),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
assert_eq!(body["id"], order.id.to_string());
assert_eq!(body["revocationReason"], 1);
assert!(body["revokedAt"].as_str().is_some());
assert_eq!(body["certSerial"], expected_serial);
let crl = signer.crl_der().await.expect("a local CA always has a CRL");
assert!(!crl.is_empty());
let repeat = admin_request(
&app,
Method::POST,
&format!("/api/orders/{}/revoke", order.id),
Some(&session),
None,
)
.await;
assert_eq!(repeat.status(), StatusCode::CONFLICT);
assert_eq!(json_body(repeat).await["error"], "already_revoked");
}
#[tokio::test]
async fn changing_a_live_factor_requires_the_password_again() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let secret = enrol_totp(database.clone(), "alice").await;
let session = admin_login_mfa(&app, database.clone(), "alice", ADMIN_PASSWORD, &secret).await;
let mut attempt = 0;
for (method, path) in [
(Method::POST, "/api/mfa/totp"),
(Method::POST, "/api/mfa/recovery-codes"),
(Method::DELETE, "/api/mfa/totp"),
] {
for body in [json!({}), json!({ "password": "not-the-password" })] {
attempt += 1;
let refused = admin_request_from(
&app,
method.clone(),
path,
Some(&session),
Some(body),
&format!("192.0.2.{attempt}:1234"),
)
.await;
assert_eq!(
refused.status(),
StatusCode::UNAUTHORIZED,
"{method} {path} must not act on a cookie alone"
);
assert_eq!(
json_body(refused).await["error"],
"invalid_credentials",
"{method} {path}"
);
}
}
let status =
json_body(admin_request(&app, Method::GET, "/api/mfa", Some(&session), None).await).await;
assert_eq!(status["totpEnabled"], true);
assert_eq!(status["enrolmentPending"], false);
assert_eq!(status["recoveryCodesRemaining"], 10);
assert_eq!(
admin_request(
&app,
Method::POST,
"/api/mfa/totp",
Some(&session),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await
.status(),
StatusCode::CREATED
);
}
#[tokio::test]
async fn a_first_enrolment_asks_for_no_password() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let session = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let begun = admin_request(
&app,
Method::POST,
"/api/mfa/totp",
Some(&session),
Some(json!({})),
)
.await;
assert_eq!(begun.status(), StatusCode::CREATED);
let secret = json_body(begun).await["secret"]
.as_str()
.expect("the secret is readable exactly once")
.to_string();
let confirmed = admin_request(
&app,
Method::POST,
"/api/mfa/totp/confirm",
Some(&session),
Some(json!({ "code": totp_code(&base32_decode(&secret), 0) })),
)
.await;
assert_eq!(confirmed.status(), StatusCode::OK);
}
#[tokio::test]
async fn the_admin_listener_fails_closed_when_the_database_is_gone() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
database.pool.close().await;
for (method, path) in [
(Method::GET, "/api/session"),
(Method::GET, "/api/accounts"),
(Method::GET, "/api/orders"),
(Method::GET, "/api/eab"),
(Method::GET, "/api/mfa"),
(Method::GET, "/api/profiles"),
] {
let response = admin_request(&app, method.clone(), path, Some(&session), None).await;
assert_eq!(
response.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"{method} {path} must say it could not authorise, not answer something \
that reads like an ordinary refusal"
);
}
let response = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
assert!(
response.status().is_client_error() || response.status().is_server_error(),
"a login answered {} against an unreadable user table",
response.status()
);
assert!(
session_cookie_token(&response).is_none(),
"a failed login must not hand out a session cookie"
);
let response = admin_request(
&app,
Method::DELETE,
"/api/accounts/00000000-0000-0000-0000-000000000000",
Some(&session),
None,
)
.await;
assert!(response.status().is_client_error() || response.status().is_server_error());
}
#[tokio::test]
async fn an_oversized_admin_request_body_is_refused() {
let mut config = admin_config();
config.admin.max_body_bytes = 1024;
let (app, database) = test_admin_app(config).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database,
)
.await
.unwrap();
let session = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let oversized = json!({ "contact": ["mailto:a@b.test"], "padding": "x".repeat(4096) });
let response = admin_request(
&app,
Method::PATCH,
"/api/accounts/00000000-0000-0000-0000-000000000000",
Some(&session),
Some(oversized),
)
.await;
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
let response = admin_request(
&app,
Method::PATCH,
"/api/accounts/00000000-0000-0000-0000-000000000000",
Some(&session),
Some(json!({ "contact": ["mailto:a@b.test"] })),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn the_audit_api_lists_pages_and_refuses_every_way_of_writing_to_it() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
for _ in 0..3 {
AuditEntry::insert(
AuditRecord::new(AuditEvent::CertificateIssued, PROFILE, Actor::admin("root"))
.with_account("acct-1")
.with_serial("0a0b")
.with_client(ClientContext {
ip: Some("203.0.113.7".to_string()),
ptr: Some("host.example.com".to_string()),
..ClientContext::default()
}),
&database,
)
.await
.unwrap();
}
AuditEntry::insert(
AuditRecord::new(
AuditEvent::CertificateRevokeFailed,
PROFILE,
Actor::acme_certificate_key(),
)
.with_reason("unauthorized"),
&database,
)
.await
.unwrap();
let body =
json_body(admin_request(&app, Method::GET, "/api/audit", Some(&session), None).await).await;
assert_eq!(body["total"], 4);
assert_eq!(body["items"].as_array().unwrap().len(), 4);
assert_eq!(body["items"][0]["event"], "certificate_revoke_failed");
assert_eq!(body["items"][3]["clientIp"], "203.0.113.7");
assert_eq!(body["items"][3]["clientPtr"], "host.example.com");
assert!(
!body["items"][0]
.as_object()
.unwrap()
.contains_key("clientIp")
);
let filtered = json_body(
admin_request(
&app,
Method::GET,
"/api/audit?outcome=failure",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(filtered["total"], 1);
let paged = json_body(
admin_request(
&app,
Method::GET,
"/api/audit?limit=2",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(paged["total"], 4);
assert_eq!(paged["items"].as_array().unwrap().len(), 2);
assert_eq!(paged["limit"], 2);
let id = body["items"][0]["id"].as_i64().unwrap();
let one = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/audit/{id}"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(one["id"], id);
let missing = admin_request(&app, Method::GET, "/api/audit/999999", Some(&session), None).await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
for (method, path) in [
(Method::POST, "/api/audit"),
(Method::DELETE, "/api/audit"),
(Method::DELETE, "/api/audit/1"),
(Method::PATCH, "/api/audit/1"),
(Method::POST, "/api/audit/cleanup"),
] {
let response =
admin_request(&app, method.clone(), path, Some(&session), Some(json!({}))).await;
assert!(
response.status() == StatusCode::METHOD_NOT_ALLOWED
|| response.status() == StatusCode::NOT_FOUND,
"{method} {path} answered {}",
response.status()
);
}
assert_eq!(
json_body(admin_request(&app, Method::GET, "/api/audit", Some(&session), None).await).await
["total"],
4
);
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
async fn seed_sweep_job(database: &std::sync::Arc<acme_proxy::sqlite::db::Database>) -> String {
use acme_proxy::sqlite::job::{Job, NewJob};
let job_id = acme_proxy::sqlite::id::mint();
Job::enqueue(
NewJob {
id: job_id,
kind: "nonce_sweep",
dedup_key: "nonce_sweep",
payload: &json!({}),
run_at: now_secs(),
deadline: None,
max_attempts: 5,
},
database,
)
.await
.unwrap();
job_id.to_string()
}
async fn seed_relay_job(
database: &std::sync::Arc<acme_proxy::sqlite::db::Database>,
) -> (String, String) {
use acme_proxy::sqlite::account::Account;
use acme_proxy::sqlite::job::{Job, NewJob};
use acme_proxy::sqlite::order::{Identifier, Order};
use acme_proxy::sqlite::upstream_order::UpstreamOrder;
let (account, _) = Account::find_or_create(
PROFILE,
&[9u8, 9],
Vec::new(),
&ClientContext::default(),
database,
)
.await
.unwrap();
let order = Order::create(
PROFILE,
account.id,
vec![Identifier::dns("relay.example.com")],
now_secs() + 3600,
None,
None,
database,
)
.await
.unwrap();
UpstreamOrder::create(
order.id.to_string().as_str(),
"https://up.example/o/1",
None,
b"csr",
database,
)
.await
.unwrap();
let job_id = acme_proxy::sqlite::id::mint();
Job::enqueue(
NewJob {
id: job_id,
kind: "signer_relay_issue",
dedup_key: &order.id.to_string(),
payload: &json!({ "order_id": order.id.to_string(), "profile": PROFILE }),
run_at: now_secs(),
deadline: Some(order.expires),
max_attempts: 5,
},
database,
)
.await
.unwrap();
(job_id.to_string(), order.id.to_string())
}
#[tokio::test]
async fn the_jobs_api_lists_cancels_and_runs() {
use acme_proxy::sqlite::job::{Job, NewJob};
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let sweep_id = acme_proxy::sqlite::id::mint();
Job::enqueue(
NewJob {
id: sweep_id,
kind: "nonce_sweep",
dedup_key: "nonce_sweep",
payload: &json!({}),
run_at: now_secs() + 3600,
deadline: None,
max_attempts: 5,
},
&database,
)
.await
.unwrap();
let (relay_job_id, order_id) = seed_relay_job(&database).await;
let body =
json_body(admin_request(&app, Method::GET, "/api/jobs", Some(&session), None).await).await;
assert_eq!(body["total"], 2);
let detail = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/jobs/{relay_job_id}"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(detail["kind"], "signer_relay_issue");
assert_eq!(detail["upstreamOrder"]["orderId"], order_id);
let response = admin_request(
&app,
Method::POST,
&format!("/api/jobs/{sweep_id}/cancel"),
Some(&session),
Some(json!({})),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["status"], "cancelled");
let audit =
json_body(admin_request(&app, Method::GET, "/api/audit", Some(&session), None).await).await;
assert_eq!(audit["total"], 1);
assert_eq!(audit["items"][0]["event"], "job_cancelled");
assert_eq!(audit["items"][0]["actorKind"], "admin");
let response = admin_request(
&app,
Method::POST,
&format!("/api/jobs/{relay_job_id}/cancel"),
Some(&session),
Some(json!({})),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let order = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/orders/{order_id}"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(order["order"]["status"], "invalid");
let audit =
json_body(admin_request(&app, Method::GET, "/api/audit", Some(&session), None).await).await;
assert_eq!(audit["total"], 2);
assert_eq!(audit["items"][0]["event"], "certificate_issue_failed");
assert_eq!(audit["items"][0]["actorKind"], "admin");
assert_eq!(audit["items"][1]["event"], "job_cancelled");
let done_id = acme_proxy::sqlite::id::mint();
Job::enqueue(
NewJob {
id: done_id,
kind: "nonce_sweep",
dedup_key: "done-key",
payload: &json!({}),
run_at: now_secs(),
deadline: None,
max_attempts: 5,
},
&database,
)
.await
.unwrap();
sqlx::query("UPDATE jobs SET status = 'done' WHERE id = ?;")
.bind(done_id)
.execute(&database.pool)
.await
.unwrap();
let refused = admin_request(
&app,
Method::POST,
&format!("/api/jobs/{done_id}/run"),
Some(&session),
Some(json!({})),
)
.await;
assert_eq!(refused.status(), StatusCode::CONFLICT);
assert_eq!(json_body(refused).await["error"], "job_not_runnable");
let failed_id = acme_proxy::sqlite::id::mint();
Job::enqueue(
NewJob {
id: failed_id,
kind: "nonce_sweep",
dedup_key: "failed-key",
payload: &json!({}),
run_at: now_secs(),
deadline: None,
max_attempts: 5,
},
&database,
)
.await
.unwrap();
sqlx::query("UPDATE jobs SET status = 'failed', attempts = 5 WHERE id = ?;")
.bind(failed_id)
.execute(&database.pool)
.await
.unwrap();
let revived = json_body(
admin_request(
&app,
Method::POST,
&format!("/api/jobs/{failed_id}/run"),
Some(&session),
Some(json!({})),
)
.await,
)
.await;
assert_eq!(revived["status"], "ready");
assert_eq!(revived["attempts"], 4);
}
#[tokio::test]
async fn the_upstream_orders_api_is_read_only() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let (_job_id, order_id) = seed_relay_job(&database).await;
let body = json_body(
admin_request(
&app,
Method::GET,
"/api/upstream-orders",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(body["total"], 1);
assert_eq!(body["items"][0]["orderId"], order_id);
assert_eq!(body["items"][0]["status"], "processing");
assert!(!body.to_string().contains("csrDer"));
let bad = admin_request(
&app,
Method::GET,
"/api/upstream-orders?status=nope",
Some(&session),
None,
)
.await;
assert_eq!(bad.status(), StatusCode::BAD_REQUEST);
let one = json_body(
admin_request(
&app,
Method::GET,
&format!("/api/upstream-orders/{order_id}"),
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(one["orderId"], order_id);
assert_eq!(one["job"]["kind"], "signer_relay_issue");
let missing = admin_request(
&app,
Method::GET,
"/api/upstream-orders/nope",
Some(&session),
None,
)
.await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
for (method, path) in [
(Method::POST, "/api/upstream-orders"),
(Method::DELETE, "/api/upstream-orders/1"),
(Method::POST, "/api/upstream-orders/1/cancel"),
(Method::PATCH, "/api/upstream-orders/1"),
] {
let response =
admin_request(&app, method.clone(), path, Some(&session), Some(json!({}))).await;
assert!(
response.status() == StatusCode::METHOD_NOT_ALLOWED
|| response.status() == StatusCode::NOT_FOUND,
"{method} {path} answered {}",
response.status()
);
}
}
async fn expiring(
database: &std::sync::Arc<acme_proxy::sqlite::db::Database>,
account: uuid::Uuid,
names: &[&str],
not_after: i64,
) -> String {
use acme_proxy::sqlite::order::{Identifier, Order};
let mut order = Order::create(
PROFILE,
account,
names.iter().map(|name| Identifier::dns(*name)).collect(),
2_000_000_000,
None,
None,
database,
)
.await
.unwrap();
order
.finalize(
"-----BEGIN CERTIFICATE-----\nplaceholder\n".to_string(),
format!("serial-{}", &order.id.to_string()[..8]),
vec![1],
Some(not_after),
database,
)
.await
.unwrap();
order.id.to_string()
}
#[tokio::test]
async fn the_expiring_api_lists_annotates_filters_and_refuses_every_way_of_writing_to_it() {
use acme_proxy::sqlite::account::Account;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let (account, _) = Account::find_or_create(
PROFILE,
b"expiry-key",
Vec::new(),
&ClientContext::default(),
&database,
)
.await
.unwrap();
let now = now_unix();
const DAY: i64 = 24 * 60 * 60;
let soon = expiring(
&database,
account.id,
&["soon.example.com"],
now + 3 * DAY + DAY / 2,
)
.await;
let mid = expiring(&database, account.id, &["mid.example.com"], now + 20 * DAY).await;
let renewal = expiring(
&database,
account.id,
&["soon.example.com"],
now + 300 * DAY,
)
.await;
let mut revoked = acme_proxy::sqlite::order::Order::find_by_id(
&expiring(&database, account.id, &["gone.example.com"], now + 2 * DAY).await,
&database,
)
.await
.unwrap()
.unwrap();
revoked.revoke(Some(1), &database).await.unwrap();
let body =
json_body(admin_request(&app, Method::GET, "/api/expiring", Some(&session), None).await)
.await;
assert_eq!(body["total"], 2, "the revoked row is not expiring");
assert_eq!(body["items"].as_array().unwrap().len(), 2);
assert_eq!(body["hidden"], 0, "nothing hidden by default");
assert_eq!(body["days"], 30);
assert_eq!(body["items"][0]["orderId"], soon);
assert_eq!(body["items"][1]["orderId"], mid);
assert_eq!(body["items"][0]["daysRemaining"], 3, "floored, not rounded");
assert_eq!(body["items"][0]["identifiers"][0], "soon.example.com");
assert_eq!(body["items"][0]["supersededBy"]["orderId"], renewal);
assert_eq!(body["items"][0]["supersededBy"]["via"], "identifiers");
assert!(
!body["items"][1]
.as_object()
.unwrap()
.contains_key("supersededBy"),
"{body}"
);
let narrow = json_body(
admin_request(
&app,
Method::GET,
"/api/expiring?days=7",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(narrow["total"], 1);
assert_eq!(narrow["days"], 7);
assert_eq!(narrow["items"][0]["orderId"], soon);
let hidden = json_body(
admin_request(
&app,
Method::GET,
"/api/expiring?superseded=hide",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(hidden["items"].as_array().unwrap().len(), 1);
assert_eq!(hidden["items"][0]["orderId"], mid);
assert_eq!(hidden["hidden"], 1);
assert_eq!(hidden["total"], 2, "the total counts the window");
let typo = json_body(
admin_request(
&app,
Method::GET,
"/api/expiring?superseded=hde",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(typo["items"].as_array().unwrap().len(), 2);
let other = json_body(
admin_request(
&app,
Method::GET,
"/api/expiring?profile=nothing-here",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(other["total"], 0);
for query in [
"/api/expiring?profile=",
"/api/expiring?profile=&days=30&superseded=",
] {
let blank =
json_body(admin_request(&app, Method::GET, query, Some(&session), None).await).await;
assert_eq!(blank["total"], 2, "{query} filtered on the empty string");
assert_eq!(blank["items"].as_array().unwrap().len(), 2, "{query}");
}
let paged = json_body(
admin_request(
&app,
Method::GET,
"/api/expiring?limit=1&offset=1",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(paged["total"], 2);
assert_eq!(paged["limit"], 1);
assert_eq!(paged["offset"], 1);
assert_eq!(paged["items"][0]["orderId"], mid);
assert_eq!(
admin_request(&app, Method::GET, "/api/expiring", None, None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
for method in [Method::POST, Method::DELETE, Method::PATCH, Method::PUT] {
let response = admin_request(
&app,
method.clone(),
"/api/expiring",
Some(&session),
Some(json!({})),
)
.await;
assert!(
response.status() == StatusCode::METHOD_NOT_ALLOWED
|| response.status() == StatusCode::NOT_FOUND,
"{method} /api/expiring answered {}",
response.status()
);
}
}
#[tokio::test]
async fn a_blank_filter_is_absent_on_every_list() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
seed(&database, 3).await;
for _ in 0..2 {
AuditEntry::insert(
AuditRecord::new(AuditEvent::CertificateIssued, PROFILE, Actor::admin("root"))
.with_account("acct-1")
.with_serial("0a0b"),
&database,
)
.await
.unwrap();
}
for (bare, blank, expected) in [
("/api/accounts", "/api/accounts?profile=", 3),
(
"/api/orders",
"/api/orders?profile=&accountId=&status=&identifier=&identifierContains=&certSerial=",
3,
),
(
"/api/audit",
"/api/audit?profile=&accountId=&orderId=&certSerial=&event=&outcome=",
2,
),
("/api/jobs", "/api/jobs?kind=&status=", 0),
(
"/api/upstream-orders",
"/api/upstream-orders?profile=&status=",
0,
),
] {
let response = admin_request(&app, Method::GET, blank, Some(&session), None).await;
assert_eq!(response.status(), StatusCode::OK, "{blank}");
let filtered = json_body(response).await;
let unfiltered =
json_body(admin_request(&app, Method::GET, bare, Some(&session), None).await).await;
assert_eq!(unfiltered["total"], expected, "{bare} seeded wrong");
assert_eq!(
filtered["total"], unfiltered["total"],
"{blank} filtered on the empty string"
);
assert_eq!(
filtered["items"].as_array().unwrap().len(),
unfiltered["items"].as_array().unwrap().len(),
"{blank}"
);
}
let named = json_body(
admin_request(
&app,
Method::GET,
"/api/orders?status=pending",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(named["total"], 3);
let elsewhere = json_body(
admin_request(
&app,
Method::GET,
"/api/accounts?profile=nothing-here",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(elsewhere["total"], 0);
let padded = json_body(
admin_request(
&app,
Method::GET,
"/api/orders?accountId=%20%20",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(padded["total"], 3);
}
#[tokio::test]
async fn own_sessions_are_listed_with_the_current_one_marked() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let _other = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let body = json_body(
admin_request(
&app,
Method::GET,
"/api/account/sessions",
Some(&session),
None,
)
.await,
)
.await;
assert_eq!(body["total"], 2);
let items = body["items"].as_array().unwrap();
assert_eq!(items.iter().filter(|s| s["current"] == true).count(), 1);
assert_eq!(items.iter().filter(|s| s["current"] == false).count(), 1);
let rendered = body.to_string();
assert!(!rendered.contains(&session.csrf));
}
#[tokio::test]
async fn revoking_a_non_current_own_session_removes_only_that_one() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let other = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let listed = json_body(
admin_request(
&app,
Method::GET,
"/api/account/sessions",
Some(&session),
None,
)
.await,
)
.await;
let other_id = listed["items"]
.as_array()
.unwrap()
.iter()
.find(|s| s["current"] == false)
.expect("the second session must be listed and not current")["id"]
.as_str()
.unwrap()
.to_string();
let response = admin_request(
&app,
Method::POST,
&format!("/api/account/sessions/{other_id}/revoke"),
Some(&session),
None,
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert!(response.headers().get(header::SET_COOKIE).is_none());
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&session), None)
.await
.status(),
StatusCode::OK
);
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&other), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn revoking_the_current_own_session_signs_it_out() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let listed = json_body(
admin_request(
&app,
Method::GET,
"/api/account/sessions",
Some(&session),
None,
)
.await,
)
.await;
let own_id = listed["items"][0]["id"].as_str().unwrap().to_string();
assert_eq!(listed["items"][0]["current"], true);
let response = admin_request(
&app,
Method::POST,
&format!("/api/account/sessions/{own_id}/revoke"),
Some(&session),
None,
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let set_cookie = response
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(set_cookie.contains("Max-Age=0"));
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&session), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn own_sessions_route_cannot_reach_another_operators_session() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
acme_proxy::admin::users::create_user(
"bob",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let bob = admin_login(&app, "bob", ADMIN_PASSWORD).await;
let bob_sessions = json_body(
admin_request(&app, Method::GET, "/api/account/sessions", Some(&bob), None).await,
)
.await;
let bob_session_id = bob_sessions["items"][0]["id"].as_str().unwrap().to_string();
let response = admin_request(
&app,
Method::POST,
&format!("/api/account/sessions/{bob_session_id}/revoke"),
Some(&session),
None,
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&bob), None)
.await
.status(),
StatusCode::OK
);
}
async fn app_with_bob() -> (
axum::Router,
std::sync::Arc<acme_proxy::sqlite::db::Database>,
common::AdminSessionHandle,
common::AdminSessionHandle,
) {
let (app, database, alice) = test_admin_app_logged_in(admin_config()).await;
acme_proxy::admin::users::create_user(
"bob",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let bob = admin_login(&app, "bob", ADMIN_PASSWORD).await;
(app, database, alice, bob)
}
#[tokio::test]
async fn every_mutation_writes_one_audit_row_naming_the_operator_and_the_address() {
use acme_proxy::sqlite::audit::{AuditEntry, AuditQuery};
let (app, database, alice, bob) = app_with_bob().await;
let accounts = seed(&database, 2).await;
let eab: String = json_body(
admin_request(
&app,
Method::POST,
"/api/eab",
Some(&alice),
Some(json!({ "label": "for-the-trail" })),
)
.await,
)
.await["kid"]
.as_str()
.unwrap()
.to_string();
let sweep_job = seed_sweep_job(&database).await;
let (relay_job, _) = seed_relay_job(&database).await;
acme_proxy::admin::users::create_user(
"carol",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let _carol = admin_login(&app, "carol", ADMIN_PASSWORD).await;
let carol_session = json_body(
admin_request(
&app,
Method::GET,
"/api/operators/carol/sessions",
Some(&alice),
None,
)
.await,
)
.await["items"][0]["id"]
.as_str()
.unwrap()
.to_string();
let step_up = || Some(json!({ "password": ADMIN_PASSWORD }));
let cases: Vec<(Method, String, Option<serde_json::Value>, &[&str])> = vec![
(
Method::PATCH,
format!("/api/accounts/{}", accounts[0]),
Some(json!({ "contact": ["mailto:new@example.com"] })),
&["account_contact_updated"],
),
(
Method::POST,
format!("/api/accounts/{}/deactivate", accounts[0]),
None,
&["account_deactivated"],
),
(
Method::DELETE,
format!("/api/accounts/{}", accounts[1]),
None,
&["account_deleted"],
),
(
Method::POST,
format!("/api/eab/{eab}/revoke"),
None,
&["eab_revoked"],
),
(
Method::POST,
format!("/api/jobs/{sweep_job}/cancel"),
None,
&["job_cancelled"],
),
(
Method::POST,
format!("/api/jobs/{relay_job}/cancel"),
None,
&["certificate_issue_failed"],
),
(
Method::POST,
"/api/operators/bob/disable".to_string(),
step_up(),
&["session_revoked", "operator_disabled"],
),
(
Method::POST,
"/api/operators/bob/enable".to_string(),
step_up(),
&["operator_enabled"],
),
(
Method::POST,
format!("/api/operators/carol/sessions/{carol_session}/revoke"),
step_up(),
&["session_revoked"],
),
(
Method::POST,
"/api/operators/bob/totp/reset".to_string(),
step_up(),
&["operator_totp_disabled"],
),
];
let _ = &bob;
for (method, path, body, events) in cases {
let before = AuditEntry::search(&AuditQuery::default(), &database)
.await
.unwrap()
.1;
let response = admin_request(&app, method.clone(), &path, Some(&alice), body).await;
assert!(
response.status().is_success(),
"{method} {path} must succeed for this test to say anything: {}",
response.status()
);
let (rows, after) = AuditEntry::search(
&AuditQuery {
limit: 5,
..AuditQuery::default()
},
&database,
)
.await
.unwrap();
assert_eq!(
after,
before + events.len() as i64,
"{method} {path} must leave exactly {} new audit row(s)",
events.len()
);
for (row, event) in rows.iter().zip(events) {
assert_eq!(&row.event, event, "{method} {path}");
assert_eq!(row.actor_kind, "admin", "{method} {path}");
assert_eq!(row.actor_id.as_deref(), Some("alice"), "{method} {path}");
assert_eq!(
row.client_ip.as_deref(),
Some("127.0.0.1"),
"{method} {path} ({event}) must record the address it came from"
);
}
}
}
#[tokio::test]
async fn operators_are_listed_oldest_first() {
let (app, _database, alice, _bob) = app_with_bob().await;
let body =
json_body(admin_request(&app, Method::GET, "/api/operators", Some(&alice), None).await)
.await;
assert_eq!(body["total"], 2);
assert_eq!(body["items"][0]["username"], "alice");
assert_eq!(body["items"][1]["username"], "bob");
}
#[tokio::test]
async fn an_operator_detail_reports_status_and_totp_state() {
let (app, _database, alice, _bob) = app_with_bob().await;
let body =
json_body(admin_request(&app, Method::GET, "/api/operators/bob", Some(&alice), None).await)
.await;
assert_eq!(body["username"], "bob");
assert_eq!(body["status"], "active");
assert_eq!(body["totpEnabled"], false);
assert_eq!(body["enrolmentPending"], false);
let missing = admin_request(
&app,
Method::GET,
"/api/operators/nobody",
Some(&alice),
None,
)
.await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn disabling_and_enabling_an_operator_round_trips_and_revokes_their_sessions() {
let (app, _database, alice, bob) = app_with_bob().await;
let response = admin_request(
&app,
Method::POST,
"/api/operators/bob/disable",
Some(&alice),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let detail =
json_body(admin_request(&app, Method::GET, "/api/operators/bob", Some(&alice), None).await)
.await;
assert_eq!(detail["status"], "disabled");
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&bob), None)
.await
.status(),
StatusCode::UNAUTHORIZED,
"a disabled operator's sessions must be revoked immediately"
);
let response = admin_request(
&app,
Method::POST,
"/api/operators/bob/enable",
Some(&alice),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let detail =
json_body(admin_request(&app, Method::GET, "/api/operators/bob", Some(&alice), None).await)
.await;
assert_eq!(detail["status"], "active");
}
#[tokio::test]
async fn resetting_an_operators_totp_clears_it_and_their_sessions() {
let (app, database, alice, bob) = app_with_bob().await;
enrol_totp(database.clone(), "bob").await;
let bob_detail_before =
json_body(admin_request(&app, Method::GET, "/api/operators/bob", Some(&alice), None).await)
.await;
assert_eq!(bob_detail_before["totpEnabled"], true);
let response = admin_request(
&app,
Method::POST,
"/api/operators/bob/totp/reset",
Some(&alice),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let bob_detail_after =
json_body(admin_request(&app, Method::GET, "/api/operators/bob", Some(&alice), None).await)
.await;
assert_eq!(bob_detail_after["totpEnabled"], false);
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&bob), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn revoking_one_of_another_operators_sessions_leaves_the_rest() {
let (app, _database, alice, bob) = app_with_bob().await;
let bob_second = admin_login(&app, "bob", ADMIN_PASSWORD).await;
let sessions = json_body(
admin_request(
&app,
Method::GET,
"/api/operators/bob/sessions",
Some(&alice),
None,
)
.await,
)
.await;
assert_eq!(sessions["total"], 2);
let target_id = sessions["items"][0]["id"].as_str().unwrap().to_string();
let response = admin_request(
&app,
Method::POST,
&format!("/api/operators/bob/sessions/{target_id}/revoke"),
Some(&alice),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let remaining = json_body(
admin_request(
&app,
Method::GET,
"/api/operators/bob/sessions",
Some(&alice),
None,
)
.await,
)
.await;
assert_eq!(remaining["total"], 1);
let statuses = [
admin_request(&app, Method::GET, "/api/session", Some(&bob), None)
.await
.status(),
admin_request(&app, Method::GET, "/api/session", Some(&bob_second), None)
.await
.status(),
];
assert_eq!(statuses.iter().filter(|s| **s == StatusCode::OK).count(), 1);
}
#[tokio::test]
async fn the_operators_surface_refuses_to_target_the_caller() {
let (app, _database, alice, _bob) = app_with_bob().await;
for (method, path) in [
(Method::POST, "/api/operators/alice/disable"),
(Method::POST, "/api/operators/alice/enable"),
(Method::POST, "/api/operators/alice/totp/reset"),
(Method::POST, "/api/operators/alice/sessions/some-id/revoke"),
] {
let response = admin_request(&app, method.clone(), path, Some(&alice), None).await;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{method} {path} must refuse to target the caller"
);
}
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&alice), None)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn operators_mutations_require_the_callers_password_even_with_no_factor() {
let (app, _database, alice, bob) = app_with_bob().await;
for (attempt, body) in [json!({}), json!({ "password": "not-the-password" })]
.into_iter()
.enumerate()
{
let refused = admin_request_from(
&app,
Method::POST,
"/api/operators/bob/disable",
Some(&alice),
Some(body),
&format!("198.51.100.{}:1234", attempt + 1),
)
.await;
assert_eq!(
refused.status(),
StatusCode::UNAUTHORIZED,
"a factorless admin must still prove their password here"
);
assert_eq!(json_body(refused).await["error"], "invalid_credentials");
}
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&bob), None)
.await
.status(),
StatusCode::OK
);
let response = admin_request(
&app,
Method::POST,
"/api/operators/bob/disable",
Some(&alice),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn operators_mutations_require_the_callers_password_once_they_have_a_factor() {
let (app, database, _seed_alice, bob) = app_with_bob().await;
let secret = enrol_totp(database.clone(), "alice").await;
let alice = admin_login_mfa(&app, database.clone(), "alice", ADMIN_PASSWORD, &secret).await;
let mut attempt = 0;
for body in [json!({}), json!({ "password": "not-the-password" })] {
attempt += 1;
let refused = admin_request_from(
&app,
Method::POST,
"/api/operators/bob/disable",
Some(&alice),
Some(body),
&format!("192.0.2.{attempt}:1234"),
)
.await;
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(refused).await["error"], "invalid_credentials");
}
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&bob), None)
.await
.status(),
StatusCode::OK
);
let response = admin_request(
&app,
Method::POST,
"/api/operators/bob/disable",
Some(&alice),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn a_sign_in_from_a_new_address_notifies_the_operator() {
let (app, _database, _session, notify) =
test_admin_app_logged_in_with_security_notify(admin_config()).await;
let _ = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let response = admin_request_from(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
"203.0.113.77:5555",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let events = notify.recorded(1).await;
assert_eq!(events.len(), 1, "exactly the new-address login fired");
match &events[0] {
acme_proxy::notify::NotifyEvent::AdminSignIn(data) => {
assert_eq!(data.username, "alice");
assert_eq!(data.recipient.as_deref(), Some("alice@example.com"));
assert_eq!(data.client_ip.as_deref(), Some("203.0.113.77"));
assert!(matches!(
data.outcome,
acme_proxy::notify::AdminSignInOutcome::SucceededFromNewAddress
));
}
other => panic!("expected AdminSignIn, got {other:?}"),
}
}
#[tokio::test]
async fn a_password_change_notifies_the_operator() {
let (app, _database, session, notify) =
test_admin_app_logged_in_with_security_notify(admin_config()).await;
let response = admin_request(
&app,
Method::POST,
"/api/account/password",
Some(&session),
Some(json!({
"current_password": ADMIN_PASSWORD,
"new_password": "another-long-enough-password",
})),
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let events = notify.recorded(1).await;
match &events[0] {
acme_proxy::notify::NotifyEvent::AdminCredentialChanged(data) => {
assert_eq!(data.username, "alice");
assert_eq!(data.recipient.as_deref(), Some("alice@example.com"));
assert!(data.by_self);
assert!(matches!(
data.change,
acme_proxy::notify::AdminCredentialChange::Password
));
}
other => panic!("expected AdminCredentialChanged, got {other:?}"),
}
}
#[tokio::test]
async fn a_first_sign_in_and_a_contactless_operator_are_silent() {
let (app, database, _session, notify) =
test_admin_app_logged_in_with_security_notify(admin_config()).await;
acme_proxy::admin::users::create_user(
"quiet",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let response = admin_request_from(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "quiet", "password": ADMIN_PASSWORD })),
"203.0.113.99:5555",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert!(
notify.settled().await.is_empty(),
"a first-ever sign-in has no 'usual' address to differ from, and an \
operator with no contact address names no recipient"
);
}
#[tokio::test]
async fn a_refused_second_factor_notifies_the_operator() {
let (app, database, _session, notify) =
test_admin_app_logged_in_with_security_notify(admin_config()).await;
enrol_totp(database.clone(), "alice").await;
let pending = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(pending.status(), StatusCode::OK);
let cookie = session_cookie_token(&pending).expect("a pending session cookie");
let refused = send_from(
&app,
axum::http::Request::builder()
.method(Method::POST)
.uri("/api/session/mfa")
.header(
header::COOKIE,
format!("__Host-acme_admin_session={cookie}"),
)
.header(header::ORIGIN, "http://localhost:3001")
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
json!({ "code": "000000" }).to_string(),
))
.unwrap(),
"127.0.0.1:40000",
)
.await;
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
let events = notify.recorded(1).await;
match &events[0] {
acme_proxy::notify::NotifyEvent::AdminSignIn(data) => {
assert_eq!(data.username, "alice");
assert_eq!(data.recipient.as_deref(), Some("alice@example.com"));
assert!(matches!(
data.outcome,
acme_proxy::notify::AdminSignInOutcome::SecondFactorRefused
));
}
other => panic!("expected AdminSignIn, got {other:?}"),
}
}
#[tokio::test]
async fn resetting_a_colleagues_factor_notifies_them_as_not_self() {
let (app, database, alice, notify) =
test_admin_app_logged_in_with_security_notify(admin_config()).await;
acme_proxy::admin::users::create_user(
"bob",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
acme_proxy::admin::users::set_contact_email("bob", Some("bob@example.com"), database.clone())
.await
.unwrap()
.unwrap();
enrol_totp(database.clone(), "bob").await;
let response = admin_request(
&app,
Method::POST,
"/api/operators/bob/totp/reset",
Some(&alice),
Some(json!({ "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let events = notify.recorded(1).await;
match &events[0] {
acme_proxy::notify::NotifyEvent::AdminCredentialChanged(data) => {
assert_eq!(data.username, "bob", "the message goes to the subject");
assert_eq!(data.recipient.as_deref(), Some("bob@example.com"));
assert!(!data.by_self, "somebody else made this change");
assert!(matches!(
data.change,
acme_proxy::notify::AdminCredentialChange::SecondFactorDisabled
));
}
other => panic!("expected AdminCredentialChanged, got {other:?}"),
}
}