mod common;
use acme_proxy::admin::password::PasswordContext;
use acme_proxy::audit::{Actor, AuditEvent, AuditRecord, ClientContext};
use acme_proxy::sqlite::audit::AuditEntry;
use axum::http::{Method, StatusCode, header};
use common::*;
#[tokio::test]
async fn the_sign_in_page_renders_without_a_session_and_carries_no_htmx() {
let (app, _database) = test_admin_app(admin_config()).await;
let response = admin_page(&app, "/ui/login", None, false).await;
assert_eq!(response.status(), StatusCode::OK);
assert!(
response.headers()[header::CONTENT_TYPE]
.to_str()
.unwrap()
.starts_with("text/html")
);
let body = html_body(response).await;
assert!(body.starts_with("<!doctype html>"));
assert!(body.contains(r#"name="username""#));
assert!(body.contains(r#"name="password""#));
assert!(!body.contains("htmx.min.js"));
assert!(body.contains(r#"action="/ui/login""#));
}
#[tokio::test]
async fn a_form_login_sets_the_same_hardened_cookie_and_redirects_to_the_panel() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
database,
)
.await
.unwrap();
let response = admin_form_request(
&app,
Method::POST,
"/ui/login",
None,
Some(&[("username", "alice"), ("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers()[header::LOCATION], "/ui/");
let set_cookie = response.headers()[header::SET_COOKIE].to_str().unwrap();
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("Domain"));
assert!(session_cookie_token(&response).is_some());
}
#[tokio::test]
async fn a_failed_form_login_re_renders_the_page_with_its_real_status() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
database,
)
.await
.unwrap();
let response = admin_form_request(
&app,
Method::POST,
"/ui/login",
None,
Some(&[("username", "alice"), ("password", "wrong-password")]),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(session_cookie_token(&response).is_none());
let body = html_body(response).await;
assert!(body.contains("invalid username or password"));
assert!(body.contains("invalid_credentials"));
assert!(body.contains(r#"value="alice""#));
}
#[tokio::test]
async fn the_sign_in_page_is_rate_limited_like_the_api() {
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(),
database,
)
.await
.unwrap();
for _ in 0..2 {
admin_form_request(
&app,
Method::POST,
"/ui/login",
None,
Some(&[("username", "alice"), ("password", "wrong-password")]),
)
.await;
}
let response = admin_form_request(
&app,
Method::POST,
"/ui/login",
None,
Some(&[("username", "alice"), ("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(html_body(response).await.contains("rate_limited"));
}
#[tokio::test]
async fn the_challenge_page_renders_for_a_pending_session_and_carries_no_htmx() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
database.clone(),
)
.await
.unwrap();
enrol_totp(database, "alice").await;
let response = admin_form_request(
&app,
Method::POST,
"/ui/login",
None,
Some(&[("username", "alice"), ("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers()[header::LOCATION],
"/ui/login/mfa",
"a factor-bearing operator goes to the challenge, not the panel"
);
let cookie = session_cookie_token(&response).expect("the pending cookie is still set");
let pending = AdminSessionHandle {
cookie,
csrf: String::new(),
};
let page = admin_page(&app, "/ui/login/mfa", Some(&pending), false).await;
assert_eq!(page.status(), StatusCode::OK);
let body = html_body(page).await;
assert!(body.starts_with("<!doctype html>"));
assert!(body.contains(r#"name="code""#));
assert!(body.contains(r#"action="/ui/login/mfa""#));
assert!(
!body.contains("htmx.min.js"),
"finishing a sign-in must work with JavaScript off, like starting one"
);
assert!(!body.contains(r#"href="/ui/accounts""#));
assert!(!body.contains("alice"));
}
#[tokio::test]
async fn a_form_second_step_completes_the_sign_in_and_rotates_the_cookie() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
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 started = admin_form_request(
&app,
Method::POST,
"/ui/login",
None,
Some(&[("username", "alice"), ("password", ADMIN_PASSWORD)]),
)
.await;
let cookie = session_cookie_token(&started).unwrap();
let pending = AdminSessionHandle {
cookie: cookie.clone(),
csrf: String::new(),
};
let refused = admin_form_request(
&app,
Method::POST,
"/ui/login/mfa",
Some(&pending),
Some(&[("code", "000000")]),
)
.await;
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
let body = html_body(refused).await;
assert!(body.contains("invalid_credentials"));
assert!(body.contains(r#"name="code""#));
let done = admin_form_request(
&app,
Method::POST,
"/ui/login/mfa",
Some(&pending),
Some(&[("code", &totp_code(&secret, 0))]),
)
.await;
assert_eq!(done.status(), StatusCode::SEE_OTHER);
assert_eq!(done.headers()[header::LOCATION], "/ui/");
let promoted = session_cookie_token(&done).expect("promotion must set the rotated cookie");
assert_ne!(promoted, cookie);
let session = AdminSessionHandle {
cookie: promoted,
csrf: String::new(),
};
assert_eq!(
admin_page(&app, "/ui/", Some(&session), false)
.await
.status(),
StatusCode::OK
);
assert_eq!(
admin_page(&app, "/ui/", Some(&pending), false)
.await
.status(),
StatusCode::SEE_OTHER
);
}
#[tokio::test]
async fn a_half_authenticated_cookie_reaches_no_page() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
database.clone(),
)
.await
.unwrap();
enrol_totp(database, "alice").await;
let started = admin_form_request(
&app,
Method::POST,
"/ui/login",
None,
Some(&[("username", "alice"), ("password", ADMIN_PASSWORD)]),
)
.await;
let pending = AdminSessionHandle {
cookie: session_cookie_token(&started).unwrap(),
csrf: String::new(),
};
for path in authenticated_pages() {
let response = admin_page(&app, path, Some(&pending), false).await;
assert_eq!(
response.status(),
StatusCode::SEE_OTHER,
"GET {path} must bounce a half-authenticated cookie"
);
assert_eq!(response.headers()[header::LOCATION], "/ui/login");
}
}
#[tokio::test]
async fn require_mfa_turns_the_challenge_page_into_an_enrolment_page() {
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(),
database,
)
.await
.unwrap();
let started = admin_form_request(
&app,
Method::POST,
"/ui/login",
None,
Some(&[("username", "alice"), ("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(started.headers()[header::LOCATION], "/ui/login/mfa");
let pending = AdminSessionHandle {
cookie: session_cookie_token(&started).unwrap(),
csrf: String::new(),
};
let page = admin_page(&app, "/ui/login/mfa", Some(&pending), false).await;
assert_eq!(page.status(), StatusCode::OK);
let body = html_body(page).await;
assert!(body.contains("Set up a second factor"));
assert!(
body.contains("otpauth://totp/acme-proxy:alice@"),
"the provisioning URI must be present and auto-escaped"
);
let secret_base32 = between(&body, r#"<pre class="secret">"#, "</pre>");
let secret = base32_decode(&secret_base32);
let again = html_body(admin_page(&app, "/ui/login/mfa", Some(&pending), false).await).await;
assert!(again.contains(&secret_base32), "the enrolment must resume");
let confirmed = admin_form_request(
&app,
Method::POST,
"/ui/login/mfa",
Some(&pending),
Some(&[("code", &totp_code(&secret, 0))]),
)
.await;
assert_eq!(
confirmed.status(),
StatusCode::OK,
"the recovery codes exist for one moment; a 303 would spend it"
);
let promoted =
session_cookie_token(&confirmed).expect("finishing enrolment finishes the login");
let body = html_body(confirmed).await;
assert!(body.contains("Store these recovery codes now"));
assert!(body.contains(r#"href="/ui/""#));
let session = AdminSessionHandle {
cookie: promoted,
csrf: String::new(),
};
assert_eq!(
admin_page(&app, "/ui/", Some(&session), false)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn the_account_page_runs_the_whole_enrolment_lifecycle() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let body = html_body(admin_page(&app, "/ui/account", Some(&session), false).await).await;
assert!(body.contains("Two-factor authentication"));
assert!(body.contains("Set one up"));
let begun = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp",
Some(&session),
Some(&[]),
)
.await;
assert_eq!(begun.status(), StatusCode::OK);
let body = html_body(begun).await;
assert!(
body.contains(r#"id="account-mfa""#),
"the swap target survives"
);
let secret_base32 = between(&body, r#"<pre class="secret">"#, "</pre>");
let secret = base32_decode(&secret_base32);
let refused = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp/confirm",
Some(&session),
Some(&[("code", "000000")]),
)
.await;
assert_eq!(refused.status(), StatusCode::BAD_REQUEST);
let body = html_body(refused).await;
assert!(body.contains("That code did not match"));
assert!(
body.contains(&secret_base32),
"the pending secret survives a wrong code, so the next one can be tried"
);
let confirmed = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp/confirm",
Some(&session),
Some(&[("code", &totp_code(&secret, 0))]),
)
.await;
assert_eq!(confirmed.status(), StatusCode::OK);
let body = html_body(confirmed).await;
assert!(body.contains("Store these recovery codes now"));
assert!(body.contains("10 unused"));
let codes = between(&body, r#"<pre class="secret">"#, "</pre>");
assert_eq!(codes.lines().count(), 10);
let body = html_body(admin_page(&app, "/ui/account", Some(&session), false).await).await;
for code in codes.lines() {
assert!(!body.contains(code.trim()), "a recovery code is shown once");
}
assert!(!body.contains(&secret_base32), "so is the secret");
let refused = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/recovery-codes",
Some(&session),
Some(&[("password", "not-the-password")]),
)
.await;
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
let body = html_body(refused).await;
assert!(body.contains("That password is not correct"));
assert!(
body.contains(r#"id="account-mfa""#),
"a wrong password is this card's own banner, not an error page"
);
let reissued = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/recovery-codes",
Some(&session),
Some(&[("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(reissued.status(), StatusCode::OK);
let body = html_body(reissued).await;
let fresh = between(&body, r#"<pre class="secret">"#, "</pre>");
assert_ne!(fresh, codes, "the previous set is superseded");
let refused = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp",
Some(&session),
Some(&[("password", "not-the-password")]),
)
.await;
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
assert!(
html_body(refused)
.await
.contains("That password is not correct"),
"the refusal is this card's banner"
);
assert_eq!(
admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp/disable",
Some(&session),
Some(&[("password", "not-the-password")]),
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
let off = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp/disable",
Some(&session),
Some(&[("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(off.status(), StatusCode::OK);
assert!(
html_body(off)
.await
.contains("Two-factor authentication is off")
);
let after = acme_proxy::sqlite::admin_user::AdminUser::find_by_username("alice", &database)
.await
.unwrap()
.unwrap();
assert!(!after.has_totp());
}
#[tokio::test]
async fn the_account_page_reports_its_refusals_as_banners() {
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(),
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 body = html_body(admin_page(&app, "/ui/account", Some(&session), false).await).await;
assert!(
!body.contains("Turn off"),
"the button is not dangled when the server refuses the action"
);
let refused = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp/disable",
Some(&session),
Some(&[]),
)
.await;
assert_eq!(refused.status(), StatusCode::CONFLICT);
let body = html_body(refused).await;
assert!(body.contains("requires a second factor"));
assert!(body.contains(r#"id="account-mfa""#));
}
#[tokio::test]
async fn a_rate_limited_step_up_is_a_banner_at_its_own_status() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
database.clone(),
)
.await
.unwrap();
let secret = enrol_totp(database.clone(), "alice").await;
let session = admin_login_mfa(&app, database, "alice", ADMIN_PASSWORD, &secret).await;
for _ in 0..5 {
let refused = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp",
Some(&session),
Some(&[("password", "not-the-password")]),
)
.await;
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
}
let limited = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp",
Some(&session),
Some(&[("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
let body = html_body(limited).await;
assert!(
body.contains(r#"id="account-mfa""#),
"a lockout is this card's own banner, not an error page"
);
assert!(
body.contains("too many"),
"the banner has to say what happened, and how long to wait"
);
}
#[tokio::test]
async fn signing_out_clears_the_cookie_and_redirects_both_kinds_of_caller() {
for hx in [false, true] {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let mut builder = axum::http::Request::builder()
.method(Method::POST)
.uri("/ui/logout")
.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", session.cookie),
)
.header("x-csrf-token", &session.csrf)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
if hx {
builder = builder.header("hx-request", "true");
}
let response = send_from(
&app,
builder.body(axum::body::Body::empty()).unwrap(),
"127.0.0.1:40000",
)
.await;
if hx {
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert_eq!(response.headers()["hx-redirect"], "/ui/login");
} else {
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers()[header::LOCATION], "/ui/login");
}
assert!(
response.headers()[header::SET_COOKIE]
.to_str()
.unwrap()
.contains("Max-Age=0"),
"hx={hx}"
);
let after = admin_page(&app, "/ui/accounts", Some(&session), false).await;
assert_eq!(after.status(), StatusCode::SEE_OTHER, "hx={hx}");
}
}
fn authenticated_pages() -> Vec<&'static str> {
vec![
"/ui/",
"/ui/accounts",
"/ui/accounts/some-id",
"/ui/orders",
"/ui/orders/some-id",
"/ui/orders/some-id/chain.pem",
"/ui/expiring",
"/ui/audit",
"/ui/audit/1",
"/ui/eab",
"/ui/eab/some-kid",
"/ui/nonces",
"/ui/profiles",
"/ui/profiles/default/filter",
"/ui/account",
]
}
#[tokio::test]
async fn every_page_without_a_session_redirects_the_way_its_caller_understands() {
let (app, _database) = test_admin_app(admin_config()).await;
for path in authenticated_pages() {
let browser = admin_page(&app, path, None, false).await;
assert_eq!(browser.status(), StatusCode::SEE_OTHER, "GET {path}");
assert_eq!(browser.headers()[header::LOCATION], "/ui/login", "{path}");
assert!(!browser.headers().contains_key("hx-redirect"), "{path}");
let htmx = admin_page(&app, path, None, true).await;
assert_eq!(htmx.status(), StatusCode::NO_CONTENT, "htmx GET {path}");
assert_eq!(htmx.headers()["hx-redirect"], "/ui/login", "{path}");
assert!(!htmx.headers().contains_key(header::LOCATION), "{path}");
}
}
#[tokio::test]
async fn an_expired_session_redirects_rather_than_answering_json() {
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_page(&app, "/ui/accounts", Some(&session), false).await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers()[header::LOCATION], "/ui/login");
}
fn mutating_page_endpoints() -> Vec<(Method, &'static str)> {
vec![
(Method::POST, "/ui/accounts/some-id/contact"),
(Method::POST, "/ui/accounts/some-id/deactivate"),
(Method::DELETE, "/ui/accounts/some-id"),
(Method::POST, "/ui/orders/some-id/revoke"),
(Method::DELETE, "/ui/orders/some-id"),
(Method::POST, "/ui/eab"),
(Method::POST, "/ui/eab/some-kid/revoke"),
(Method::POST, "/ui/nonces/cleanup"),
(Method::POST, "/ui/logout"),
(Method::POST, "/ui/account/mfa/totp"),
(Method::POST, "/ui/account/mfa/totp/confirm"),
(Method::POST, "/ui/account/mfa/totp/disable"),
(Method::POST, "/ui/account/mfa/recovery-codes"),
]
}
#[tokio::test]
async fn every_mutating_page_endpoint_refuses_a_missing_csrf_token() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
for (method, path) in mutating_page_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/x-www-form-urlencoded")
.body(axum::body::Body::empty())
.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!(!response.headers().contains_key("hx-redirect"), "{path}");
assert!(html_body(response).await.contains("csrf_failed"), "{path}");
}
}
#[tokio::test]
async fn every_mutating_page_endpoint_refuses_another_sessions_csrf_token() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
acme_proxy::admin::users::create_user(
"bob",
ADMIN_PASSWORD,
&PasswordContext::empty(),
database,
)
.await
.unwrap();
let other = admin_login(&app, "bob", ADMIN_PASSWORD).await;
for (method, path) in mutating_page_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/x-www-form-urlencoded")
.body(axum::body::Body::empty())
.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_page_write_is_refused_before_anything_else() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let request = axum::http::Request::builder()
.method(Method::POST)
.uri("/ui/eab")
.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", session.cookie),
)
.header("x-csrf-token", &session.csrf)
.header(header::ORIGIN, "http://evil.example.com")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(axum::body::Body::empty())
.unwrap();
let response = send_from(&app, request, "127.0.0.1:40000").await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn page_reads_need_no_csrf_token() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
for path in authenticated_pages() {
let response = admin_page(&app, path, Some(&session), false).await;
assert!(
response.status() == StatusCode::OK || response.status() == StatusCode::NOT_FOUND,
"GET {path} answered {}",
response.status()
);
}
}
#[tokio::test]
async fn a_stored_script_tag_is_escaped_everywhere_it_is_rendered() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
const HOSTILE: &str = "<script>alert(1)</script>";
let created = admin_form_request(
&app,
Method::POST,
"/ui/eab",
Some(&session),
Some(&[("label", HOSTILE), ("profile", "")]),
)
.await;
assert_eq!(created.status(), StatusCode::CREATED);
let created_body = html_body(created).await;
assert!(!created_body.contains(HOSTILE));
assert!(created_body.contains("<script>"));
let listed = html_body(admin_page(&app, "/ui/eab", Some(&session), false).await).await;
assert!(
!listed.contains(HOSTILE),
"the list rendered a raw <script> tag"
);
assert!(listed.contains("<script>"));
}
#[tokio::test]
async fn a_hostile_reverse_name_is_escaped_on_both_account_surfaces() {
use acme_proxy::sqlite::account::Account;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
const HOSTILE: &str = "<script>alert(1)</script>";
let (account, _) = Account::find_or_create(
PROFILE,
&[7u8, 7],
vec![],
&ClientContext {
ip: Some("198.51.100.9".to_string()),
ptr: Some(HOSTILE.to_string()),
..ClientContext::default()
},
&database,
)
.await
.unwrap();
for path in [
"/ui/accounts".to_string(),
format!("/ui/accounts/{}", account.id),
] {
let body = html_body(admin_page(&app, &path, Some(&session), false).await).await;
assert!(body.contains("198.51.100.9"), "{path}: {body}");
assert!(
!body.contains(HOSTILE),
"{path} rendered a raw <script> tag"
);
assert!(body.contains("<script>"), "{path}: {body}");
}
}
#[tokio::test]
async fn an_id_echoed_into_a_not_found_page_is_escaped() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
for path in [
"/ui/accounts/%3Cscript%3Ealert(1)%3C%2Fscript%3E",
"/ui/profiles/%3Cscript%3Ealert(1)%3C%2Fscript%3E/filter",
] {
let response = admin_page(&app, path, Some(&session), false).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
let body = html_body(response).await;
assert!(!body.contains("<script>alert"), "{path}");
assert!(body.contains("<script>"), "{path}");
assert!(body.contains("<code>not_found</code>"), "{path}");
}
}
#[tokio::test]
async fn a_list_route_serves_a_document_or_a_bare_fragment() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
seed(&database, 2).await;
for (path, marker) in [
("/ui/accounts", "accounts-table"),
("/ui/orders", "orders-table"),
("/ui/expiring", "expiring-table"),
("/ui/eab", "eab-table"),
("/ui/nonces", "nonces-panel"),
] {
let page = html_body(admin_page(&app, path, Some(&session), false).await).await;
assert!(page.starts_with("<!doctype html>"), "{path}");
assert!(page.contains("htmx.min.js"), "{path}");
assert!(page.contains(marker), "{path}");
let fragment = html_body(admin_page(&app, path, Some(&session), true).await).await;
assert!(
!fragment.contains("<!doctype html>"),
"{path} served a whole document into an htmx swap"
);
assert!(!fragment.contains("<body"), "{path}");
assert!(fragment.contains(marker), "{path}");
}
}
#[tokio::test]
async fn the_layout_carries_the_csrf_token_every_write_depends_on() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let body = html_body(admin_page(&app, "/ui/", Some(&session), false).await).await;
assert!(body.contains(&format!(
r#"hx-headers='{{"X-CSRF-Token": "{}"}}'"#,
session.csrf
)));
assert!(body.contains("alice"));
}
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![],
&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);
}
ids
}
#[tokio::test]
async fn the_overview_counts_every_resource() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
seed(&database, 3).await;
let body = html_body(admin_page(&app, "/ui/", Some(&session), false).await).await;
assert!(body.contains("Overview"));
assert!(body.contains(r#"<div class="value">3</div>"#));
assert!(body.contains(PROFILE));
}
#[tokio::test]
async fn an_account_page_shows_the_account_and_its_orders() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let body = html_body(
admin_page(
&app,
&format!("/ui/accounts/{}", ids[0]),
Some(&session),
false,
)
.await,
)
.await;
assert!(body.contains(&ids[0]));
assert!(body.contains("host0.example.com"));
assert!(body.contains("account-card"));
assert!(body.contains("Created from"), "{body}");
assert!(body.contains("Last seen from"), "{body}");
assert!(body.contains("203.0.113.0"), "{body}");
assert!(body.contains("client0.example.net"), "{body}");
}
#[tokio::test]
async fn an_account_page_shows_an_address_that_never_resolved() {
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,
&[9u8, 9],
vec![],
&ClientContext {
ip: Some("198.51.100.4".to_string()),
ptr: None,
..ClientContext::default()
},
&database,
)
.await
.unwrap();
let body = html_body(
admin_page(
&app,
&format!("/ui/accounts/{}", account.id),
Some(&session),
false,
)
.await,
)
.await;
assert!(body.contains("Created from"), "{body}");
assert!(body.contains("198.51.100.4"), "{body}");
assert!(body.contains("Last seen from"), "{body}");
}
#[tokio::test]
async fn the_account_list_shows_where_each_key_was_last_seen() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 2).await;
let body = html_body(admin_page(&app, "/ui/accounts", Some(&session), false).await).await;
assert!(body.contains("Last seen from"), "{body}");
assert!(body.contains("203.0.113.1"), "{body}");
assert!(body.contains("client1.example.net"), "{body}");
assert!(!body.contains("<th>Key</th>"), "{body}");
let card = html_body(
admin_page(
&app,
&format!("/ui/accounts/{}", ids[0]),
Some(&session),
false,
)
.await,
)
.await;
assert!(card.contains("Public key"), "{card}");
}
#[tokio::test]
async fn editing_a_contact_accepts_a_textarea_and_refuses_what_new_account_refuses() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let path = format!("/ui/accounts/{}/contact", ids[0]);
let good = admin_form_request(
&app,
Method::POST,
&path,
Some(&session),
Some(&[("contact", "mailto:a@example.com\nmailto:b@example.com")]),
)
.await;
assert_eq!(good.status(), StatusCode::OK);
let body = html_body(good).await;
assert!(body.contains("Contact updated."));
assert!(body.contains("mailto:a@example.com"));
let bad = admin_form_request(
&app,
Method::POST,
&path,
Some(&session),
Some(&[("contact", "tel:+15551234")]),
)
.await;
assert_eq!(bad.status(), StatusCode::OK);
let body = html_body(bad).await;
assert!(body.contains("unsupported scheme"));
assert!(body.contains("bad_request"));
}
#[tokio::test]
async fn deactivating_an_account_swaps_the_card_and_deleting_it_redirects() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 2).await;
let deactivated = admin_form_request(
&app,
Method::POST,
&format!("/ui/accounts/{}/deactivate", ids[0]),
Some(&session),
Some(&[]),
)
.await;
assert_eq!(deactivated.status(), StatusCode::OK);
let body = html_body(deactivated).await;
assert!(body.contains("Account deactivated"));
assert!(body.contains(r#"<span class="badge deactivated">deactivated</span>"#));
let deleted = admin_form_request(
&app,
Method::DELETE,
&format!("/ui/accounts/{}", ids[1]),
Some(&session),
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::SEE_OTHER);
assert_eq!(deleted.headers()[header::LOCATION], "/ui/accounts");
assert_eq!(
admin_page(
&app,
&format!("/ui/accounts/{}", ids[1]),
Some(&session),
false
)
.await
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn an_eab_secret_is_shown_once_and_the_list_refreshes_out_of_band() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let created = admin_form_request(
&app,
Method::POST,
"/ui/eab",
Some(&session),
Some(&[("label", "team-a"), ("profile", "")]),
)
.await;
assert_eq!(created.status(), StatusCode::CREATED);
let body = html_body(created).await;
assert!(body.contains("Copy the HMAC key now"));
assert!(body.contains(r#"id="eab-table" hx-swap-oob="true""#));
let kid = body
.split("<dt>Key ID</dt><dd><code>")
.nth(1)
.and_then(|rest| rest.split("</code>").next())
.expect("the created panel must name the kid")
.to_string();
let listed = html_body(admin_page(&app, "/ui/eab", Some(&session), false).await).await;
assert!(!listed.contains("hmacKey"));
assert!(!listed.contains("Copy the HMAC key"));
let detail =
html_body(admin_page(&app, &format!("/ui/eab/{kid}"), Some(&session), false).await).await;
assert!(detail.contains(&kid));
assert!(!detail.contains("HMAC key"));
let revoked = admin_form_request(
&app,
Method::POST,
&format!("/ui/eab/{kid}/revoke"),
Some(&session),
Some(&[]),
)
.await;
assert_eq!(revoked.status(), StatusCode::OK);
let body = html_body(revoked).await;
assert!(body.contains("Credential revoked"));
assert!(body.contains(r#"<span class="badge revoked">revoked</span>"#));
}
#[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_form_request(
&app,
Method::POST,
"/ui/eab",
Some(&session),
Some(&[("label", ""), ("profile", "not-mounted")]),
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(html_body(response).await.contains("no profile named"));
}
#[tokio::test]
async fn revoking_an_issued_order_shows_a_banner_and_then_a_conflict() {
use acme_proxy::signer::RequestedValidity;
use acme_proxy::sqlite::account::Account;
use acme_proxy::sqlite::order::{Identifier, Order};
let (app, database, signer) = test_admin_app_with_signer(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
database.clone(),
)
.await
.unwrap();
let session = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let (account, _) = Account::find_or_create(
PROFILE,
&[7u8, 7],
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 = {
use base64::prelude::*;
BASE64_URL_SAFE_NO_PAD
.decode(make_csr("revoke-me.example.com"))
.unwrap()
};
let issued = signer
.issue(
&order.id,
&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();
order
.finalize(chain, serial, spki, None, &database)
.await
.unwrap();
let path = format!("/ui/orders/{}/revoke", order.id);
let response = admin_form_request(
&app,
Method::POST,
&path,
Some(&session),
Some(&[("reason", "1")]),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = html_body(response).await;
assert!(body.contains("Certificate revoked"));
assert!(body.contains(r#"<span class="badge revoked">revoked</span>"#));
assert!(body.contains("reason 1"));
assert!(
!signer
.crl_der()
.await
.expect("a local CA always has a CRL")
.is_empty()
);
let again = admin_form_request(
&app,
Method::POST,
&path,
Some(&session),
Some(&[("reason", "")]),
)
.await;
assert_eq!(again.status(), StatusCode::OK);
let body = html_body(again).await;
assert!(body.contains("already_revoked"));
assert!(body.contains("order-card"));
}
async fn issue_into_an_order(
database: &std::sync::Arc<acme_proxy::sqlite::db::Database>,
signer: &std::sync::Arc<dyn acme_proxy::signer::SignerBackend>,
name: &str,
) -> (String, String) {
use acme_proxy::signer::RequestedValidity;
use acme_proxy::sqlite::account::Account;
use acme_proxy::sqlite::order::{Identifier, Order};
let (account, _) = Account::find_or_create(
PROFILE,
&[9u8, 9],
vec![],
&ClientContext::default(),
database,
)
.await
.unwrap();
let identifier = Identifier::dns(name);
let mut order = Order::create(
PROFILE,
&account.id,
vec![identifier.clone()],
2_000_000_000,
None,
None,
database,
)
.await
.unwrap();
let csr_der = {
use base64::prelude::*;
BASE64_URL_SAFE_NO_PAD.decode(make_csr(name)).unwrap()
};
let issued = signer
.issue(
&order.id,
&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 leaf_der = first_certificate(&chain);
let (serial, spki) = acme_proxy::cert::cert_serial_and_spki(&leaf_der).unwrap();
let cert_not_after = acme_proxy::cert::cert_validity(&leaf_der)
.ok()
.map(|(_, not_after)| not_after);
order
.finalize(chain.clone(), serial, spki, cert_not_after, database)
.await
.unwrap();
(order.id, chain)
}
#[tokio::test]
async fn an_issued_order_card_shows_the_chain_and_offers_it_for_download() {
let (app, database, signer) = test_admin_app_with_signer(admin_config()).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
database.clone(),
)
.await
.unwrap();
let session = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let (order_id, chain) = issue_into_an_order(&database, &signer, "chain.example.com").await;
let response = admin_page(
&app,
&format!("/ui/orders/{order_id}"),
Some(&session),
false,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = html_body(response).await;
assert!(
body.contains("-----BEGIN CERTIFICATE-----"),
"the card must render the PEM itself"
);
assert!(
body.contains(&format!("/ui/orders/{order_id}/chain.pem")),
"and offer the download route"
);
assert!(
!body.contains("/profile/default/certificate/"),
"the ACME certificate URL is unreachable from a browser and must not \
be presented as if it were a link"
);
let (serial, _) = acme_proxy::cert::cert_serial_and_spki(&first_certificate(&chain)).unwrap();
assert!(
body.contains("<dt>Serial</dt>"),
"the card must name the serial"
);
assert!(
body.contains(&serial),
"and it must be this order's: {serial}"
);
assert!(
body.contains("<dt>Certificate expires</dt>"),
"the leaf's own expiry is a different date from the requested window \
above it, and the card carried neither"
);
let download = admin_page(
&app,
&format!("/ui/orders/{order_id}/chain.pem"),
Some(&session),
false,
)
.await;
assert_eq!(download.status(), StatusCode::OK);
assert_eq!(
download.headers()[header::CONTENT_TYPE],
"application/pem-certificate-chain"
);
assert_eq!(
download.headers()[header::CONTENT_DISPOSITION],
format!("attachment; filename=\"{order_id}.pem\"")
);
assert_eq!(
html_body(download).await,
chain,
"the download must be the stored chain byte for byte"
);
}
#[tokio::test]
async fn downloading_a_chain_from_an_unissued_order_is_a_404() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let (orders, _) = acme_proxy::sqlite::order::Order::search(
&acme_proxy::sqlite::order::OrderQuery {
account_id: Some(ids[0].clone()),
limit: 10,
..Default::default()
},
&database,
)
.await
.unwrap();
let order_id = &orders[0].id;
let response = admin_page(
&app,
&format!("/ui/orders/{order_id}/chain.pem"),
Some(&session),
false,
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let missing = admin_page(
&app,
"/ui/orders/no-such-order/chain.pem",
Some(&session),
false,
)
.await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn revoking_an_order_with_no_certificate_is_a_banner_not_a_page() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let (orders, _) = acme_proxy::sqlite::order::Order::search(
&acme_proxy::sqlite::order::OrderQuery {
account_id: Some(ids[0].clone()),
limit: 10,
..Default::default()
},
&database,
)
.await
.unwrap();
let response = admin_form_request(
&app,
Method::POST,
&format!("/ui/orders/{}/revoke", orders[0].id),
Some(&session),
Some(&[("reason", "")]),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = html_body(response).await;
assert!(body.contains("order_not_issued"));
assert!(body.contains("order-card"));
}
#[tokio::test]
async fn revoking_covers_the_refusals_that_are_not_the_rows_state() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let (orders, _) = acme_proxy::sqlite::order::Order::search(
&acme_proxy::sqlite::order::OrderQuery {
account_id: Some(ids[0].clone()),
limit: 10,
..Default::default()
},
&database,
)
.await
.unwrap();
let path = format!("/ui/orders/{}/revoke", orders[0].id);
let bad = admin_form_request(
&app,
Method::POST,
&path,
Some(&session),
Some(&[("reason", "not-a-number")]),
)
.await;
assert_eq!(bad.status(), StatusCode::BAD_REQUEST);
assert!(html_body(bad).await.contains("is not a number"));
let missing = admin_form_request(
&app,
Method::POST,
"/ui/orders/no-such-order/revoke",
Some(&session),
Some(&[("reason", "")]),
)
.await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
assert!(html_body(missing).await.contains("no such order"));
}
#[tokio::test]
async fn revoking_an_order_from_an_unmounted_profile_is_a_banner() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let (orders, _) = acme_proxy::sqlite::order::Order::search(
&acme_proxy::sqlite::order::OrderQuery {
account_id: Some(ids[0].clone()),
limit: 10,
..Default::default()
},
&database,
)
.await
.unwrap();
sqlx::query("UPDATE orders SET profile = 'gone' WHERE id = ?")
.bind(&orders[0].id)
.execute(&database.pool)
.await
.unwrap();
let response = admin_form_request(
&app,
Method::POST,
&format!("/ui/orders/{}/revoke", orders[0].id),
Some(&session),
Some(&[("reason", "")]),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = html_body(response).await;
assert!(body.contains("profile_not_mounted"));
assert!(body.contains("order-card"));
}
#[tokio::test]
async fn deleting_an_order_redirects_to_the_list() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 1).await;
let (orders, _) = acme_proxy::sqlite::order::Order::search(
&acme_proxy::sqlite::order::OrderQuery {
account_id: Some(ids[0].clone()),
limit: 10,
..Default::default()
},
&database,
)
.await
.unwrap();
let path = format!("/ui/orders/{}", orders[0].id);
let deleted = admin_form_request(&app, Method::DELETE, &path, Some(&session), None).await;
assert_eq!(deleted.status(), StatusCode::SEE_OTHER);
assert_eq!(deleted.headers()[header::LOCATION], "/ui/orders");
assert_eq!(
admin_page(&app, &path, Some(&session), false)
.await
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
admin_form_request(&app, Method::DELETE, &path, Some(&session), None)
.await
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn the_order_list_filters_and_the_detail_links_back_to_its_account() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let ids = seed(&database, 3).await;
let all = html_body(admin_page(&app, "/ui/orders", Some(&session), false).await).await;
assert!(all.contains("host0.example.com"));
assert!(all.contains("host2.example.com"));
let filtered = html_body(
admin_page(
&app,
&format!("/ui/orders?accountId={}", ids[0]),
Some(&session),
true,
)
.await,
)
.await;
assert!(filtered.contains("host0.example.com"));
assert!(!filtered.contains("host2.example.com"));
let (orders, _) = acme_proxy::sqlite::order::Order::search(
&acme_proxy::sqlite::order::OrderQuery {
account_id: Some(ids[0].clone()),
limit: 10,
..Default::default()
},
&database,
)
.await
.unwrap();
let detail = html_body(
admin_page(
&app,
&format!("/ui/orders/{}", orders[0].id),
Some(&session),
false,
)
.await,
)
.await;
assert!(detail.contains(&format!(r#"href="/ui/accounts/{}""#, ids[0])));
}
#[tokio::test]
async fn a_page_limit_over_the_ceiling_is_clamped_rather_than_refused() {
let mut config = admin_config();
config.admin.page_size_max = 2;
let (app, database) = test_admin_app(config).await;
acme_proxy::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
database.clone(),
)
.await
.unwrap();
let session = admin_login(&app, "alice", ADMIN_PASSWORD).await;
seed(&database, 5).await;
let body =
html_body(admin_page(&app, "/ui/accounts?limit=500", Some(&session), true).await).await;
assert!(body.contains("1–2 of 5"));
assert!(body.contains("offset=2"));
}
#[tokio::test]
async fn sweeping_the_nonce_table_reports_what_it_removed() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let response = admin_form_request(
&app,
Method::POST,
"/ui/nonces/cleanup",
Some(&session),
Some(&[("ttlSeconds", "0")]),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = html_body(response).await;
assert!(body.contains("Swept"));
assert!(body.contains("nonces-panel"));
}
#[tokio::test]
async fn the_profiles_page_warns_when_an_endpoint_bypasses_validation() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let body = html_body(admin_page(&app, "/ui/profiles", Some(&session), false).await).await;
assert!(body.contains(PROFILE));
assert!(body.contains("challenge.bypass"));
assert!(body.contains(r#"<span class="badge invalid">bypassed</span>"#));
assert!(body.contains(&format!(r#"href="/ui/profiles/{PROFILE}/filter""#)));
}
#[tokio::test]
async fn the_filter_policy_page_shows_what_the_process_is_enforcing() {
let (app, _database, session) =
test_admin_app_logged_in_with_filter(admin_config(), test_filter_policy()).await;
let path = format!("/ui/profiles/{PROFILE}/filter");
let body = html_body(admin_page(&app, &path, Some(&session), false).await).await;
assert!(body.starts_with("<!doctype html>"), "{body}");
assert!(body.contains("mgmt-net"), "{body}");
assert!(body.contains("allowed_ip"), "{body}");
assert!(body.contains("connection and identifiers"), "{body}");
assert!(body.contains("identifiers only"), "{body}");
assert!(body.contains("names or (mgmt-net and names)"), "{body}");
assert!(
body.contains(r#"<span class="badge deny">deny</span>"#),
"{body}"
);
assert!(
body.contains(r#"<span class="badge allow">allow</span>"#),
"{body}"
);
assert!(
body.contains(r#"<span class="badge warn">does not decide</span>"#),
"{body}"
);
assert!(!body.contains("style="), "{body}");
assert!(!body.contains("<form"), "{body}");
assert!(!body.contains("hx-target"), "{body}");
assert!(
body.contains(r#"<a href="/ui/profiles" class="active">Profiles</a>"#),
"{body}"
);
let htmx = html_body(admin_page(&app, &path, Some(&session), true).await).await;
assert!(htmx.starts_with("<!doctype html>"), "{htmx}");
assert_eq!(
admin_page(&app, "/ui/profiles/nope/filter", Some(&session), false)
.await
.status(),
StatusCode::NOT_FOUND
);
for method in [Method::POST, Method::DELETE, Method::PATCH] {
let response = admin_form_request(&app, method.clone(), &path, Some(&session), None).await;
assert!(
response.status() == StatusCode::METHOD_NOT_ALLOWED
|| response.status() == StatusCode::NOT_FOUND,
"{method} {path} answered {}",
response.status()
);
}
}
#[tokio::test]
async fn the_filter_policy_page_says_plainly_when_nothing_is_configured() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let body = html_body(
admin_page(
&app,
&format!("/ui/profiles/{PROFILE}/filter"),
Some(&session),
false,
)
.await,
)
.await;
assert!(body.contains("filters nothing"), "{body}");
assert!(!body.contains("Evaluated at"), "{body}");
assert!(!body.contains("first match decides"), "{body}");
}
#[tokio::test]
async fn the_pages_carry_a_content_security_policy_strict_enough_to_matter() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let response = admin_page(&app, "/ui/", Some(&session), false).await;
let csp = response.headers()[header::CONTENT_SECURITY_POLICY]
.to_str()
.unwrap()
.to_string();
assert!(csp.contains("default-src 'none'"));
assert!(csp.contains("script-src 'self'"));
assert!(csp.contains("frame-ancestors 'none'"));
assert!(!csp.contains("unsafe-inline"));
assert!(!csp.contains("unsafe-eval"));
let body = html_body(response).await;
assert!(!body.contains("<script>"));
assert!(!body.contains(" style=\""));
assert!(!body.contains("//cdn."));
}
#[tokio::test]
async fn the_assets_are_served_from_this_origin_with_their_own_types() {
let (app, _database) = test_admin_app(admin_config()).await;
for (path, content_type, marker) in [
(
"/ui/static/htmx.min.js",
"text/javascript; charset=utf-8",
"htmx",
),
(
"/ui/static/admin.css",
"text/css; charset=utf-8",
".htmx-indicator",
),
] {
let response = admin_page(&app, path, None, false).await;
assert_eq!(response.status(), StatusCode::OK, "{path}");
assert_eq!(response.headers()[header::CONTENT_TYPE], content_type);
assert_eq!(
response.headers()[header::X_CONTENT_TYPE_OPTIONS],
"nosniff"
);
assert!(html_body(response).await.contains(marker), "{path}");
}
assert_eq!(
admin_page(&app, "/ui/static/../Cargo.toml", None, false)
.await
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn the_root_redirects_to_the_panel_and_an_unknown_page_is_html() {
let (app, _database) = test_admin_app(admin_config()).await;
let root = admin_page(&app, "/", None, false).await;
assert_eq!(root.status(), StatusCode::SEE_OTHER);
assert_eq!(root.headers()[header::LOCATION], "/ui/");
let missing = admin_page(&app, "/ui/nothing-here", None, false).await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
let body = html_body(missing).await;
assert!(body.starts_with("<!doctype html>"));
assert!(body.contains("no such page"));
}
#[tokio::test]
async fn the_api_keeps_its_json_shape_beside_the_html_fallback() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let response =
admin_request(&app, Method::GET, "/api/nothing-here", Some(&session), None).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(json_body(response).await["error"], "not_found");
}
#[tokio::test]
async fn recovery_codes_without_a_factor_are_refused_on_the_card() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let refused = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/recovery-codes",
Some(&session),
Some(&[]),
)
.await;
assert_eq!(refused.status(), StatusCode::CONFLICT);
let body = html_body(refused).await;
assert!(body.contains("no second factor"));
assert!(
body.contains(r#"id="account-mfa""#),
"the card's own state is a banner, not an error page"
);
}
#[tokio::test]
async fn the_audit_page_lists_rows_escapes_them_and_offers_nothing_to_write() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let id = 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()),
user_agent: Some("<script>alert(1)</script>".to_string()),
request_id: Some("req-1".to_string()),
}),
&database,
)
.await
.unwrap();
AuditEntry::insert(
AuditRecord::new(
AuditEvent::CertificateRevokeFailed,
PROFILE,
Actor::system(),
)
.with_reason("unauthorized"),
&database,
)
.await
.unwrap();
let body = html_body(admin_page(&app, "/ui/audit", Some(&session), false).await).await;
assert!(body.contains("certificate_issued"), "{body}");
assert!(body.contains("certificate_revoke_failed"), "{body}");
assert!(body.contains("203.0.113.7"), "{body}");
assert!(body.contains("host.example.com"), "{body}");
assert!(body.contains('—'), "{body}");
assert!(body.contains(r#"hx-get="/ui/audit""#), "{body}");
let detail =
html_body(admin_page(&app, &format!("/ui/audit/{id}"), Some(&session), false).await).await;
assert!(detail.contains("req-1"), "{detail}");
assert!(
!detail.contains("<script>alert(1)</script>"),
"the User-Agent must be escaped: {detail}"
);
assert!(detail.contains("<script>"), "{detail}");
let fragment = html_body(admin_page(&app, "/ui/audit", Some(&session), true).await).await;
assert!(
fragment.trim_start().starts_with("<div id=\"audit-table\""),
"{fragment}"
);
assert!(!fragment.contains("<nav>"), "{fragment}");
assert!(!fragment.contains("hx-delete"), "{fragment}");
assert!(!fragment.contains("hx-post"), "{fragment}");
let missing = admin_page(&app, "/ui/audit/999999", Some(&session), false).await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
for method in [Method::POST, Method::DELETE] {
let response = admin_request(&app, method.clone(), "/ui/audit", Some(&session), None).await;
assert!(
response.status() == StatusCode::METHOD_NOT_ALLOWED
|| response.status() == StatusCode::NOT_FOUND,
"{method} /ui/audit answered {}",
response.status()
);
}
}
#[tokio::test]
async fn the_page_surface_fails_closed_when_the_database_is_gone() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
database.pool.close().await;
for path in authenticated_pages() {
for hx in [false, true] {
let response = common::admin_page(&app, path, Some(&session), hx).await;
let status = response.status();
assert_ne!(
status,
StatusCode::OK,
"GET {path} (hx={hx}) rendered a page with no database to authorise against"
);
assert!(
!status.is_redirection(),
"GET {path} (hx={hx}) answered {status}: a database that cannot be read \
is a server fault, not an expired session, and sending the operator \
back to sign in hides an outage behind a login form"
);
assert_eq!(
status,
StatusCode::INTERNAL_SERVER_ERROR,
"GET {path} (hx={hx}) must say it could not authorise"
);
}
}
}
async fn expiring_row(
database: &std::sync::Arc<acme_proxy::sqlite::db::Database>,
account: &str,
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[..8]),
vec![1],
Some(not_after),
database,
)
.await
.unwrap();
order.id
}
#[tokio::test]
async fn the_expiring_page_annotates_rows_escapes_them_and_offers_nothing_to_write() {
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![],
&ClientContext::default(),
&database,
)
.await
.unwrap();
const DAY: i64 = 24 * 60 * 60;
let now = now_unix();
let soon = expiring_row(&database, &account.id, &["soon.example.com"], now + 3 * DAY).await;
let mid = expiring_row(&database, &account.id, &["mid.example.com"], now + 20 * DAY).await;
let renewal = expiring_row(
&database,
&account.id,
&["soon.example.com"],
now + 300 * DAY,
)
.await;
expiring_row(
&database,
&account.id,
&["<script>alert(1)</script>"],
now + 5 * DAY,
)
.await;
let body = html_body(admin_page(&app, "/ui/expiring", Some(&session), false).await).await;
assert!(body.contains("soon.example.com"), "{body}");
assert!(
body.contains(&format!(r#"href="/ui/orders/{mid}""#)),
"{body}"
);
assert!(
body.contains(&format!(r#"href="/ui/orders/{renewal}""#)),
"{body}"
);
assert!(body.contains("identifiers"), "{body}");
assert!(body.contains('—'), "{body}");
assert!(body.contains("badge urgent"), "{body}");
assert!(body.contains("badge soon"), "{body}");
assert!(!body.contains("style="), "{body}");
assert!(
body.contains(&format!(r#"href="/ui/orders/{soon}""#)),
"{body}"
);
assert!(
body.contains(&format!(r#"href="/ui/accounts/{}""#, account.id)),
"{body}"
);
assert!(!body.contains("<script>alert(1)</script>"), "{body}");
assert!(body.contains("<script>"), "{body}");
assert!(body.contains(r#"hx-get="/ui/expiring""#), "{body}");
assert!(!body.contains("hidden as already replaced"), "{body}");
let hidden =
html_body(admin_page(&app, "/ui/expiring?superseded=hide", Some(&session), false).await)
.await;
assert!(!hidden.contains("soon.example.com"), "{hidden}");
assert!(hidden.contains("mid.example.com"), "{hidden}");
assert!(hidden.contains("1 row on this page"), "{hidden}");
assert!(hidden.contains("hidden as already replaced"), "{hidden}");
let narrow =
html_body(admin_page(&app, "/ui/expiring?days=4", Some(&session), false).await).await;
assert!(narrow.contains("soon.example.com"), "{narrow}");
assert!(!narrow.contains("mid.example.com"), "{narrow}");
let empty =
html_body(admin_page(&app, "/ui/expiring?days=1", Some(&session), false).await).await;
assert!(
empty.contains("Nothing is expiring in this window."),
"{empty}"
);
let fragment = html_body(admin_page(&app, "/ui/expiring", Some(&session), true).await).await;
assert!(
fragment
.trim_start()
.starts_with("<div id=\"expiring-table\""),
"{fragment}"
);
assert!(!fragment.contains("hx-post"), "{fragment}");
assert!(!fragment.contains("hx-delete"), "{fragment}");
let elsewhere = html_body(admin_page(&app, "/ui/", Some(&session), false).await).await;
assert!(elsewhere.contains(r#"href="/ui/expiring""#), "{elsewhere}");
for method in [Method::POST, Method::DELETE, Method::PATCH] {
let response = admin_form_request(
&app,
method.clone(),
"/ui/expiring",
Some(&session),
Some(&[]),
)
.await;
assert!(
response.status() == StatusCode::METHOD_NOT_ALLOWED
|| response.status() == StatusCode::NOT_FOUND,
"{method} /ui/expiring answered {}",
response.status()
);
}
}