use adminx_core::auth::{
self, build_ctx, configure, handle_login, is_authorized, issue_token, verify_password,
verify_token, AuthConfig,
};
use adminx_core::prelude::*;
use adminx_core::storage::{CreateOutcome, ListPage, QueryOptions, StorageError};
use async_trait::async_trait;
use serde_json::{json, Map, Value};
struct AuthMock;
#[async_trait]
impl Storage for AuthMock {
async fn list(&self, _t: &str, _o: &QueryOptions) -> Result<ListPage, StorageError> {
Ok(ListPage {
rows: vec![json!({"id": 1, "name": "Row"})],
total: 1,
})
}
async fn get(&self, _t: &str, _pk: &str, _id: &str) -> Result<Option<Value>, StorageError> {
Ok(None)
}
async fn find_one_by(
&self,
_table: &str,
column: &str,
value: &str,
) -> Result<Option<Value>, StorageError> {
if column == "email" && matches!(value, "admin@x.io" | "throttle@x.io" | "clears@x.io") {
let hash = auth::hash_password("secret").unwrap();
Ok(Some(json!({
"id": 1,
"email": value,
"encrypted_password": hash,
"role": "admin",
})))
} else {
Ok(None)
}
}
async fn create(&self, _t: &str, _d: Map<String, Value>) -> Result<CreateOutcome, StorageError> {
Ok(CreateOutcome::default())
}
async fn update(&self, _t: &str, _pk: &str, _id: &str, _d: Map<String, Value>) -> Result<u64, StorageError> {
Ok(1)
}
async fn delete(&self, _t: &str, _pk: &str, _id: &str, _s: bool) -> Result<u64, StorageError> {
Ok(1)
}
async fn health(&self) -> bool {
true
}
}
#[derive(Clone)]
struct Widgets;
#[async_trait]
impl Resource for Widgets {
fn resource_name(&self) -> &'static str {
"Widgets"
}
fn base_path(&self) -> &'static str {
"widgets"
}
fn table_name(&self) -> &'static str {
"widgets"
}
fn clone_box(&self) -> Box<dyn Resource> {
Box::new(self.clone())
}
}
fn has_auth_cookie(resp: &ApiResponse) -> bool {
resp.headers
.iter()
.any(|(k, v)| k == "Set-Cookie" && v.contains("adminx_token="))
}
#[tokio::test]
async fn auth_enforced_across_api_and_ui() {
configure(AuthConfig {
jwt_secret: "test-secret-key-that-is-long-enough-32b".into(),
token_ttl_secs: 3600,
admin_table: "adminx_users".into(),
secure_cookie: false,
});
set_storage(Box::new(AuthMock));
register_resource(Box::new(Widgets));
let token = issue_token("1", "admin@x.io", "admin", auth::MFA_OK).expect("issue");
let claims = verify_token(&token).expect("verify");
assert_eq!(claims.role, "admin");
assert!(verify_token("not-a-real-token").is_none());
let hash = auth::hash_password("secret").unwrap();
assert!(verify_password("secret", &hash));
assert!(!verify_password("wrong", &hash));
let admin_roles = ["admin".to_string()];
let anon = ReqCtx::new().with_mount("/adminx");
assert!(!is_authorized(&anon, &admin_roles), "anon denied");
let admin_ctx = build_ctx("/adminx", "", Some(&token), None);
assert!(is_authorized(&admin_ctx, &admin_roles), "admin allowed");
let editor_token = issue_token("2", "ed@x.io", "editor", auth::MFA_OK).unwrap();
let editor_ctx = build_ctx("/adminx", "", Some(&editor_token), None);
assert!(
!is_authorized(&editor_ctx, &admin_roles),
"editor denied on admin-only"
);
let res = Widgets;
assert_eq!(res.list(&anon).await.status, 401);
assert_eq!(res.list(&admin_ctx).await.status, 200);
let ui_anon = res.list_page(&anon).await;
assert_eq!(ui_anon.status, 303);
assert!(ui_anon
.headers
.iter()
.any(|(k, v)| k == "Location" && v == "/adminx/login"));
assert_eq!(res.list_page(&admin_ctx).await.status, 200);
const CSRF_TOK: &str = "csrf-token-value";
let csrf_ctx = build_ctx("/adminx", "", None, Some(CSRF_TOK));
let good = handle_login(&csrf_ctx, "admin@x.io", "secret", Some(CSRF_TOK)).await;
assert_eq!(good.status, 303);
assert!(has_auth_cookie(&good), "login sets auth cookie");
let bad = handle_login(&csrf_ctx, "admin@x.io", "nope", Some(CSRF_TOK)).await;
assert_eq!(bad.status, 200, "bad login re-renders the form");
assert!(!has_auth_cookie(&bad));
}
#[tokio::test]
async fn login_requires_a_matching_csrf_pair() {
configure(AuthConfig {
jwt_secret: "test-secret-key-that-is-long-enough-32b".into(),
token_ttl_secs: 3600,
admin_table: "adminx_users".into(),
secure_cookie: false,
});
set_storage(Box::new(AuthMock));
const TOK: &str = "csrf-token-value";
let with_cookie = build_ctx("/adminx", "", None, Some(TOK));
let no_cookie = build_ctx("/adminx", "", None, None);
let forged = handle_login(&no_cookie, "admin@x.io", "secret", Some(TOK)).await;
assert_eq!(forged.status, 403, "no cookie -> rejected");
assert!(!has_auth_cookie(&forged), "forged post must not log anyone in");
let bare = handle_login(&with_cookie, "admin@x.io", "secret", None).await;
assert_eq!(bare.status, 403, "no form field -> rejected");
assert!(!has_auth_cookie(&bare));
let mismatch = handle_login(&with_cookie, "admin@x.io", "secret", Some("other")).await;
assert_eq!(mismatch.status, 403, "mismatched pair -> rejected");
assert!(!has_auth_cookie(&mismatch));
let neither = handle_login(&no_cookie, "admin@x.io", "secret", None).await;
assert_eq!(neither.status, 403, "absent pair -> rejected");
assert!(!has_auth_cookie(&neither));
let ok = handle_login(&with_cookie, "admin@x.io", "secret", Some(TOK)).await;
assert_eq!(ok.status, 303);
assert!(has_auth_cookie(&ok));
}
#[tokio::test]
async fn login_throttles_repeated_password_guessing() {
configure(AuthConfig {
jwt_secret: "test-secret-key-that-is-long-enough-32b".into(),
token_ttl_secs: 3600,
admin_table: "adminx_users".into(),
secure_cookie: false,
});
set_storage(Box::new(AuthMock));
const TOK: &str = "csrf-token-value";
let ctx = build_ctx("/adminx", "", None, Some(TOK));
let guess = |pw: &'static str| handle_login(&ctx, "throttle@x.io", pw, Some(TOK));
for i in 1..=10 {
let resp = guess("wrong").await;
assert_eq!(resp.status, 200, "attempt {i} should re-render, not throttle");
}
let over = guess("wrong").await;
assert_eq!(over.status, 429, "attempt 11 must be throttled");
let correct = guess("secret").await;
assert_eq!(correct.status, 429, "a throttled account is closed to everyone");
assert!(!has_auth_cookie(&correct), "throttled login must not issue a token");
}
#[tokio::test]
async fn a_successful_login_clears_the_count() {
configure(AuthConfig {
jwt_secret: "test-secret-key-that-is-long-enough-32b".into(),
token_ttl_secs: 3600,
admin_table: "adminx_users".into(),
secure_cookie: false,
});
set_storage(Box::new(AuthMock));
const TOK: &str = "csrf-token-value";
let ctx = build_ctx("/adminx", "", None, Some(TOK));
let attempt = |pw: &'static str| handle_login(&ctx, "clears@x.io", pw, Some(TOK));
for _ in 0..9 {
assert_eq!(attempt("wrong").await.status, 200);
}
let good = attempt("secret").await;
assert_eq!(good.status, 303, "the 10th attempt, correct, must succeed");
assert!(has_auth_cookie(&good));
for i in 1..=9 {
assert_eq!(
attempt("wrong").await.status,
200,
"failure {i} after a success should start from a clean count"
);
}
}
#[test]
fn login_page_issues_a_usable_token() {
let fresh = auth::login_page(&ReqCtx::new().with_mount("/adminx"), None);
let cookie = fresh
.headers
.iter()
.find(|(k, _)| k == "Set-Cookie")
.map(|(_, v)| v.clone())
.expect("a visitor with no CSRF cookie gets one minted");
assert!(cookie.contains("adminx_csrf="));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Strict"));
let token = cookie
.strip_prefix("adminx_csrf=")
.and_then(|s| s.split(';').next())
.expect("token in cookie");
let html = match &fresh.body {
ApiBody::Bytes { data, .. } => String::from_utf8_lossy(data).to_string(),
other => panic!("expected an HTML body, got {other:?}"),
};
assert!(
html.contains(&format!(r#"name="_csrf" value="{token}""#)),
"the form field must mirror the cookie the same response sets"
);
let repeat = auth::login_page(&ReqCtx::new().with_mount("/adminx").with_csrf("existing"), None);
assert!(
!repeat.headers.iter().any(|(k, _)| k == "Set-Cookie"),
"an existing CSRF cookie must be reused, not replaced"
);
}