#![allow(dead_code, private_interfaces)]
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::sync::OnceCell;
use tower::ServiceExt;
use umbral_admin::{AdminModel, AdminPlugin};
use umbral_auth::{AuthPlugin, AuthUser, create_user};
use umbral_sessions::SessionsPlugin;
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
struct Post {
id: i64,
title: String,
body: String,
}
static BOOT: OnceCell<axum::Router> = OnceCell::const_new();
async fn boot() -> &'static axum::Router {
BOOT.get_or_init(|| async {
let settings =
umbral::Settings::from_env().expect("figment defaults always load in test env");
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("admin_phase1.sqlite");
std::mem::forget(tmp);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::new()
.filename(&path)
.create_if_missing(true),
)
.await
.expect("sqlite pool");
let admin = AdminPlugin::default().register_for(
"blog",
AdminModel::new("post").label("Posts").icon("file-text"),
);
let app = umbral::App::builder()
.settings(settings)
.database("default", pool)
.plugin(AuthPlugin::<AuthUser>::default())
.plugin(SessionsPlugin::default().without_auto_layer())
.plugin(admin)
.model::<Post>()
.build()
.expect("App::build");
let pool = umbral::db::pool();
sqlx::query(
"CREATE TABLE auth_user (\
id INTEGER PRIMARY KEY AUTOINCREMENT,\
username TEXT NOT NULL UNIQUE,\
email TEXT NOT NULL,\
password_hash TEXT NOT NULL,\
is_active INTEGER NOT NULL,\
is_staff INTEGER NOT NULL,\
is_superuser INTEGER NOT NULL,\
date_joined TEXT NOT NULL,\
last_login TEXT\
)",
)
.execute(&pool)
.await
.expect("create auth_user");
sqlx::query(
"CREATE TABLE session (\
id TEXT PRIMARY KEY,\
user_id TEXT,\
data TEXT NOT NULL DEFAULT '{}',\
created_at TEXT NOT NULL,\
expires_at TEXT NOT NULL\
)",
)
.execute(&pool)
.await
.expect("create session");
sqlx::query(
"CREATE TABLE post (\
id INTEGER PRIMARY KEY AUTOINCREMENT,\
title TEXT NOT NULL,\
body TEXT NOT NULL\
)",
)
.execute(&pool)
.await
.expect("create post");
let staff = create_user("staff_user", "staff@test.com", "staffpass")
.await
.expect("create staff");
sqlx::query("UPDATE auth_user SET is_staff = 1 WHERE id = ?")
.bind(staff.id)
.execute(&pool)
.await
.expect("mark staff");
let _: AuthUser = create_user("reg_user", "reg@test.com", "regpass")
.await
.expect("create regular user");
sqlx::query("INSERT INTO post (title, body) VALUES ('Hello world', 'First post body')")
.execute(&pool)
.await
.expect("seed post");
app.into_router()
})
.await
}
async fn send_full(
router: axum::Router,
req: Request<Body>,
) -> (StatusCode, axum::http::HeaderMap, String) {
let resp = router.oneshot(req).await.expect("oneshot");
let status = resp.status();
let headers = resp.headers().clone();
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let body = String::from_utf8_lossy(&bytes).into_owned();
(status, headers, body)
}
async fn send(router: axum::Router, req: Request<Body>) -> (StatusCode, String) {
let (s, _, b) = send_full(router, req).await;
(s, b)
}
fn extract_csrf_token(html: &str) -> Option<String> {
let name_marker = r#"name="csrf_token""#;
let pos = html.find(name_marker)?;
let window_end = pos.saturating_add(400).min(html.len());
let window = &html[pos..window_end];
let value_marker = "value=\"";
let vstart = window.find(value_marker)? + value_marker.len();
let vend = window[vstart..].find('"')?;
Some(window[vstart..vstart + vend].to_string())
}
async fn login_session(router: &axum::Router, username: &str, password: &str) -> String {
let (_, hdrs, body) = send_full(
router.clone(),
Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await;
let anon_cookie = hdrs
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find_map(|s| {
let first = s.split(';').next()?;
let (k, v) = first.split_once('=')?;
if k.trim() == "umbral_csrf_token" {
Some(v.to_string())
} else {
None
}
})
.expect("GET /admin/login must set umbral_csrf_token cookie");
let csrf = extract_csrf_token(&body).expect("login page must have csrf_token");
let form = serde_urlencoded::to_string([
("username", username),
("password", password),
("csrf_token", &csrf),
("next", "/admin/"),
])
.unwrap();
let (_, hdrs2, _) = send_full(
router.clone(),
Request::builder()
.method("POST")
.uri("/admin/login")
.header(header::COOKIE, format!("umbral_csrf_token={anon_cookie}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(form))
.unwrap(),
)
.await;
hdrs2
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
s.split(';')
.next()
.and_then(|p| p.split_once('=').map(|(_, v)| v.to_string()))
})
.expect("POST /admin/login must set authenticated session cookie")
}
#[tokio::test]
async fn login_page_returns_200_with_form_and_csrf() {
let router = boot().await.clone();
let (status, _, body) = send_full(
router,
Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::OK, "GET /admin/login should be 200");
assert!(
body.contains("<form"),
"login page must contain a form element; body:\n{body}"
);
assert!(
body.contains(r#"name="csrf_token""#),
"login page must have a csrf_token field; body:\n{body}"
);
let token = extract_csrf_token(&body);
assert!(
token.as_deref().is_some_and(|t| !t.is_empty()),
"csrf_token value must be non-empty; body:\n{body}"
);
}
#[tokio::test]
async fn unauthenticated_admin_redirects_to_login_with_next() {
let router = boot().await.clone();
let (status, headers, _) = send_full(
router,
Request::builder()
.uri("/admin/")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER, "should be 302");
let location = headers.get(header::LOCATION).unwrap().to_str().unwrap();
assert!(
location.contains("/admin/login"),
"should redirect to /admin/login; got {location}"
);
assert!(
location.contains("next="),
"redirect should include next= param; got {location}"
);
assert!(
location.contains("%2Fadmin%2F") || location.contains("/admin/"),
"next should encode the admin path; got {location}"
);
}
#[tokio::test]
async fn login_with_valid_creds_sets_session_and_redirects() {
let router = boot().await.clone();
let (status, hdrs, body) = send_full(
router.clone(),
Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::OK);
let anon_cookie = hdrs
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
s.split(';')
.next()
.and_then(|p| p.split_once('=').map(|(_, v)| v.to_string()))
})
.expect("session cookie from GET /admin/login");
let csrf = extract_csrf_token(&body).expect("csrf_token from login page");
let form = serde_urlencoded::to_string([
("username", "staff_user"),
("password", "staffpass"),
("csrf_token", &csrf),
("next", "/admin/"),
])
.unwrap();
let (status2, hdrs2, _) = send_full(
router.clone(),
Request::builder()
.method("POST")
.uri("/admin/login")
.header(header::COOKIE, format!("umbral_csrf_token={anon_cookie}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(form))
.unwrap(),
)
.await;
assert_eq!(status2, StatusCode::SEE_OTHER, "valid login should 302");
let new_cookie = hdrs2.get(header::SET_COOKIE).and_then(|v| v.to_str().ok());
assert!(
new_cookie.is_some_and(|s| s.contains("umbral_session")),
"login should set umbral_session cookie; got {:?}",
new_cookie
);
let location = hdrs2.get(header::LOCATION).unwrap().to_str().unwrap();
assert_eq!(
location, "/admin/",
"should redirect to /admin/; got {location}"
);
}
#[tokio::test]
async fn login_with_bad_creds_returns_generic_error() {
let router = boot().await.clone();
let (_, hdrs, body) = send_full(
router.clone(),
Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await;
let anon_cookie = hdrs
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
s.split(';')
.next()
.and_then(|p| p.split_once('=').map(|(_, v)| v.to_string()))
})
.unwrap();
let csrf = extract_csrf_token(&body).unwrap();
let form = serde_urlencoded::to_string([
("username", "staff_user"),
("password", "wrongpassword"),
("csrf_token", &csrf),
("next", "/admin/"),
])
.unwrap();
let (status, _, body2) = send_full(
router.clone(),
Request::builder()
.method("POST")
.uri("/admin/login")
.header(header::COOKIE, format!("umbral_csrf_token={anon_cookie}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(form))
.unwrap(),
)
.await;
assert_ne!(status, StatusCode::SEE_OTHER, "bad creds must not redirect");
assert!(
body2.contains("<form"),
"must re-render the login form; body:\n{body2}"
);
let error_msg = "incorrect"; assert!(
body2.contains(error_msg),
"error message should say 'incorrect'; body:\n{body2}"
);
assert!(
!body2.contains("wrong password"),
"must not reveal which field was wrong; body:\n{body2}"
);
}
#[tokio::test]
async fn login_malicious_next_is_rejected() {
let router = boot().await.clone();
let (_, hdrs, body) = send_full(
router.clone(),
Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await;
let anon_cookie = hdrs
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
s.split(';')
.next()
.and_then(|p| p.split_once('=').map(|(_, v)| v.to_string()))
})
.unwrap();
let csrf = extract_csrf_token(&body).unwrap();
let form = serde_urlencoded::to_string([
("username", "staff_user"),
("password", "staffpass"),
("csrf_token", &csrf),
("next", "//evil.com/steal"),
])
.unwrap();
let (status, hdrs2, _) = send_full(
router.clone(),
Request::builder()
.method("POST")
.uri("/admin/login")
.header(header::COOKIE, format!("umbral_csrf_token={anon_cookie}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(form))
.unwrap(),
)
.await;
assert_eq!(
status,
StatusCode::SEE_OTHER,
"valid login should still redirect"
);
let location = hdrs2.get(header::LOCATION).unwrap().to_str().unwrap();
assert!(
!location.contains("evil.com"),
"must not redirect to external URL; got {location}"
);
assert!(
location.starts_with("/admin"),
"should redirect within /admin; got {location}"
);
}
#[tokio::test]
async fn changelist_renders_with_sidebar() {
let router = boot().await.clone();
let cookie = login_session(&router, "staff_user", "staffpass").await;
let (status, body) = send(
router,
Request::builder()
.uri("/admin/post/")
.header(header::COOKIE, format!("umbral_session={cookie}"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"changelist should be 200; body:\n{body}"
);
assert!(
body.contains(r#"id="umbral-admin-sidebar""#),
"base.html sidebar must be rendered; body:\n{body}"
);
}
#[tokio::test]
async fn sidebar_nav_lists_models_by_plugin() {
let router = boot().await.clone();
let cookie = login_session(&router, "staff_user", "staffpass").await;
let (status, body) = send(
router,
Request::builder()
.uri("/admin/")
.header(header::COOKIE, format!("umbral_session={cookie}"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::OK, "body:\n{body}");
assert!(
body.contains("sidebar-group-blog"),
"sidebar must show the 'blog' plugin group; body:\n{body}"
);
assert!(
body.contains("/admin/post/"),
"sidebar must link to /admin/post/; body:\n{body}"
);
}
#[tokio::test]
async fn explicit_label_overrides_model_display() {
let router = boot().await.clone();
let cookie = login_session(&router, "staff_user", "staffpass").await;
let (status, body) = send(
router,
Request::builder()
.uri("/admin/")
.header(header::COOKIE, format!("umbral_session={cookie}"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::OK, "body:\n{body}");
assert!(
body.contains("Posts"),
"sidebar must show the explicit label 'Posts'; body:\n{body}"
);
}
#[tokio::test]
async fn explicit_icon_appears_in_sidebar() {
let router = boot().await.clone();
let cookie = login_session(&router, "staff_user", "staffpass").await;
let (status, body) = send(
router,
Request::builder()
.uri("/admin/")
.header(header::COOKIE, format!("umbral_session={cookie}"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::OK, "body:\n{body}");
assert!(
body.contains("data-lucide=\"file-text\""),
"sidebar must contain the file-text icon; body:\n{body}"
);
}
#[tokio::test]
async fn auto_discovered_model_appears_in_sidebar() {
let router = boot().await.clone();
let cookie = login_session(&router, "staff_user", "staffpass").await;
let (status, body) = send(
router,
Request::builder()
.uri("/admin/")
.header(header::COOKIE, format!("umbral_session={cookie}"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::OK, "body:\n{body}");
assert!(
body.contains("/admin/post/"),
"auto-discovered model must appear in sidebar; body:\n{body}"
);
}
#[tokio::test]
async fn theme_toggle_button_has_onclick() {
let router = boot().await.clone();
let cookie = login_session(&router, "staff_user", "staffpass").await;
let (status, body) = send(
router,
Request::builder()
.uri("/admin/")
.header(header::COOKIE, format!("umbral_session={cookie}"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::OK, "body:\n{body}");
assert!(
body.contains(r#"id="theme-toggle""#),
"page must contain the theme-toggle button; body:\n{body}"
);
assert!(
body.contains(r#"onclick="umbral.toggleTheme()""#),
"theme-toggle must have onclick=\"umbral.toggleTheme()\"; body:\n{body}"
);
}