use std::sync::{Arc, Mutex};
use axum::Router;
use tokio::sync::OnceCell;
use umbral_auth::mailer::{AuthMailError, AuthMailer, OutgoingMail};
use umbral_auth::{AuthPlugin, AuthUser};
#[derive(Default, Clone)]
struct Recorder(Arc<Mutex<Vec<OutgoingMail>>>);
#[async_trait::async_trait]
impl AuthMailer for Recorder {
async fn send(&self, mail: OutgoingMail) -> Result<(), AuthMailError> {
self.0.lock().unwrap().push(mail);
Ok(())
}
}
impl Recorder {
fn last(&self) -> Option<OutgoingMail> {
self.0.lock().unwrap().last().cloned()
}
fn last_to(&self, email: &str) -> Option<OutgoingMail> {
self.0
.lock()
.unwrap()
.iter()
.rev()
.find(|m| m.to == email)
.cloned()
}
}
static BOOT: OnceCell<()> = OnceCell::const_new();
static RECORDER: std::sync::OnceLock<Recorder> = std::sync::OnceLock::new();
static ROUTER: std::sync::OnceLock<Router> = std::sync::OnceLock::new();
async fn boot_app_with_recorder() -> (Router, Recorder) {
BOOT.get_or_init(|| async {
let settings =
umbral::Settings::from_env().expect("figment defaults always load in a test env");
let tmp = tempfile::tempdir().expect("tempdir");
let db_path = tmp.path().join("umbral_json_surface.sqlite");
std::mem::forget(tmp);
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_secs(30)),
)
.await
.expect("sqlite tempfile pool");
let rec = Recorder::default();
RECORDER.set(rec.clone()).ok();
let app = umbral::App::builder()
.settings(settings)
.database("default", pool)
.plugin(umbral_sessions::SessionsPlugin::default().without_auto_layer())
.plugin(
AuthPlugin::<AuthUser>::default()
.with_default_routes()
.disable_throttle()
.mailer(rec),
)
.build()
.expect("App::build should succeed with AuthPlugin + Recorder mailer");
umbral::migrate::create_tables_for_tests()
.await
.expect("create the test schema");
let router = app.into_router();
ROUTER.set(router).ok();
})
.await;
let router = ROUTER.get().expect("router set during boot").clone();
let rec = RECORDER.get().expect("recorder set during boot").clone();
(router, rec)
}
async fn post(router: &Router, uri: &str, body: &str) -> axum::http::StatusCode {
use tower::ServiceExt;
let req = axum::http::Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(axum::body::Body::from(body.to_string()))
.unwrap();
router.clone().oneshot(req).await.unwrap().status()
}
async fn post_full(router: &Router, uri: &str, body: &str) -> (axum::http::StatusCode, String) {
use tower::ServiceExt;
let req = axum::http::Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(axum::body::Body::from(body.to_string()))
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
(status, String::from_utf8_lossy(&bytes).to_string())
}
async fn post_form(router: &Router, uri: &str, body: &str) -> axum::http::StatusCode {
use tower::ServiceExt;
let req = axum::http::Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(body.to_string()))
.unwrap();
router.clone().oneshot(req).await.unwrap().status()
}
#[tokio::test]
async fn auth_endpoints_accept_form_encoded_bodies() {
let (router, _rec) = boot_app_with_recorder().await;
assert_eq!(
post_form(
&router,
"/api/auth/register",
"username=formuser&email=form@example.test&password=G00d%24Pass%21",
)
.await,
axum::http::StatusCode::CREATED,
"register must accept a form body"
);
assert_eq!(
post_form(
&router,
"/api/auth/login",
"username=formuser&password=G00d%24Pass%21",
)
.await,
axum::http::StatusCode::OK,
"login must accept a form body"
);
assert_eq!(
post(
&router,
"/api/auth/login",
r#"{"username":"formuser","password":"G00d$Pass!"}"#,
)
.await,
axum::http::StatusCode::OK,
"JSON must still work after adding form support"
);
}
#[tokio::test]
async fn both_slash_forms_of_login_resolve() {
let (router, _rec) = boot_app_with_recorder().await;
let reg = r#"{"username":"slashuser","email":"slash@example.test","password":"G00d$Pass!"}"#;
assert_eq!(
post(&router, "/api/auth/register", reg).await,
axum::http::StatusCode::CREATED,
"register the fixture user"
);
let creds = r#"{"username":"slashuser","password":"G00d$Pass!"}"#;
let bare = post(&router, "/api/auth/login", creds).await;
let slash = post(&router, "/api/auth/login/", creds).await;
assert_ne!(
slash,
axum::http::StatusCode::NOT_FOUND,
"the trailing-slash login form must not 404"
);
assert_eq!(
bare, slash,
"both slash forms resolve to the same login handler"
);
}
#[tokio::test]
async fn register_duplicate_does_not_leak_raw_db_error() {
let (router, _rec) = boot_app_with_recorder().await;
let body = r#"{"username":"leaky","email":"leaky@example.com","password":"G00d$Pass!"}"#;
assert_eq!(
post(&router, "/api/auth/register", body).await,
axum::http::StatusCode::CREATED,
"first register of a fresh user must succeed"
);
let (status, resp_body) = post_full(&router, "/api/auth/register", body).await;
assert_eq!(
status,
axum::http::StatusCode::CONFLICT,
"a duplicate register still signals a conflict via status"
);
assert!(
resp_body.contains("could not create account"),
"detail must be the static generic message; got {resp_body}"
);
let lowered = resp_body.to_lowercase();
for leaked in ["unique", "constraint", "sqlx", "auth_user", "column"] {
assert!(
!lowered.contains(leaked),
"response body must not leak internal error token {leaked:?}; got {resp_body}"
);
}
}
#[tokio::test]
async fn json_verify_and_reset_endpoints() {
let (router, rec) = boot_app_with_recorder().await;
assert_eq!(
post(
&router,
"/api/auth/register",
r#"{"username":"dan","email":"dan@example.com","password":"G00d$Pass!"}"#
)
.await,
axum::http::StatusCode::CREATED
);
assert_eq!(
post(
&router,
"/api/auth/resend-verification",
r#"{"email":"dan@example.com"}"#
)
.await,
axum::http::StatusCode::ACCEPTED
);
let code: String = rec
.last()
.unwrap()
.text
.chars()
.filter(|c| c.is_ascii_digit())
.collect();
assert_eq!(
post(
&router,
"/api/auth/verify-email",
r#"{"email":"dan@example.com","code":"000000"}"#
)
.await,
axum::http::StatusCode::BAD_REQUEST
);
assert_eq!(
post(
&router,
"/api/auth/verify-email",
&format!(r#"{{"email":"dan@example.com","code":"{code}"}}"#)
)
.await,
axum::http::StatusCode::NO_CONTENT
);
let (unknown_status, unknown_body) = post_full(
&router,
"/api/auth/password-forgot",
r#"{"email":"ghost@example.com"}"#,
)
.await;
assert_eq!(unknown_status, axum::http::StatusCode::ACCEPTED);
let (known_status, known_body) = post_full(
&router,
"/api/auth/password-forgot",
r#"{"email":"dan@example.com"}"#,
)
.await;
assert_eq!(known_status, axum::http::StatusCode::ACCEPTED);
assert_eq!(
unknown_body, known_body,
"the 202 body must not distinguish known from unknown emails"
);
let parsed: serde_json::Value =
serde_json::from_str(&unknown_body).expect("password-forgot 202 must carry a JSON body");
assert!(
parsed["detail"].as_str().is_some_and(|d| !d.is_empty()),
"the 202 body carries a non-empty `detail` message; got: {unknown_body}"
);
}
#[tokio::test]
async fn json_password_reset_via_http() {
let (router, rec) = boot_app_with_recorder().await;
assert_eq!(
post(
&router,
"/api/auth/register",
r#"{"username":"charlie","email":"charlie@example.com","password":"G00d$Pass!"}"#
)
.await,
axum::http::StatusCode::CREATED
);
assert_eq!(
post(
&router,
"/api/auth/password-forgot",
r#"{"email":"charlie@example.com"}"#
)
.await,
axum::http::StatusCode::ACCEPTED
);
let mail = rec
.last_to("charlie@example.com")
.expect("a reset email must have been sent to charlie@example.com");
let token = mail
.text
.split("token=")
.nth(1)
.expect("reset link text body must contain 'token='")
.split_whitespace()
.next()
.expect("token must be followed by whitespace or end-of-input")
.to_string();
assert!(
token.starts_with("umbral_"),
"extracted reset token must have the 'umbral_' prefix; got {token:?}"
);
let weak_body = format!(r#"{{"token":"{token}","new_password":"123"}}"#);
assert_eq!(
post(&router, "/api/auth/password-reset", &weak_body).await,
axum::http::StatusCode::BAD_REQUEST,
"weak password must be rejected by the default policy"
);
let strong_body = format!(r#"{{"token":"{token}","new_password":"Br4nd-New$Pass"}}"#);
assert_eq!(
post(&router, "/api/auth/password-reset", &strong_body).await,
axum::http::StatusCode::NO_CONTENT,
"valid strong password must be accepted and return 204"
);
assert_eq!(
post(&router, "/api/auth/password-reset", &strong_body).await,
axum::http::StatusCode::BAD_REQUEST,
"a consumed reset token must not be accepted a second time"
);
}
#[tokio::test]
async fn json_resend_verification_returns_202_for_verified_user() {
let (router, rec) = boot_app_with_recorder().await;
assert_eq!(
post(
&router,
"/api/auth/register",
r#"{"username":"rvuser","email":"rvuser@example.com","password":"G00d$Pass!"}"#
)
.await,
axum::http::StatusCode::CREATED
);
assert_eq!(
post(
&router,
"/api/auth/resend-verification",
r#"{"email":"rvuser@example.com"}"#
)
.await,
axum::http::StatusCode::ACCEPTED
);
let code: String = rec
.last_to("rvuser@example.com")
.expect("a verification email must have been sent to rvuser@example.com")
.text
.chars()
.filter(|c| c.is_ascii_digit())
.collect();
assert_eq!(
post(
&router,
"/api/auth/verify-email",
&format!(r#"{{"email":"rvuser@example.com","code":"{code}"}}"#)
)
.await,
axum::http::StatusCode::NO_CONTENT,
"correct verification code must return 204"
);
assert_eq!(
post(
&router,
"/api/auth/resend-verification",
r#"{"email":"rvuser@example.com"}"#
)
.await,
axum::http::StatusCode::ACCEPTED,
"resend-verification must return 202 even when the user is already verified \
(anti-enumeration: never reveal verified state)"
);
}
#[tokio::test]
async fn logout_revokes_the_presented_bearer_token() {
use tower::ServiceExt;
let (router, _rec) = boot_app_with_recorder().await;
async fn with_bearer(
router: &Router,
method: &str,
uri: &str,
token: &str,
) -> axum::http::StatusCode {
let req = axum::http::Request::builder()
.method(method)
.uri(uri)
.header("authorization", format!("Bearer {token}"))
.body(axum::body::Body::empty())
.unwrap();
router.clone().oneshot(req).await.unwrap().status()
}
let reg = r#"{"username":"tokuser","email":"tok@example.test","password":"G00d$Pass!"}"#;
assert_eq!(
post(&router, "/api/auth/register", reg).await,
axum::http::StatusCode::CREATED,
"register the fixture user"
);
let (status, body) = post_full(
&router,
"/api/auth/login",
r#"{"username":"tokuser","password":"G00d$Pass!"}"#,
)
.await;
assert_eq!(status, axum::http::StatusCode::OK, "login: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("LoginOut json");
let token = parsed["token"].as_str().expect("login returns the token");
assert_eq!(
with_bearer(&router, "GET", "/api/auth/me", token).await,
axum::http::StatusCode::OK,
"the fresh token must resolve /me"
);
assert_eq!(
with_bearer(&router, "POST", "/api/auth/logout", token).await,
axum::http::StatusCode::NO_CONTENT,
"logout with the bearer token returns 204"
);
assert_eq!(
with_bearer(&router, "GET", "/api/auth/me", token).await,
axum::http::StatusCode::UNAUTHORIZED,
"the revoked token must no longer resolve /me — logout revokes the presented bearer token"
);
}
#[tokio::test]
async fn register_rejects_an_invalid_email() {
let (router, _rec) = boot_app_with_recorder().await;
let (status, body) = post_full(
&router,
"/api/auth/register",
r#"{"username":"nomail","email":"admin","password":"G00d$Pass!"}"#,
)
.await;
assert_eq!(
status,
axum::http::StatusCode::BAD_REQUEST,
"an email with no @ must be a 400; body: {body}"
);
assert!(
body.contains("not a valid email address"),
"the error detail names the problem; got: {body}"
);
use umbral::prelude::Model;
let email_field = umbral_auth::AuthUser::FIELDS
.iter()
.find(|f| f.name == "email")
.expect("AuthUser has an email field");
assert_eq!(
email_field.text_format,
Some("email"),
"AuthUser.email must carry the email text-format marker"
);
}