use adminx_core::actions::{ActionFuture, CustomAction};
use adminx_core::prelude::*;
use adminx_core::storage::{CreateOutcome, ListPage, QueryOptions, StorageError};
use async_trait::async_trait;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::Router;
use serde_json::{json, Map, Value};
use tower::util::ServiceExt;
struct AuthMock;
#[async_trait]
impl Storage for AuthMock {
async fn list(&self, _t: &str, _o: &QueryOptions) -> Result<ListPage, StorageError> {
Ok(ListPage { rows: vec![], total: 0 })
}
async fn get(&self, _t: &str, _pk: &str, _id: &str) -> Result<Option<Value>, StorageError> {
Ok(None)
}
async fn find_one_by(&self, _t: &str, column: &str, value: &str) -> Result<Option<Value>, StorageError> {
if column == "email" && value == "admin@x.io" {
return Ok(Some(json!({
"id": 1,
"email": "admin@x.io",
"encrypted_password": adminx_core::auth::hash_password("secret").unwrap(),
"role": "admin",
})));
}
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 Widget;
fn toggle(_ctx: ReqCtx, id: String, _body: Value) -> ActionFuture {
Box::pin(async move { ApiResponse::ok(json!({ "toggled": id })) })
}
#[async_trait]
impl Resource for Widget {
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 permit_keys(&self) -> Vec<&'static str> {
vec!["name"]
}
fn custom_actions(&self) -> Vec<CustomAction> {
vec![CustomAction::labeled("toggle", "Toggle", toggle)]
}
}
fn app() -> Router {
Router::new().nest("/adminx", adminx_axum::router())
}
fn setup() {
configure_auth(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));
}
fn cookies(resp: &axum::response::Response) -> Vec<String> {
resp.headers()
.get_all("set-cookie")
.iter()
.filter_map(|v| v.to_str().ok().map(str::to_string))
.collect()
}
fn cookie_named<'a>(cookies: &'a [String], name: &str) -> Option<&'a str> {
cookies
.iter()
.find_map(|c| c.strip_prefix(&format!("{name}=")))
.and_then(|v| v.split(';').next())
}
fn post_login(cookie_header: Option<&str>, body: String) -> Request<Body> {
let mut req = Request::builder()
.method("POST")
.uri("/adminx/login")
.header("content-type", "application/x-www-form-urlencoded");
if let Some(c) = cookie_header {
req = req.header("cookie", c);
}
req.body(Body::from(body)).unwrap()
}
#[tokio::test]
async fn browser_login_succeeds_and_forged_post_is_rejected() {
setup();
let form = app()
.oneshot(Request::builder().uri("/adminx/login").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(form.status(), StatusCode::OK);
let set = cookies(&form);
let token = cookie_named(&set, "adminx_csrf")
.expect("GET /login must mint a CSRF cookie")
.to_string();
let html = {
let bytes = axum::body::to_bytes(form.into_body(), usize::MAX).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
};
assert!(
html.contains(&format!(r#"name="_csrf" value="{token}""#)),
"the served form must carry the token matching the cookie it set"
);
let jar = format!("adminx_csrf={token}");
let creds = format!("email=admin%40x.io&password=secret&_csrf={token}");
let ok = app().oneshot(post_login(Some(&jar), creds.clone())).await.unwrap();
assert_eq!(ok.status(), StatusCode::SEE_OTHER, "genuine login must succeed");
assert!(
cookie_named(&cookies(&ok), "adminx_token").is_some(),
"genuine login must set the auth cookie"
);
let forged = app().oneshot(post_login(None, creds)).await.unwrap();
assert_eq!(forged.status(), StatusCode::FORBIDDEN, "no cookie -> rejected");
assert!(
cookie_named(&cookies(&forged), "adminx_token").is_none(),
"a forged post must never log anyone in"
);
let bare = app()
.oneshot(post_login(Some(&jar), "email=admin%40x.io&password=secret".into()))
.await
.unwrap();
assert_eq!(bare.status(), StatusCode::FORBIDDEN, "no _csrf field -> rejected");
assert!(cookie_named(&cookies(&bare), "adminx_token").is_none());
let mismatch = app()
.oneshot(post_login(
Some(&jar),
"email=admin%40x.io&password=secret&_csrf=wrong".into(),
))
.await
.unwrap();
assert_eq!(mismatch.status(), StatusCode::FORBIDDEN, "mismatch -> rejected");
assert!(cookie_named(&cookies(&mismatch), "adminx_token").is_none());
}
async fn login_as_admin() -> String {
let form = app()
.oneshot(Request::builder().uri("/adminx/login").body(Body::empty()).unwrap())
.await
.unwrap();
let token = cookie_named(&cookies(&form), "adminx_csrf").unwrap().to_string();
let jar = format!("adminx_csrf={token}");
let creds = format!("email=admin%40x.io&password=secret&_csrf={token}");
let ok = app().oneshot(post_login(Some(&jar), creds)).await.unwrap();
cookie_named(&cookies(&ok), "adminx_token")
.expect("login should set the auth cookie")
.to_string()
}
fn post_form(uri: &str, cookie_header: Option<&str>, body: String) -> Request<Body> {
let mut req = Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/x-www-form-urlencoded");
if let Some(c) = cookie_header {
req = req.header("cookie", c);
}
req.body(Body::from(body)).unwrap()
}
#[tokio::test]
async fn resource_forms_require_csrf() {
setup();
register_resource(Box::new(Widget));
let auth = login_as_admin().await;
let new_page = app()
.oneshot(
Request::builder()
.uri("/adminx/widgets/new")
.header("cookie", format!("adminx_token={auth}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(new_page.status(), StatusCode::OK, "admin can open the new form");
let csrf = cookie_named(&cookies(&new_page), "adminx_csrf")
.expect("the form page mints a CSRF cookie")
.to_string();
let body = axum::body::to_bytes(new_page.into_body(), usize::MAX).await.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains(&format!(r#"name="_csrf" value="{csrf}""#)));
let jar = format!("adminx_token={auth}; adminx_csrf={csrf}");
let cases = [
("create", "/adminx/widgets/create", StatusCode::SEE_OTHER),
("delete", "/adminx/widgets/1/delete", StatusCode::SEE_OTHER),
("action", "/adminx/widgets/1/action/toggle", StatusCode::OK),
];
for (label, uri, ok_status) in cases {
let good = app()
.oneshot(post_form(uri, Some(&jar), format!("name=Ada&_csrf={csrf}")))
.await
.unwrap();
assert_eq!(good.status(), ok_status, "{label}: valid token should pass");
let bad = app()
.oneshot(post_form(uri, Some(&jar), "name=Ada".into()))
.await
.unwrap();
assert_eq!(bad.status(), StatusCode::FORBIDDEN, "{label}: no token -> rejected");
let wrong = app()
.oneshot(post_form(uri, Some(&jar), "name=Ada&_csrf=nope".into()))
.await
.unwrap();
assert_eq!(wrong.status(), StatusCode::FORBIDDEN, "{label}: bad token -> rejected");
}
}