#![allow(dead_code, private_interfaces)]
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use chrono::{DateTime, Utc};
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,
published: bool,
created_at: Option<DateTime<Utc>>,
}
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 a test env");
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("phase2_dt.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 post_config = AdminModel::new("post")
.list_display(&["title", "published"])
.list_filter(&["published"])
.search_fields(&["title"])
.ordering(&["title"]);
let app = umbral::App::builder()
.settings(settings)
.database("default", pool)
.plugin(AuthPlugin::<AuthUser>::default())
.plugin(SessionsPlugin::default().without_auto_layer())
.plugin(AdminPlugin::default().register(post_config))
.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,\
email_verified_at 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,\
published INTEGER NOT NULL DEFAULT 0,\
created_at TEXT\
)",
)
.execute(&pool)
.await
.expect("create post");
let staff = create_user("dt_admin", "dt_admin@example.com", "password123")
.await
.expect("create staff user");
sqlx::query("UPDATE auth_user SET is_staff = 1 WHERE id = ?")
.bind(staff.id)
.execute(&pool)
.await
.expect("mark staff");
sqlx::query(
"INSERT INTO post (title, published) VALUES \
('Alpha post', 0), ('Beta post', 1), ('Gamma post', 1)",
)
.execute(&pool)
.await
.expect("seed posts");
app.into_router()
})
.await
}
async fn send(router: axum::Router, req: Request<Body>) -> (StatusCode, String) {
let resp = router.oneshot(req).await.expect("oneshot");
let status = resp.status();
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
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();
(
status,
headers,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
fn extract_csrf(html: &str) -> Option<String> {
let marker = r#"name="csrf_token""#;
let pos = html.find(marker)?;
let window = &html[pos..pos + 200];
let val_marker = r#"value=""#;
let vpos = window.find(val_marker)?;
let after = &window[vpos + val_marker.len()..];
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn extract_cookie_value(set_cookie: &str) -> String {
set_cookie
.split(';')
.next()
.and_then(|p| p.split_once('=').map(|(_, v)| v.to_string()))
.unwrap_or_default()
}
async fn login_session(router: axum::Router, username: &str, password: &str) -> String {
let resp = router
.clone()
.oneshot(
Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await
.expect("login get");
let anon_raw = resp
.headers()
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_default();
let anon_cookie = extract_cookie_value(&anon_raw);
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let html = String::from_utf8_lossy(&bytes).into_owned();
let csrf = extract_csrf(&html).unwrap_or_default();
let form_body = serde_urlencoded::to_string([
("username", username),
("password", password),
("csrf_token", csrf.as_str()),
("next", "/admin/"),
])
.unwrap();
let resp2 = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/login")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, format!("umbral_csrf_token={anon_cookie}"))
.body(Body::from(form_body))
.unwrap(),
)
.await
.expect("login post");
resp2
.headers()
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.map(extract_cookie_value)
.unwrap_or(anon_cookie)
}
#[tokio::test]
async fn test_changelist_list_display_columns_only() {
let router = boot().await.clone();
let session = login_session(router.clone(), "dt_admin", "password123").await;
let req = Request::builder()
.uri("/admin/post/")
.header(header::COOKIE, format!("umbral_session={session}"))
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("title"), "title column present: {body}");
assert!(
body.contains("published"),
"published column present: {body}"
);
}
#[tokio::test]
async fn test_changelist_tbody_carries_authoritative_rows_url() {
let router = boot().await.clone();
let session = login_session(router.clone(), "dt_admin", "password123").await;
let req = Request::builder()
.uri("/admin/post/")
.header(header::COOKIE, format!("umbral_session={session}"))
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains(r#"data-rows-url="/admin/post/rows""#),
"tbody must expose the authoritative rows URL for the refreshTable handler: {body}"
);
}
#[tokio::test]
async fn test_changelist_search_returns_matching_rows() {
let router = boot().await.clone();
let session = login_session(router.clone(), "dt_admin", "password123").await;
let req = Request::builder()
.uri("/admin/post/rows?search=alpha")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.to_lowercase().contains("alpha"),
"alpha row present: {body}"
);
assert!(
!body.to_lowercase().contains("beta"),
"beta row absent: {body}"
);
}
#[tokio::test]
async fn test_changelist_sort_order_desc() {
let router = boot().await.clone();
let session = login_session(router.clone(), "dt_admin", "password123").await;
let req = Request::builder()
.uri("/admin/post/rows?sort=title&order=desc&page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let gamma_pos = body.find("Gamma").unwrap_or(usize::MAX);
let alpha_pos = body.find("Alpha").unwrap_or(usize::MAX);
assert!(
gamma_pos < alpha_pos,
"Gamma before Alpha in desc order. body snippet: {}",
&body[..body.len().min(500)]
);
}
#[tokio::test]
async fn test_changelist_filter_published() {
let router = boot().await.clone();
let session = login_session(router.clone(), "dt_admin", "password123").await;
let req = Request::builder()
.uri("/admin/post/rows?filter=published=true&page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(
!body.contains("Alpha post"),
"Alpha (unpublished) absent: {body}"
);
}
#[tokio::test]
async fn test_changelist_pagination() {
let router = boot().await.clone();
let session = login_session(router.clone(), "dt_admin", "password123").await;
let req = Request::builder()
.uri("/admin/post/rows?sort=title&order=asc&page=2&page_size=1")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("Beta"), "Beta on page 2: {body}");
assert!(!body.contains("Alpha"), "Alpha absent on page 2: {body}");
assert!(!body.contains("Gamma"), "Gamma absent on page 2: {body}");
}
#[tokio::test]
async fn test_changelist_htmx_target_markup_present() {
let router = boot().await.clone();
let session = login_session(router.clone(), "dt_admin", "password123").await;
let req = Request::builder()
.uri("/admin/post/")
.header(header::COOKIE, format!("umbral_session={session}"))
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("hx-target=\"#table-body\""),
"hx-target=\"#table-body\" present in changelist: snippet={}",
&body[..body.len().min(2000)]
);
}
#[tokio::test]
async fn test_rows_fragment_includes_action_column() {
let router = boot().await.clone();
let session = login_session(router.clone(), "dt_admin", "password123").await;
let req = Request::builder()
.uri("/admin/post/rows?sort=title&order=asc&page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("data-lucide=\"eye\""),
"eye icon present in rows fragment: {body}"
);
assert!(
body.contains("data-lucide=\"pencil\""),
"pencil icon present in rows fragment: {body}"
);
assert!(
body.contains("data-lucide=\"trash-2\""),
"trash icon present in rows fragment: {body}"
);
assert!(
body.contains("sticky right-0"),
"sticky-right action cell present in rows fragment: {body}"
);
}
#[tokio::test]
async fn test_rows_htmx_fragment_only() {
let router = boot().await.clone();
let session = login_session(router.clone(), "dt_admin", "password123").await;
let req = Request::builder()
.uri("/admin/post/rows?page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, body) = send(router.clone(), req).await;
assert_eq!(status, StatusCode::OK);
assert!(!body.contains("<!doctype html>"), "not a full page: {body}");
assert!(!body.contains("<html"), "not a full page: {body}");
let req2 = Request::builder()
.uri("/admin/post/rows?page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.body(Body::empty())
.unwrap();
let (status2, headers2, _) = send_full(router, req2).await;
assert!(
status2 == StatusCode::SEE_OTHER
|| status2 == StatusCode::TEMPORARY_REDIRECT
|| status2 == StatusCode::FOUND,
"expected redirect status, got: {status2}"
);
let location = headers2
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
location.starts_with("/admin/post/"),
"redirect target should be the changelist; got: {location}"
);
}
#[tokio::test]
async fn test_chip_strip_oob_swap_clears_when_filter_removed() {
let router = boot().await.clone();
let session = login_session(router.clone(), "dt_admin", "password123").await;
let req_with_filter = Request::builder()
.uri("/admin/post/rows?filter_published=true&page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status, body) = send(router.clone(), req_with_filter).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains(r#"id="dt-active-filters-strip""#),
"OOB wrapper must be present so HTMX has a target: {}",
&body[..body.len().min(2000)]
);
assert!(
body.contains(r#"hx-swap-oob="outerHTML""#),
"OOB swap directive must be present so the strip updates with the rows: {}",
&body[..body.len().min(2000)]
);
assert!(
body.contains("Active filters:"),
"label must be present when at least one filter is active: {}",
&body[..body.len().min(2000)]
);
let oob_pos = body
.find(r#"id="dt-active-filters-strip""#)
.expect("OOB block present");
let pre_oob = &body[..oob_pos];
let last_template_open = pre_oob.rfind("<template");
let last_template_close = pre_oob.rfind("</template>");
let inside_template = match (last_template_open, last_template_close) {
(Some(open), Some(close)) => open > close,
(Some(_), None) => true,
_ => false,
};
assert!(
inside_template,
"OOB <div id=\"dt-active-filters-strip\"> MUST be inside a \
<template> wrapper or the browser strips it from tbody \
context and the table layout collapses. Body so far:\n{}",
&body[..body.len().min(800)]
);
let req_without_filter = Request::builder()
.uri("/admin/post/rows?page_size=10")
.header(header::COOKIE, format!("umbral_session={session}"))
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let (status2, body2) = send(router.clone(), req_without_filter).await;
assert_eq!(status2, StatusCode::OK);
assert!(
body2.contains(r#"id="dt-active-filters-strip""#),
"wrapper must persist even with zero filters so the next OOB swap has a target"
);
assert!(
body2.contains(r#"hx-swap-oob="outerHTML""#),
"OOB directive must still be present"
);
let strip_start = body2
.find(r#"id="dt-active-filters-strip""#)
.expect("wrapper present");
let strip_window = &body2[strip_start..body2.len().min(strip_start + 600)];
assert!(
!strip_window.contains("Active filters:"),
"with no active filters, the label MUST NOT appear inside the \
OOB strip — chip-removal regression. Strip window:\n{strip_window}"
);
}