use axum::Json;
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use tracing::error;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{code}: {message}")]
pub struct AdminError {
pub status: StatusCode,
pub code: &'static str,
pub message: String,
retry_after: Option<u64>,
}
impl AdminError {
fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
Self {
status,
code,
message: message.into(),
retry_after: None,
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
}
pub fn session_invalid() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
"session_invalid",
"no valid session; sign in again",
)
}
pub fn session_expired() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
"session_expired",
"the session has expired; sign in again",
)
}
pub fn session_idle() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
"session_idle",
"the session timed out through inactivity; sign in again",
)
}
pub fn invalid_credentials() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
"invalid_credentials",
"invalid username or password",
)
}
pub fn csrf_failed(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, "csrf_failed", message)
}
pub fn insufficient_role() -> Self {
Self::new(
StatusCode::FORBIDDEN,
"insufficient_role",
"your role does not permit this action",
)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, "not_found", message)
}
pub fn with_code(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
Self::new(status, code, message)
}
pub fn conflict(code: &'static str, message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, code, message)
}
pub fn rate_limited(retry_after_seconds: u64) -> Self {
Self {
retry_after: Some(retry_after_seconds),
..Self::new(
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
format!("too many failed sign-in attempts; retry in {retry_after_seconds}s"),
)
}
}
pub fn signer_failed(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_GATEWAY, "signer_failed", message)
}
pub fn access_denied(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, "access_denied", message)
}
pub fn internal() -> Self {
Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
"internal error",
)
}
}
impl From<sqlx::Error> for AdminError {
fn from(error: sqlx::Error) -> Self {
error!(event = "admin_db_error", outcome = "failure", error = %error);
AdminError::internal()
}
}
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
let body = Json(serde_json::json!({
"error": self.code,
"message": self.message,
}));
let mut response = (self.status, body).into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
header::HeaderValue::from_static("no-store"),
);
if let Some(seconds) = self.retry_after
&& let Ok(value) = header::HeaderValue::from_str(&seconds.to_string())
{
response.headers_mut().insert(header::RETRY_AFTER, value);
}
response
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
async fn parts(error: AdminError) -> (StatusCode, serde_json::Value, axum::http::HeaderMap) {
let expected_status = error.status;
let response = error.into_response();
let status = response.status();
assert_eq!(status, expected_status);
let headers = response.headers().clone();
let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap(), headers)
}
#[tokio::test]
async fn every_constructor_has_its_own_status_and_code() {
let cases: Vec<(AdminError, u16, &str)> = vec![
(AdminError::bad_request("nope"), 400, "bad_request"),
(AdminError::session_invalid(), 401, "session_invalid"),
(AdminError::session_expired(), 401, "session_expired"),
(AdminError::session_idle(), 401, "session_idle"),
(
AdminError::invalid_credentials(),
401,
"invalid_credentials",
),
(AdminError::csrf_failed("missing"), 403, "csrf_failed"),
(AdminError::not_found("no such thing"), 404, "not_found"),
(
AdminError::conflict("already_revoked", "already revoked"),
409,
"already_revoked",
),
(AdminError::rate_limited(30), 429, "rate_limited"),
(AdminError::signer_failed("down"), 502, "signer_failed"),
(AdminError::internal(), 500, "internal"),
(
AdminError::with_code(StatusCode::METHOD_NOT_ALLOWED, "method_not_allowed", "no"),
405,
"method_not_allowed",
),
];
for (error, expected_status, expected_code) in cases {
let (status, body, headers) = parts(error).await;
assert_eq!(status.as_u16(), expected_status, "for {expected_code}");
assert_eq!(body["error"], expected_code);
assert!(
body["message"].as_str().is_some_and(|m| !m.is_empty()),
"{expected_code} must carry a human message"
);
assert_eq!(
headers[header::CONTENT_TYPE],
"application/json",
"{expected_code} must not be application/problem+json"
);
assert_eq!(headers[header::CACHE_CONTROL], "no-store");
assert!(
!body.to_string().contains("urn:ietf:params:acme"),
"{expected_code} must not carry an ACME error URN"
);
}
}
#[tokio::test]
async fn only_the_rate_limiter_sets_retry_after() {
let (_, body, headers) = parts(AdminError::rate_limited(42)).await;
assert_eq!(headers[header::RETRY_AFTER], "42");
assert!(body["message"].as_str().unwrap().contains("42s"));
let (_, _, headers) = parts(AdminError::internal()).await;
assert!(!headers.contains_key(header::RETRY_AFTER));
}
#[tokio::test]
async fn every_login_failure_looks_the_same_to_the_client() {
let (first_status, first_body, _) = parts(AdminError::invalid_credentials()).await;
let (second_status, second_body, _) = parts(AdminError::invalid_credentials()).await;
assert_eq!(first_status, second_status);
assert_eq!(first_body, second_body);
assert_eq!(first_body["error"], "invalid_credentials");
let message = first_body["message"].as_str().unwrap().to_lowercase();
assert!(!message.contains("no such"));
assert!(!message.contains("disabled"));
assert!(!message.contains("unknown"));
}
#[tokio::test]
async fn a_database_error_is_logged_and_answered_generically() {
let error = AdminError::from(sqlx::Error::RowNotFound);
assert_eq!(error, AdminError::internal());
let (status, body, _) = parts(error).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body["message"], "internal error");
}
#[test]
fn display_names_the_code_and_the_message() {
assert_eq!(
AdminError::not_found("no such account: acct-1").to_string(),
"not_found: no such account: acct-1"
);
}
}