mod common;
use acme_proxy_admin::admin::password::PasswordContext;
use acme_proxy_core::audit::Actor;
use acme_proxy_core::audit::AuditEvent;
use acme_proxy_core::audit::AuditRecord;
use acme_proxy_core::audit::ClientContext;
use acme_proxy_store::audit::AuditEntry;
use axum::http::{Method, StatusCode, header};
use common::*;
use serde_json::json;
#[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::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
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::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
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::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
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::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
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::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.raw_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::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
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 finishing_an_enrolment_from_the_page_records_the_row_and_the_message() {
use acme_proxy_store::audit::AuditEntry;
use acme_proxy_store::audit::AuditQuery;
let mut config = admin_config();
config.admin.require_mfa = true;
let (app, database, _session, notify) =
test_admin_app_logged_in_with_security_notify(config).await;
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 body = html_body(admin_page(&app, "/ui/login/mfa", Some(&pending), false).await).await;
let secret = base32_decode(&between(&body, r#"<pre class="secret">"#, "</pre>"));
let before = AuditEntry::search(&AuditQuery::default(), &database)
.await
.unwrap()
.1;
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);
let (rows, after) = AuditEntry::search(
&AuditQuery {
limit: 1,
..AuditQuery::default()
},
&database,
)
.await
.unwrap();
assert_eq!(after, before + 1, "the enrolment must leave a row");
assert_eq!(rows[0].event, "operator_totp_enrolled");
assert_eq!(rows[0].actor_id.as_deref(), Some("alice"));
assert_eq!(rows[0].client_ip.as_deref(), Some("127.0.0.1"));
let events = notify.recorded(1).await;
match &events[0] {
acme_proxy_jobs::notify::NotifyEvent::AdminCredentialChanged(data) => {
assert_eq!(data.username, "alice");
assert!(data.by_self);
assert!(matches!(
data.change,
acme_proxy_jobs::notify::AdminCredentialChange::SecondFactorEnabled
));
}
other => panic!("expected AdminCredentialChanged, got {other:?}"),
}
}
#[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::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
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_store::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::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 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::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;
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);
assert!(
limited.headers().contains_key(header::RETRY_AFTER),
"a rate-limited refusal keeps its Retry-After"
);
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 the_password_card_changes_the_password_keeps_the_session_and_revokes_every_other() {
let (app, database) = test_admin_app(admin_config()).await;
acme_proxy_admin::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;
const NEW_PASSWORD: &str = "a-different-long-password";
let response = admin_form_request(
&app,
Method::POST,
"/ui/account/password",
Some(&session),
Some(&[
("current_password", ADMIN_PASSWORD),
("new_password", NEW_PASSWORD),
]),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = html_body(response).await;
assert!(body.contains("Your password was changed"));
assert!(
body.contains(r#"id="account-password""#),
"the success message is this card's own banner, not a redirect"
);
assert_eq!(
admin_page(&app, "/ui/account", Some(&session), false)
.await
.status(),
StatusCode::OK,
"the session that made the change must not sign itself out"
);
assert_eq!(
admin_page(&app, "/ui/account", Some(&other), false)
.await
.status(),
StatusCode::SEE_OTHER,
"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);
}
#[tokio::test]
async fn the_password_card_reports_a_wrong_current_password_as_a_banner() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let refused = admin_form_request(
&app,
Method::POST,
"/ui/account/password",
Some(&session),
Some(&[
("current_password", "not the password"),
("new_password", "a-different-long-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-password""#));
let login = admin_request(
&app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": "alice", "password": ADMIN_PASSWORD })),
)
.await;
assert_eq!(login.status(), StatusCode::OK);
}
#[tokio::test]
async fn the_password_card_reports_a_weak_new_password_as_a_banner() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let refused = admin_form_request(
&app,
Method::POST,
"/ui/account/password",
Some(&session),
Some(&[
("current_password", ADMIN_PASSWORD),
("new_password", "short"),
]),
)
.await;
assert_eq!(refused.status(), StatusCode::BAD_REQUEST);
let body = html_body(refused).await;
assert!(body.contains("at least 12"));
assert!(body.contains(r#"id="account-password""#));
assert!(body.contains("(bad_request)"));
}
#[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/accounts/some-id/orders",
"/ui/orders",
"/ui/orders/some-id",
"/ui/orders/some-id/chain.pem",
"/ui/expiring",
"/ui/jobs",
"/ui/upstream-orders",
"/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_admin::webadmin::session::hash_token(&session.cookie);
sqlx::query("UPDATE admin_sessions SET expires_at = 1 WHERE token_hash = ?")
.bind(&hash)
.execute(database.raw_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");
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RequiredTier {
SelfService,
Operator,
Admin,
}
fn mutating_page_endpoints() -> Vec<(Method, &'static str, RequiredTier)> {
use RequiredTier::{Admin, Operator, SelfService};
vec![
(Method::POST, "/ui/accounts/some-id/contact", Operator),
(Method::POST, "/ui/accounts/some-id/deactivate", Operator),
(Method::DELETE, "/ui/accounts/some-id", Operator),
(Method::POST, "/ui/orders/some-id/revoke", Operator),
(Method::DELETE, "/ui/orders/some-id", Operator),
(Method::POST, "/ui/jobs/some-id/cancel", Operator),
(Method::POST, "/ui/jobs/some-id/run", Operator),
(Method::POST, "/ui/eab", Operator),
(Method::POST, "/ui/eab/some-kid/revoke", Operator),
(Method::DELETE, "/ui/eab/some-kid", Operator),
(Method::POST, "/ui/nonces/cleanup", Operator),
(Method::POST, "/ui/logout", SelfService),
(Method::POST, "/ui/account/mfa/totp", SelfService),
(Method::POST, "/ui/account/mfa/totp/confirm", SelfService),
(Method::POST, "/ui/account/mfa/totp/disable", SelfService),
(Method::POST, "/ui/account/mfa/recovery-codes", SelfService),
(Method::POST, "/ui/account/password", SelfService),
(Method::POST, "/ui/account/contact", SelfService),
(
Method::POST,
"/ui/account/sessions/some-id/revoke",
SelfService,
),
(Method::POST, "/ui/operators/some-username/disable", Admin),
(Method::POST, "/ui/operators/some-username/enable", Admin),
(Method::POST, "/ui/operators/some-username/contact", Admin),
(Method::POST, "/ui/operators/some-username/role", Admin),
(
Method::POST,
"/ui/operators/some-username/totp/reset",
Admin,
),
(
Method::POST,
"/ui/operators/some-username/sessions/some-id/revoke",
Admin,
),
]
}
#[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, _tier) 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::admin::users::create_user(
"bob",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database,
)
.await
.unwrap();
let other = admin_login(&app, "bob", ADMIN_PASSWORD).await;
for (method, path, _tier) 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 role_gates_every_mutating_page_endpoint() {
use acme_proxy_store::admin_user::AdminRole;
let (app, database, _session) = test_admin_app_logged_in(admin_config()).await;
for (name, role) in [
("adam", None),
("olga", Some(AdminRole::Operator)),
("vera", Some(AdminRole::Viewer)),
] {
acme_proxy_admin::admin::users::create_user(
name,
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
if let Some(role) = role {
acme_proxy_admin::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_page_endpoints() {
if path == "/ui/logout" {
continue;
}
for (who, session, held) in [
("admin", &adam, AdminRole::Admin),
("operator", &olga, AdminRole::Operator),
("viewer", &vera, AdminRole::Viewer),
] {
let response =
admin_form_request(&app, method.clone(), path, Some(session), Some(&[])).await;
let status = response.status();
let refused_for_role = status == StatusCode::FORBIDDEN
&& html_body(response).await.contains("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 a_viewer_reads_every_page_and_is_offered_no_control_it_cannot_use() {
use acme_proxy_store::admin_user::AdminRole;
let (app, database, admin) = test_admin_app_logged_in(admin_config()).await;
acme_proxy_admin::admin::users::create_user(
"vera",
ADMIN_PASSWORD,
&PasswordContext::empty(),
Some(AdminRole::Viewer),
database.clone(),
)
.await
.unwrap();
let vera = admin_login(&app, "vera", ADMIN_PASSWORD).await;
let ids = seed(&database, 1).await;
let account_page = format!("/ui/accounts/{}", ids[0]);
let pages: [(&str, &str); 3] = [
("/ui/eab", "hx-post=\"/ui/eab\""),
("/ui/nonces", "/ui/nonces/cleanup"),
(&account_page, "/deactivate"),
];
for (path, control) in pages {
let seen = html_body(admin_page(&app, path, Some(&admin), false).await).await;
let hidden = html_body(admin_page(&app, path, Some(&vera), false).await).await;
assert!(
seen.contains(control),
"{path} must offer {control} to an operator"
);
assert!(
!hidden.contains(control),
"{path} must not offer {control} to a viewer"
);
}
let order_id = {
let (orders, _) = acme_proxy_store::order::Order::search(
&acme_proxy_store::order::OrderQuery {
limit: 1,
..Default::default()
},
&database,
)
.await
.unwrap();
orders[0].id.to_string()
};
let order_page = format!("/ui/orders/{order_id}");
let fragments: [(&str, &str); 2] = [(&account_page, "/deactivate"), (&order_page, "hx-delete")];
for (path, control) in fragments {
let seen = html_body(admin_page(&app, path, Some(&admin), true).await).await;
let hidden = html_body(admin_page(&app, path, Some(&vera), true).await).await;
assert!(
seen.contains(control),
"the {path} fragment must offer {control} to an operator"
);
assert!(
!hidden.contains(control),
"the {path} fragment must not offer {control} to a viewer: {hidden}"
);
}
for path in ["/ui/", "/ui/accounts", "/ui/orders", "/ui/eab", "/ui/audit"] {
assert_eq!(
admin_page(&app, path, Some(&vera), false).await.status(),
StatusCode::OK,
"a viewer must still read {path}"
);
}
}
#[tokio::test]
async fn the_operators_pages_are_admin_only_and_the_nav_entry_follows() {
use acme_proxy_store::admin_user::AdminRole;
let (app, database, _seed) = test_admin_app_logged_in(admin_config()).await;
for (name, role) in [
("adam", AdminRole::Admin),
("olga", AdminRole::Operator),
("vera", AdminRole::Viewer),
] {
acme_proxy_admin::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;
for path in ["/ui/operators", "/ui/operators/olga"] {
let allowed = admin_page(&app, path, Some(&adam), false).await;
assert_eq!(allowed.status(), StatusCode::OK, "an admin may read {path}");
for (who, session) in [("operator", &olga), ("viewer", &vera)] {
let refused = admin_page(&app, path, Some(session), false).await;
assert_eq!(
refused.status(),
StatusCode::FORBIDDEN,
"a {who} must not read {path}"
);
}
}
let admin_home = html_body(admin_page(&app, "/ui/", Some(&adam), false).await).await;
assert!(
admin_home.contains("/ui/operators"),
"an admin is shown the way in"
);
for (who, session) in [("operator", &olga), ("viewer", &vera)] {
let home = html_body(admin_page(&app, "/ui/", Some(session), false).await).await;
assert!(
!home.contains("/ui/operators"),
"a {who} must not be offered a page they cannot open"
);
}
}
#[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_store::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_store::db::Database>, count: u8) -> Vec<String> {
use acme_proxy_core::identifier::Identifier;
use acme_proxy_store::account::Account;
use acme_proxy_store::order::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.to_string());
}
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 the_overview_leads_with_what_needs_attention() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
AuditEntry::insert(
AuditRecord::new(
AuditEvent::CertificateRevokeFailed,
PROFILE,
Actor::system(),
)
.with_reason("unauthorized"),
&database,
)
.await
.unwrap();
let body = html_body(admin_page(&app, "/ui/", Some(&session), false).await).await;
assert!(body.contains("Needs attention"), "{body}");
assert!(
body.contains(r#"<a class="stat danger" href="/ui/audit?outcome=failure">"#),
"{body}"
);
assert!(
body.contains(r#"<a class="stat" href="/ui/jobs?status=failed">"#),
"{body}"
);
assert!(body.contains(r#"href="/ui/expiring?days=7""#), "{body}");
}
#[tokio::test]
async fn every_list_page_offers_a_way_back_to_the_unfiltered_list() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
for path in [
"/ui/accounts",
"/ui/orders",
"/ui/expiring",
"/ui/jobs",
"/ui/upstream-orders",
"/ui/audit",
] {
let body = html_body(admin_page(&app, path, Some(&session), false).await).await;
assert!(
body.contains(&format!(
r#"<a class="small" href="{path}">Clear filters</a>"#
)),
"{path}: {body}"
);
assert!(body.contains(r#"class="htmx-indicator"#), "{path}");
}
}
#[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("its 1 order(s)"), "{body}");
assert!(
body.contains(&format!(r#"href="/ui/audit?accountId={}""#, ids[0])),
"{body}"
);
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 paging_an_accounts_orders_swaps_the_orders_table_and_not_the_card() {
use acme_proxy_core::identifier::Identifier;
use acme_proxy_store::account::Account;
use acme_proxy_store::order::Order;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let (account, _) = Account::find_or_create(
PROFILE,
&[9u8, 9],
vec![],
&ClientContext::default(),
&database,
)
.await
.unwrap();
for index in 0..3 {
Order::create(
PROFILE,
account.id,
vec![Identifier::dns(format!("page{index}.example.com"))],
2_000_000_000,
None,
None,
&database,
)
.await
.unwrap();
}
let id = account.id.to_string();
let page = html_body(
admin_page(
&app,
&format!("/ui/accounts/{id}?limit=2"),
Some(&session),
false,
)
.await,
)
.await;
assert!(
page.contains(&format!("{id}/orders?limit=2&offset=2")),
"{page}"
);
assert!(!page.contains("hx-push-url"), "{page}");
let response = admin_page(
&app,
&format!("/ui/accounts/{id}/orders?limit=2&offset=2"),
Some(&session),
true,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let fragment = html_body(response).await;
assert!(fragment.contains(r#"id="orders-table""#), "{fragment}");
assert!(!fragment.contains("account-card"), "{fragment}");
assert!(!fragment.contains("<html"), "{fragment}");
assert!(fragment.contains("3–3 of 3"), "{fragment}");
let document = html_body(
admin_page(
&app,
&format!("/ui/accounts/{id}/orders?limit=2&offset=2"),
Some(&session),
false,
)
.await,
)
.await;
assert!(document.contains("<html"), "{document}");
assert!(document.contains("account-card"), "{document}");
assert!(document.contains(r#"id="orders-table""#), "{document}");
let missing = admin_page(
&app,
"/ui/accounts/00000000-0000-0000-0000-000000000000/orders",
Some(&session),
true,
)
.await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn an_account_page_shows_an_address_that_never_resolved() {
use acme_proxy_store::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 the_eab_list_pages_and_a_new_credential_lands_on_the_refreshed_page() {
let mut config = admin_config();
config.admin.page_size_max = 200;
let (app, database, session) = test_admin_app_logged_in(config).await;
let mut minted = Vec::new();
for _ in 0..3 {
minted.push(
acme_proxy_store::eab::Eab::create(None, None, &database)
.await
.unwrap()
.kid
.to_string(),
);
}
let first = html_body(admin_page(&app, "/ui/eab?limit=1", Some(&session), false).await).await;
assert!(first.contains(minted[2].as_str()), "{first}");
assert!(!first.contains(minted[0].as_str()), "{first}");
assert!(first.contains("1–1 of 3"), "{first}");
assert!(first.contains("?limit=1&offset=1"), "{first}");
assert!(first.contains(r##"hx-target="#eab-table""##), "{first}");
let last =
html_body(admin_page(&app, "/ui/eab?limit=1&offset=2", Some(&session), false).await).await;
assert!(last.contains(minted[0].as_str()), "{last}");
assert!(last.contains("3–3 of 3"), "{last}");
let fragment = html_body(admin_page(&app, "/ui/eab", Some(&session), true).await).await;
assert!(
fragment.trim_start().starts_with("<div id=\"eab-table\""),
"{fragment}"
);
let created = admin_form_request(
&app,
Method::POST,
"/ui/eab",
Some(&session),
Some(&[("label", "fresh"), ("profile", "")]),
)
.await;
assert_eq!(created.status(), StatusCode::CREATED);
let body = html_body(created).await;
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();
assert!(
body.contains(&format!(r#"href="/ui/eab/{kid}""#)),
"the refreshed table must hold the row the secret above it belongs to"
);
assert!(body.contains("1–4 of 4"), "{body}");
}
#[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_core::identifier::Identifier;
use acme_proxy_signer::RequestedValidity;
use acme_proxy_store::account::Account;
use acme_proxy_store::order::Order;
let (app, database, signer) = test_admin_app_with_signer(admin_config()).await;
acme_proxy_admin::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,
&[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.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_core::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
.info()
.crl_der()
.await
.unwrap()
.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_store::db::Database>,
signer: &std::sync::Arc<dyn acme_proxy_signer::SignerBackend>,
name: &str,
) -> (String, String) {
use acme_proxy_core::identifier::Identifier;
use acme_proxy_signer::RequestedValidity;
use acme_proxy_store::account::Account;
use acme_proxy_store::order::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.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 leaf_der = first_certificate(&chain);
let (serial, spki) = acme_proxy_core::cert::cert_serial_and_spki(&leaf_der).unwrap();
let cert_not_after = acme_proxy_core::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.to_string(), 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::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
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_core::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_store::order::Order::search(
&acme_proxy_store::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_store::order::Order::search(
&acme_proxy_store::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_store::order::Order::search(
&acme_proxy_store::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_store::order::Order::search(
&acme_proxy_store::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.raw_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_store::order::Order::search(
&acme_proxy_store::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_store::order::Order::search(
&acme_proxy_store::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::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
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" aria-current="page">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_audit_filters_survive_a_page_step_and_are_shown_in_the_form() {
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_serial("0a0b"),
&database,
)
.await
.unwrap();
}
AuditEntry::insert(
AuditRecord::new(AuditEvent::CertificateIssued, PROFILE, Actor::admin("root"))
.with_serial("ffff"),
&database,
)
.await
.unwrap();
let body = html_body(
admin_page(
&app,
"/ui/audit?certSerial=0a0b&limit=1",
Some(&session),
false,
)
.await,
)
.await;
assert!(body.contains("1–1 of 3"), "{body}");
assert!(body.contains("certSerial=0a0b"), "{body}");
assert!(
body.contains(r#"id="certSerial" name="certSerial""#),
"the form has no serial control: {body}"
);
assert!(body.contains(r#"value="0a0b""#), "{body}");
let by_order =
html_body(admin_page(&app, "/ui/audit?orderId=some-order", Some(&session), false).await)
.await;
assert!(by_order.contains(r#"value="some-order""#), "{by_order}");
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
async fn seed_relay_job(
database: &std::sync::Arc<acme_proxy_store::db::Database>,
) -> (String, String) {
use acme_proxy_core::identifier::Identifier;
use acme_proxy_store::account::Account;
use acme_proxy_store::job::Job;
use acme_proxy_store::job::NewJob;
use acme_proxy_store::order::Order;
use acme_proxy_store::upstream_order::UpstreamOrder;
let (account, _) = Account::find_or_create(
PROFILE,
&[7u8, 7],
Vec::new(),
&acme_proxy_core::audit::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_store::id::mint();
Job::enqueue(
NewJob {
id: job_id,
kind: "signer_relay_issue",
dedup_key: &order.id.to_string(),
payload: &serde_json::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_page_lists_shows_and_offers_cancel_and_run() {
use acme_proxy_store::job::Job;
use acme_proxy_store::job::NewJob;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let failed_id = acme_proxy_store::id::mint();
Job::enqueue(
NewJob {
id: failed_id,
kind: "nonce_sweep",
dedup_key: "x",
payload: &serde_json::json!({}),
run_at: now_secs(),
deadline: None,
max_attempts: 5,
},
&database,
)
.await
.unwrap();
sqlx::query("UPDATE jobs SET status = 'failed', attempts = 1, last_error = ? WHERE id = ?;")
.bind("<script>alert(1)</script>")
.bind(failed_id)
.execute(database.raw_pool())
.await
.unwrap();
let (relay_job_id, _order_id) = seed_relay_job(&database).await;
let body = html_body(admin_page(&app, "/ui/jobs", Some(&session), false).await).await;
assert!(body.contains("nonce_sweep"), "{body}");
assert!(body.contains("signer_relay_issue"), "{body}");
assert!(body.contains(r#"hx-get="/ui/jobs""#), "{body}");
assert!(!body.contains("<script>alert(1)</script>"), "{body}");
assert!(body.contains("<script>"), "{body}");
let fragment = html_body(admin_page(&app, "/ui/jobs", Some(&session), true).await).await;
assert!(
fragment.trim_start().starts_with("<div id=\"jobs-table\""),
"{fragment}"
);
assert!(!fragment.contains("hx-post"), "{fragment}");
assert!(!fragment.contains("hx-delete"), "{fragment}");
let detail = html_body(
admin_page(
&app,
&format!("/ui/jobs/{failed_id}"),
Some(&session),
false,
)
.await,
)
.await;
assert!(!detail.contains("<script>alert(1)</script>"), "{detail}");
assert!(detail.contains("<script>"), "{detail}");
assert!(
detail.contains(r#"hx-post="/ui/jobs/"#),
"the danger zone renders: {detail}"
);
let relay_detail = html_body(
admin_page(
&app,
&format!("/ui/jobs/{relay_job_id}"),
Some(&session),
false,
)
.await,
)
.await;
assert!(relay_detail.contains("Upstream order"), "{relay_detail}");
assert!(
relay_detail.contains("/ui/upstream-orders/"),
"{relay_detail}"
);
let running_id = acme_proxy_store::id::mint();
Job::enqueue(
NewJob {
id: running_id,
kind: "nonce_sweep",
dedup_key: "r",
payload: &serde_json::json!({}),
run_at: now_secs(),
deadline: None,
max_attempts: 5,
},
&database,
)
.await
.unwrap();
sqlx::query("UPDATE jobs SET status = 'running' WHERE id = ?;")
.bind(running_id)
.execute(database.raw_pool())
.await
.unwrap();
let refused = admin_request(
&app,
Method::POST,
&format!("/ui/jobs/{running_id}/cancel"),
Some(&session),
None,
)
.await;
assert_eq!(refused.status(), StatusCode::OK);
let refused_body = html_body(refused).await;
assert!(refused_body.contains(r#"id="job-card""#), "{refused_body}");
assert!(
refused_body.contains("job_not_cancellable"),
"{refused_body}"
);
let run = admin_request(
&app,
Method::POST,
&format!("/ui/jobs/{failed_id}/run"),
Some(&session),
None,
)
.await;
assert_eq!(run.status(), StatusCode::OK);
let run_body = html_body(run).await;
assert!(run_body.contains("one more attempt"), "{run_body}");
assert!(run_body.contains(r#"id="job-card""#), "{run_body}");
let done_id = acme_proxy_store::id::mint();
Job::enqueue(
NewJob {
id: done_id,
kind: "nonce_sweep",
dedup_key: "d",
payload: &serde_json::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.raw_pool())
.await
.unwrap();
let refused_run = admin_request(
&app,
Method::POST,
&format!("/ui/jobs/{done_id}/run"),
Some(&session),
None,
)
.await;
assert_eq!(refused_run.status(), StatusCode::OK);
assert!(html_body(refused_run).await.contains("job_not_runnable"));
let missing = admin_request(
&app,
Method::POST,
"/ui/jobs/00000000-0000-7000-8000-000000000000/cancel",
Some(&session),
None,
)
.await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
let abandoned = admin_request(
&app,
Method::POST,
&format!("/ui/jobs/{relay_job_id}/cancel"),
Some(&session),
None,
)
.await;
assert_eq!(abandoned.status(), StatusCode::OK);
assert!(html_body(abandoned).await.contains("marked invalid"));
acme_proxy_admin::admin::users::create_user(
"vic",
ADMIN_PASSWORD,
&acme_proxy_admin::admin::password::PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
acme_proxy_admin::admin::users::set_role(
"vic",
acme_proxy_store::admin_user::AdminRole::Viewer,
database.clone(),
)
.await
.unwrap()
.unwrap();
let viewer = admin_login(&app, "vic", ADMIN_PASSWORD).await;
let denied = admin_request(
&app,
Method::POST,
&format!("/ui/jobs/{failed_id}/run"),
Some(&viewer),
None,
)
.await;
assert_eq!(denied.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn the_upstream_orders_page_is_read_only_and_escapes_untrusted_text() {
use acme_proxy_store::upstream_order::UpstreamOrder;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let (_job_id, order_id) = seed_relay_job(&database).await;
UpstreamOrder::mark_invalid(&order_id, "<script>alert('e')</script>", &database)
.await
.unwrap();
UpstreamOrder::set_client(
&order_id,
&acme_proxy_core::audit::ClientContext {
user_agent: Some("<script>alert('ua')</script>".to_string()),
..acme_proxy_core::audit::ClientContext::default()
},
&database,
)
.await
.unwrap();
let list =
html_body(admin_page(&app, "/ui/upstream-orders", Some(&session), false).await).await;
assert!(list.contains(&order_id), "{list}");
assert!(list.contains(r#"hx-get="/ui/upstream-orders""#), "{list}");
let fragment =
html_body(admin_page(&app, "/ui/upstream-orders", Some(&session), true).await).await;
assert!(
fragment
.trim_start()
.starts_with("<div id=\"upstream-orders-table\""),
"{fragment}"
);
assert!(!fragment.contains("hx-post"), "{fragment}");
assert!(!fragment.contains("hx-delete"), "{fragment}");
let detail = html_body(
admin_page(
&app,
&format!("/ui/upstream-orders/{order_id}"),
Some(&session),
false,
)
.await,
)
.await;
assert!(!detail.contains("<script>alert('e')</script>"), "{detail}");
assert!(!detail.contains("<script>alert('ua')</script>"), "{detail}");
assert!(detail.contains("<script>"), "{detail}");
assert!(
detail.contains("Relay job"),
"cross-linked to the job: {detail}"
);
assert!(!detail.contains("csrDer"), "{detail}");
assert_eq!(
admin_page(&app, "/ui/upstream-orders/nope", Some(&session), false)
.await
.status(),
StatusCode::NOT_FOUND
);
for method in [Method::POST, Method::DELETE] {
let response = admin_request(
&app,
method.clone(),
"/ui/upstream-orders",
Some(&session),
None,
)
.await;
assert!(
response.status() == StatusCode::METHOD_NOT_ALLOWED
|| response.status() == StatusCode::NOT_FOUND,
"{method} 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.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_store::db::Database>,
account: uuid::Uuid,
names: &[&str],
not_after: i64,
) -> String {
use acme_proxy_core::identifier::Identifier;
use acme_proxy_store::order::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_page_annotates_rows_escapes_them_and_offers_nothing_to_write() {
use acme_proxy_store::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}");
let blank = html_body(
admin_page(
&app,
"/ui/expiring?profile=&days=30&superseded=",
Some(&session),
true,
)
.await,
)
.await;
assert!(blank.contains("soon.example.com"), "{blank}");
assert!(blank.contains("mid.example.com"), "{blank}");
assert!(!blank.contains("Nothing is expiring"), "{blank}");
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()
);
}
}
#[tokio::test]
async fn a_blank_filter_leaves_every_list_page_unfiltered() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
seed(&database, 3).await;
for (path, blank) in [
("/ui/accounts", "/ui/accounts?profile="),
(
"/ui/orders",
"/ui/orders?profile=&status=&accountId=&identifier=&identifierContains=&certSerial=",
),
(
"/ui/audit",
"/ui/audit?profile=&event=&outcome=&accountId=&certSerial=",
),
("/ui/jobs", "/ui/jobs?kind=&status="),
(
"/ui/upstream-orders",
"/ui/upstream-orders?profile=&status=",
),
] {
let response = admin_page(&app, blank, Some(&session), true).await;
assert_eq!(response.status(), StatusCode::OK, "{blank}");
let filtered = html_body(response).await;
let unfiltered = html_body(admin_page(&app, path, Some(&session), true).await).await;
assert_eq!(
filtered.matches("<tr").count(),
unfiltered.matches("<tr").count(),
"{blank} filtered on the empty string:\n{filtered}"
);
}
let orders = html_body(
admin_page(
&app,
"/ui/orders?profile=&status=&accountId=",
Some(&session),
true,
)
.await,
)
.await;
for index in 0..3 {
assert!(
orders.contains(&format!("host{index}.example.com")),
"{orders}"
);
}
let named = html_body(
admin_page(
&app,
"/ui/orders?profile=nothing-here",
Some(&session),
true,
)
.await,
)
.await;
assert!(!named.contains("host0.example.com"), "{named}");
let by_name = html_body(
admin_page(
&app,
"/ui/orders?identifier=host1.example.com",
Some(&session),
false,
)
.await,
)
.await;
assert!(by_name.contains("host1.example.com"), "{by_name}");
assert!(!by_name.contains("host0.example.com"), "{by_name}");
assert!(
by_name.contains(r#"value="host1.example.com""#),
"the identifier input keeps its value:\n{by_name}"
);
assert_eq!(
admin_page(
&app,
"/ui/orders?identifier=a.example.com&identifierContains=example",
Some(&session),
true,
)
.await
.status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
admin_page(&app, "/ui/orders?status=typo", Some(&session), true)
.await
.status(),
StatusCode::BAD_REQUEST
);
for path in [
"/ui/orders?status=typo",
"/ui/jobs?status=typo",
"/ui/upstream-orders?status=typo",
] {
let response = admin_page(&app, path, Some(&session), true).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{path}");
assert!(
html_body(response).await.contains("typo"),
"{path} must name the refused value"
);
}
assert_eq!(
admin_page(&app, "/ui/jobs?kind=typo", Some(&session), true)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn the_account_page_lists_sessions_and_can_revoke_a_non_current_one() {
let (app, _database, session) = test_admin_app_logged_in(admin_config()).await;
let _other = admin_login(&app, "alice", ADMIN_PASSWORD).await;
let body = html_body(admin_page(&app, "/ui/account", Some(&session), false).await).await;
assert!(body.contains("Sessions"));
assert!(body.contains("this session"));
assert_eq!(body.matches(r#"class="danger""#).count(), 2, "{body}");
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)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let response = admin_form_request(
&app,
Method::POST,
&format!("/ui/account/sessions/{other_id}/revoke"),
Some(&session),
Some(&[]),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = html_body(response).await;
assert!(body.contains(r#"id="account-sessions""#), "{body}");
assert!(body.contains("Session revoked"));
assert_eq!(body.matches(r#"class="danger""#).count(), 1, "{body}");
assert_eq!(
admin_page(&app, "/ui/account", Some(&session), false)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn revoking_the_current_session_from_the_account_page_signs_out() {
for hx in [false, true] {
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();
let mut builder = axum::http::Request::builder()
.method(Method::POST)
.uri(format!("/ui/account/sessions/{own_id}/revoke"))
.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}");
}
}
async fn app_with_bob() -> (axum::Router, AdminSessionHandle, AdminSessionHandle) {
let (app, database, alice) = test_admin_app_logged_in(admin_config()).await;
acme_proxy_admin::admin::users::create_user(
"bob",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let bob = admin_login(&app, "bob", ADMIN_PASSWORD).await;
(app, alice, bob)
}
#[tokio::test]
async fn the_operators_page_lists_every_operator_and_badges_the_callers_own_row() {
let (app, alice, _bob) = app_with_bob().await;
let body = html_body(admin_page(&app, "/ui/operators", Some(&alice), false).await).await;
assert!(body.contains("alice"));
assert!(body.contains("bob"));
assert!(body.contains(">you<"), "{body}");
assert!(body.contains(r#"href="/ui/account""#));
assert!(body.contains(r#"href="/ui/operators/bob""#));
}
#[tokio::test]
async fn the_operators_pages_show_role_contact_and_recent_addresses() {
let (app, database, alice) = test_admin_app_logged_in(admin_config()).await;
acme_proxy_admin::admin::users::create_user(
"bob",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
acme_proxy_admin::admin::users::set_role(
"bob",
acme_proxy_store::admin_user::AdminRole::Viewer,
database.clone(),
)
.await
.unwrap();
acme_proxy_admin::admin::users::set_contact_email(
"bob",
Some("\"<b>Bob</b>\" <bob@example.com>"),
database.clone(),
)
.await
.unwrap();
admin_login(&app, "bob", ADMIN_PASSWORD).await;
let list = html_body(admin_page(&app, "/ui/operators", Some(&alice), false).await).await;
assert!(list.contains(">viewer<"), "{list}");
assert!(list.contains(">admin<"), "{list}");
let card = html_body(admin_page(&app, "/ui/operators/bob", Some(&alice), false).await).await;
assert!(card.contains(">viewer<"), "{card}");
assert!(card.contains("bob@example.com"), "{card}");
assert!(
!card.contains("<b>Bob</b>"),
"the contact must be escaped: {card}"
);
assert!(card.contains("Recent sign-in addresses"), "{card}");
assert!(card.contains("127.0.0.1"), "{card}");
acme_proxy_admin::admin::users::set_contact_email("bob", None, database.clone())
.await
.unwrap();
let card = html_body(admin_page(&app, "/ui/operators/bob", Some(&alice), false).await).await;
assert!(
card.contains("security notifications cannot reach them"),
"{card}"
);
}
#[tokio::test]
async fn the_operator_card_changes_a_colleagues_role_and_address() {
let (app, alice, bob) = app_with_bob().await;
let card = html_body(admin_page(&app, "/ui/operators/bob", Some(&alice), false).await).await;
assert!(
card.contains(r#"hx-post="/ui/operators/bob/role""#),
"{card}"
);
assert!(
card.contains(r#"hx-post="/ui/operators/bob/contact""#),
"{card}"
);
assert!(card.contains(r#"value="admin" selected"#), "{card}");
let refused = admin_form_request(
&app,
Method::POST,
"/ui/operators/bob/role",
Some(&alice),
Some(&[("role", "viewer")]),
)
.await;
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
let body = html_body(refused).await;
assert!(body.contains("That password is not correct."), "{body}");
assert!(body.contains(r#"id="operator-detail""#), "{body}");
let unknown = admin_form_request(
&app,
Method::POST,
"/ui/operators/bob/role",
Some(&alice),
Some(&[("role", "root"), ("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(unknown.status(), StatusCode::BAD_REQUEST);
let body = html_body(unknown).await;
assert!(body.contains("unknown role"), "{body}");
assert!(body.contains(r#"id="operator-detail""#), "{body}");
let changed = admin_form_request(
&app,
Method::POST,
"/ui/operators/bob/role",
Some(&alice),
Some(&[("role", "viewer"), ("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(changed.status(), StatusCode::OK);
let body = html_body(changed).await;
assert!(body.contains("Role changed to viewer."), "{body}");
assert!(body.contains(r#"value="viewer" selected"#), "{body}");
let stale = admin_page(&app, "/ui/", Some(&bob), false).await;
assert_eq!(stale.status(), StatusCode::SEE_OTHER);
let contact = admin_form_request(
&app,
Method::POST,
"/ui/operators/bob/contact",
Some(&alice),
Some(&[("contact", "bob@example.org"), ("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(contact.status(), StatusCode::OK);
let body = html_body(contact).await;
assert!(body.contains("bob@example.org"), "{body}");
assert!(body.contains("The previous one was told."), "{body}");
let invalid = admin_form_request(
&app,
Method::POST,
"/ui/operators/bob/contact",
Some(&alice),
Some(&[("contact", "not an address"), ("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
let body = html_body(invalid).await;
assert!(body.contains("invalid_contact"), "{body}");
assert!(body.contains(r#"id="operator-detail""#), "{body}");
}
#[tokio::test]
async fn the_account_page_changes_the_notification_address() {
use acme_proxy_store::admin_user::AdminUser;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let page = html_body(admin_page(&app, "/ui/account", Some(&session), false).await).await;
assert!(page.contains(r#"id="account-contact""#), "{page}");
let wrong = admin_form_request(
&app,
Method::POST,
"/ui/account/contact",
Some(&session),
Some(&[("contact", "x@example.com"), ("current_password", "nope")]),
)
.await;
assert_eq!(wrong.status(), StatusCode::UNAUTHORIZED);
let body = html_body(wrong).await;
assert!(body.contains("That password is not correct."), "{body}");
assert!(body.contains(r#"id="account-contact""#), "{body}");
let alice = AdminUser::find_by_username("alice", &database)
.await
.unwrap()
.unwrap();
assert_eq!(alice.contact_email, None);
let saved = admin_form_request(
&app,
Method::POST,
"/ui/account/contact",
Some(&session),
Some(&[
("contact", "alice@example.org"),
("current_password", ADMIN_PASSWORD),
]),
)
.await;
assert_eq!(saved.status(), StatusCode::OK);
let body = html_body(saved).await;
assert!(
body.contains("Security notifications now go to alice@example.org."),
"{body}"
);
let invalid = admin_form_request(
&app,
Method::POST,
"/ui/account/contact",
Some(&session),
Some(&[
("contact", "not an address"),
("current_password", ADMIN_PASSWORD),
]),
)
.await;
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
let body = html_body(invalid).await;
assert!(body.contains("invalid_contact"), "{body}");
assert!(body.contains(r#"value="not an address""#), "{body}");
let alice = AdminUser::find_by_username("alice", &database)
.await
.unwrap()
.unwrap();
assert_eq!(alice.contact_email.as_deref(), Some("alice@example.org"));
}
#[tokio::test]
async fn the_operator_detail_page_redirects_the_caller_to_their_own_account_page() {
for hx in [false, true] {
let (app, alice, _bob) = app_with_bob().await;
let response = admin_page(&app, "/ui/operators/alice", Some(&alice), hx).await;
if hx {
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert_eq!(response.headers()["hx-redirect"], "/ui/account");
} else {
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers()[header::LOCATION], "/ui/account");
}
}
}
#[tokio::test]
async fn the_operator_detail_page_manages_another_operator_end_to_end() {
let (app, database, alice_seed) = test_admin_app_logged_in(admin_config()).await;
acme_proxy_admin::admin::users::create_user(
"bob",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.unwrap();
let _bob_seed = admin_login(&app, "bob", ADMIN_PASSWORD).await;
let bob_second = admin_login(&app, "bob", ADMIN_PASSWORD).await;
let secret = enrol_totp(database.clone(), "alice").await;
let alice = admin_login_mfa(&app, database.clone(), "alice", ADMIN_PASSWORD, &secret).await;
let _ = alice_seed;
let body = html_body(admin_page(&app, "/ui/operators/bob", Some(&alice), false).await).await;
assert!(body.contains("bob"));
assert!(body.contains(r#"id="operator-detail""#));
assert!(body.contains(r#"id="operator-step-up-password""#));
let refused = admin_form_request(
&app,
Method::POST,
"/ui/operators/bob/disable",
Some(&alice),
Some(&[]),
)
.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="operator-detail""#));
let disabled = admin_form_request(
&app,
Method::POST,
"/ui/operators/bob/disable",
Some(&alice),
Some(&[("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(disabled.status(), StatusCode::OK);
let body = html_body(disabled).await;
assert!(body.contains("Operator disabled"));
assert!(body.contains("badge disabled"));
assert_eq!(
admin_request(&app, Method::GET, "/api/session", Some(&bob_second), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
let enabled = admin_form_request(
&app,
Method::POST,
"/ui/operators/bob/enable",
Some(&alice),
Some(&[("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(enabled.status(), StatusCode::OK);
assert!(html_body(enabled).await.contains("badge active"));
let bob_a = admin_login(&app, "bob", ADMIN_PASSWORD).await;
let bob_b = 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 revoked = admin_form_request(
&app,
Method::POST,
&format!("/ui/operators/bob/sessions/{target_id}/revoke"),
Some(&alice),
Some(&[("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(revoked.status(), StatusCode::OK);
assert!(html_body(revoked).await.contains("Session revoked"));
let remaining = [
admin_request(&app, Method::GET, "/api/session", Some(&bob_a), None)
.await
.status(),
admin_request(&app, Method::GET, "/api/session", Some(&bob_b), None)
.await
.status(),
];
assert_eq!(
remaining.iter().filter(|s| **s == StatusCode::OK).count(),
1,
"exactly one of bob's two sessions must survive"
);
enrol_totp(database.clone(), "bob").await;
let reset = admin_form_request(
&app,
Method::POST,
"/ui/operators/bob/totp/reset",
Some(&alice),
Some(&[("password", ADMIN_PASSWORD)]),
)
.await;
assert_eq!(reset.status(), StatusCode::OK);
assert!(
html_body(reset)
.await
.contains("second factor and recovery codes were removed")
);
}
#[tokio::test]
async fn the_operators_page_refuses_to_target_the_caller() {
let (app, alice, _bob) = app_with_bob().await;
for (method, path) in [
(Method::POST, "/ui/operators/alice/disable"),
(Method::POST, "/ui/operators/alice/enable"),
(Method::POST, "/ui/operators/alice/totp/reset"),
] {
let response =
admin_form_request(&app, method.clone(), path, Some(&alice), Some(&[])).await;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{method} {path}"
);
}
assert_eq!(
admin_page(&app, "/ui/account", Some(&alice), false)
.await
.status(),
StatusCode::OK,
"alice's own account must be untouched"
);
}
#[tokio::test]
async fn deletes_that_would_lose_a_live_certificate_are_refused_on_the_cards() {
use acme_proxy_store::account::Account;
use acme_proxy_store::order::Order;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let (kid, accounts) = bound_eab(&database, 2, Some(1)).await;
let live = certified_order(&database, accounts[0], None).await;
let card =
html_body(admin_page(&app, &format!("/ui/eab/{kid}"), Some(&session), false).await).await;
assert!(
card.contains(&format!(r#"href="/ui/accounts?eabKid={kid}""#)),
"{card}"
);
assert!(card.contains("2 account(s)"), "{card}");
assert!(card.contains("1 live certificate(s)"), "{card}");
for mode in ["", "?accounts=deactivate", "?accounts=delete"] {
assert!(
card.contains(&format!(r#"hx-delete="/ui/eab/{kid}{mode}""#)),
"{mode}: {card}"
);
}
assert!(card.contains("cannot be deleted while they hold"), "{card}");
let refused = admin_form_request(
&app,
Method::DELETE,
&format!("/ui/eab/{kid}?accounts=delete"),
Some(&session),
None,
)
.await;
assert_eq!(refused.status(), StatusCode::CONFLICT);
let body = html_body(refused).await;
assert!(
body.trim_start().starts_with(r#"<div id="eab-card">"#),
"{body}"
);
assert!(body.contains("impossible to revoke"), "{body}");
let account_path = format!("/ui/accounts/{}", accounts[0]);
let order_path = format!("/ui/orders/{}", live.id);
let account_card =
html_body(admin_page(&app, &account_path, Some(&session), false).await).await;
assert!(
account_card.contains("Cannot be deleted while it holds 1 live"),
"{account_card}"
);
assert!(
account_card.contains(&format!(r#"href="/ui/eab/{kid}""#)),
"{account_card}"
);
let order_card = html_body(admin_page(&app, &order_path, Some(&session), false).await).await;
assert!(
order_card.contains("Cannot be deleted while its certificate is live"),
"{order_card}"
);
for (path, root) in [
(&account_path, r#"<div id="account-card">"#),
(&order_path, r#"<div id="order-card">"#),
] {
let refused = admin_form_request(&app, Method::DELETE, path, Some(&session), None).await;
assert_eq!(refused.status(), StatusCode::CONFLICT, "{path}");
let body = html_body(refused).await;
assert!(body.trim_start().starts_with(root), "{path}: {body}");
assert!(body.contains("live certificate(s)"), "{path}: {body}");
}
assert!(
Order::find_by_id(&live.id.to_string(), &database)
.await
.unwrap()
.is_some()
);
let listed = html_body(
admin_page(
&app,
&format!("/ui/accounts?eabKid={kid}"),
Some(&session),
false,
)
.await,
)
.await;
for account in &accounts {
assert!(listed.contains(&account.to_string()), "{listed}");
}
assert!(listed.contains(&format!(r#"value="{kid}""#)), "{listed}");
let deleted = admin_form_request(
&app,
Method::DELETE,
&format!("/ui/eab/{kid}?accounts=deactivate"),
Some(&session),
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::SEE_OTHER);
assert_eq!(deleted.headers()[header::LOCATION], "/ui/eab");
assert_eq!(
Account::find_any_by_id(&accounts[0].to_string(), &database)
.await
.unwrap()
.unwrap()
.status,
"deactivated"
);
assert!(
Order::find_by_id(&live.id.to_string(), &database)
.await
.unwrap()
.is_some()
);
assert_eq!(
admin_page(&app, &format!("/ui/eab/{kid}"), Some(&session), false)
.await
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn an_eab_with_no_accounts_offers_one_delete_and_refuses_an_unknown_mode() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let (kid, _) = bound_eab(&database, 0, None).await;
let card =
html_body(admin_page(&app, &format!("/ui/eab/{kid}"), Some(&session), false).await).await;
assert!(card.contains("none registered with it"), "{card}");
assert!(
card.contains(&format!(r#"hx-delete="/ui/eab/{kid}""#)),
"{card}"
);
assert!(!card.contains("?accounts="), "{card}");
let refused = admin_form_request(
&app,
Method::DELETE,
&format!("/ui/eab/{kid}?accounts=purge"),
Some(&session),
None,
)
.await;
assert_eq!(refused.status(), StatusCode::BAD_REQUEST);
assert!(html_body(refused).await.contains("unknown accounts mode"));
let deleted = admin_form_request(
&app,
Method::DELETE,
&format!("/ui/eab/{kid}"),
Some(&session),
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::SEE_OTHER);
}
async fn rows_since(
database: &std::sync::Arc<acme_proxy_store::db::Database>,
after: i64,
) -> Vec<(String, String, String, Option<String>)> {
use acme_proxy_store::audit::AuditQuery;
let (rows, _) = AuditEntry::search(
&AuditQuery {
limit: 100,
..AuditQuery::default()
},
database,
)
.await
.unwrap();
let mut rows: Vec<_> = rows
.into_iter()
.filter(|row| row.id > after)
.map(|row| (row.event, row.outcome, row.actor_kind, row.actor_id))
.collect();
rows.reverse();
rows
}
async fn last_audit_id(database: &std::sync::Arc<acme_proxy_store::db::Database>) -> i64 {
use acme_proxy_store::audit::AuditQuery;
let (rows, _) = AuditEntry::search(
&AuditQuery {
limit: 1,
..AuditQuery::default()
},
database,
)
.await
.unwrap();
rows.first().map_or(0, |row| row.id)
}
#[tokio::test]
async fn both_surfaces_record_the_same_credential_changes() {
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let mut rows = Vec::new();
for (surface, current, next) in [
("api", ADMIN_PASSWORD, "another-long-password"),
("ui", "another-long-password", "a-third-long-password"),
] {
let before = last_audit_id(&database).await;
let response = match surface {
"api" => {
admin_request(
&app,
Method::POST,
"/api/account/password",
Some(&session),
Some(json!({ "current_password": current, "new_password": next })),
)
.await
}
_ => {
admin_form_request(
&app,
Method::POST,
"/ui/account/password",
Some(&session),
Some(&[("current_password", current), ("new_password", next)]),
)
.await
}
};
assert!(
response.status().is_success(),
"password change via /{surface}: {}",
response.status()
);
rows.push(rows_since(&database, before).await);
}
assert!(!rows[0].is_empty(), "a password change is audited");
assert_eq!(rows[0], rows[1], "the two surfaces wrote different rows");
let begun = admin_request(
&app,
Method::POST,
"/api/mfa/totp",
Some(&session),
Some(json!({})),
)
.await;
assert_eq!(begun.status(), StatusCode::CREATED);
let secret = base32_decode(json_body(begun).await["secret"].as_str().unwrap());
let resumed = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp",
Some(&session),
Some(&[]),
)
.await;
assert_eq!(resumed.status(), StatusCode::OK);
let body = html_body(resumed).await;
let shown = between(&body, r#"<pre class="secret">"#, "</pre>");
assert_eq!(
base32_decode(&shown),
secret,
"the secret `/api` began must be the one `/ui` shows"
);
let before = last_audit_id(&database).await;
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 enabled = rows_since(&database, before).await;
assert!(!enabled.is_empty(), "enabling a factor is audited");
let password = "a-third-long-password";
let before = last_audit_id(&database).await;
let reissued = admin_request(
&app,
Method::POST,
"/api/mfa/recovery-codes",
Some(&session),
Some(json!({ "password": password })),
)
.await;
assert!(reissued.status().is_success(), "{}", reissued.status());
let api_codes = rows_since(&database, before).await;
let before = last_audit_id(&database).await;
let reissued = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/recovery-codes",
Some(&session),
Some(&[("password", password)]),
)
.await;
assert!(reissued.status().is_success(), "{}", reissued.status());
assert_eq!(
api_codes,
rows_since(&database, before).await,
"reissuing recovery codes wrote different rows"
);
let before = last_audit_id(&database).await;
let disabled = admin_form_request(
&app,
Method::POST,
"/ui/account/mfa/totp/disable",
Some(&session),
Some(&[("password", password)]),
)
.await;
assert!(disabled.status().is_success(), "{}", disabled.status());
assert!(
!rows_since(&database, before).await.is_empty(),
"disabling a factor is audited"
);
}
#[tokio::test]
async fn every_shared_write_leaves_the_same_audit_rows_on_both_surfaces() {
use acme_proxy_core::identifier::Identifier;
use acme_proxy_store::account::Account;
use acme_proxy_store::eab::Eab;
use acme_proxy_store::order::Order;
let (app, database, session) = test_admin_app_logged_in(admin_config()).await;
let mut next_key = 0u8;
let mut account = || {
next_key += 1;
let database = database.clone();
let key = next_key;
async move {
Account::find_or_create(
PROFILE,
&[9u8, key],
vec![],
&ClientContext::default(),
&database,
)
.await
.unwrap()
.0
}
};
let eab = || async { Eab::create(None, None, &database).await.unwrap() };
type Write = (
Method,
String,
Option<serde_json::Value>,
Vec<(String, String)>,
);
let mut cases: Vec<(&str, Write, Write)> = Vec::new();
cases.push((
"eab create",
(
Method::POST,
"/api/eab".into(),
Some(json!({ "label": "x" })),
vec![],
),
(
Method::POST,
"/ui/eab".into(),
None,
vec![("label".into(), "x".into())],
),
));
let (api_kid, ui_kid) = (eab().await.kid, eab().await.kid);
cases.push((
"eab revoke",
(
Method::POST,
format!("/api/eab/{api_kid}/revoke"),
None,
vec![],
),
(
Method::POST,
format!("/ui/eab/{ui_kid}/revoke"),
None,
vec![],
),
));
let (api_kid, ui_kid) = (eab().await.kid, eab().await.kid);
cases.push((
"eab delete",
(Method::DELETE, format!("/api/eab/{api_kid}"), None, vec![]),
(Method::DELETE, format!("/ui/eab/{ui_kid}"), None, vec![]),
));
let (api_account, ui_account) = (account().await.id, account().await.id);
cases.push((
"account contact",
(
Method::PATCH,
format!("/api/accounts/{api_account}"),
Some(json!({ "contact": ["mailto:ops@example.com"] })),
vec![],
),
(
Method::POST,
format!("/ui/accounts/{ui_account}/contact"),
None,
vec![("contact".into(), "mailto:ops@example.com".into())],
),
));
cases.push((
"account deactivate",
(
Method::POST,
format!("/api/accounts/{api_account}/deactivate"),
None,
vec![],
),
(
Method::POST,
format!("/ui/accounts/{ui_account}/deactivate"),
None,
vec![],
),
));
let (api_account, ui_account) = (account().await.id, account().await.id);
cases.push((
"account delete",
(
Method::DELETE,
format!("/api/accounts/{api_account}"),
None,
vec![],
),
(
Method::DELETE,
format!("/ui/accounts/{ui_account}"),
None,
vec![],
),
));
let order = |account_id| {
let database = database.clone();
async move {
Order::create(
PROFILE,
account_id,
vec![Identifier::dns("parity.example.com".to_string())],
2_000_000_000,
None,
None,
&database,
)
.await
.unwrap()
.id
}
};
let owner = account().await.id;
let (api_order, ui_order) = (order(owner).await, order(owner).await);
cases.push((
"order delete",
(
Method::DELETE,
format!("/api/orders/{api_order}"),
None,
vec![],
),
(
Method::DELETE,
format!("/ui/orders/{ui_order}"),
None,
vec![],
),
));
for (name, (api_method, api_path, api_body, _), (ui_method, ui_path, _, ui_form)) in cases {
let before = last_audit_id(&database).await;
let response = admin_request(&app, api_method, &api_path, Some(&session), api_body).await;
assert!(
response.status().is_success(),
"{name} via /api: {}",
response.status()
);
let api_rows = rows_since(&database, before).await;
let before = last_audit_id(&database).await;
let form: Vec<(&str, &str)> = ui_form
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let response =
admin_form_request(&app, ui_method, &ui_path, Some(&session), Some(&form)).await;
assert!(
response.status().is_success() || response.status().is_redirection(),
"{name} via /ui: {}",
response.status()
);
let ui_rows = rows_since(&database, before).await;
assert!(!api_rows.is_empty(), "{name} wrote no audit row");
assert_eq!(
api_rows, ui_rows,
"{name}: the two surfaces wrote different rows"
);
}
}