use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::sync::{Mutex, OnceCell};
use tower::ServiceExt;
use umbral_admin::{
AdminPlugin, AdminView, KpiPayload, Span, Widget, WidgetDataFn, WidgetKind, WidgetPayload,
WidgetSection,
};
use umbral_auth::{AuthPlugin, AuthUser, create_user_with_flags};
use umbral_permissions::PermissionsPlugin;
use umbral_sessions::SessionsPlugin;
static BOOT: OnceCell<axum::Router> = OnceCell::const_new();
static LOCK: Mutex<()> = Mutex::const_new(());
const SECRET_CODENAME: &str = "reports.view_secret";
const SECRET_WIDGET_KEY: &str = "rpt_secret";
const DASH_WIDGET_KEY: &str = "dash_gated";
fn tiny_kpi(key: &'static str) -> Widget {
Widget {
key,
title: format!("KPI {key}"),
kind: WidgetKind::Kpi,
default_span: Span { cols: 3, rows: 1 },
permission: None,
default_period: None,
data: WidgetDataFn::new(|_user| async move {
WidgetPayload::Kpi(KpiPayload {
value: "42".to_string(),
unit: Some("units".to_string()),
delta: None,
sparkline: None,
})
}),
}
}
fn tiny_kpi_permissioned(key: &'static str, perm: &'static str) -> Widget {
Widget {
key,
title: format!("KPI {key}"),
kind: WidgetKind::Kpi,
default_span: Span { cols: 3, rows: 1 },
permission: Some(perm),
default_period: None,
data: WidgetDataFn::new(|_user| async move {
WidgetPayload::Kpi(KpiPayload {
value: "99".to_string(),
unit: None,
delta: None,
sparkline: None,
})
}),
}
}
async fn boot() -> &'static axum::Router {
BOOT.get_or_init(|| async {
let settings = umbral::Settings::from_env().expect("settings");
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("custom_views.sqlite");
std::mem::forget(tmp);
let pool_obj = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::new()
.filename(&path)
.create_if_missing(true),
)
.await
.expect("pool");
let app = umbral::App::builder()
.settings(settings)
.database("default", pool_obj)
.plugin(AuthPlugin::<AuthUser>::default())
.plugin(SessionsPlugin::default().without_auto_layer())
.plugin(PermissionsPlugin)
.plugin(
AdminPlugin::default()
.view(
AdminView::new("reports/sales", "Sales report")
.with_icon("bar-chart")
.section(
WidgetSection::new("This month").widget(tiny_kpi("rpt_total")),
),
)
.view(
AdminView::new("reports/secret", "Secret report")
.with_permission(SECRET_CODENAME)
.section(
WidgetSection::new("Secret data")
.widget(tiny_kpi(SECRET_WIDGET_KEY)),
),
)
.dashboard_section(
WidgetSection::new("Gated section")
.widget(tiny_kpi_permissioned(DASH_WIDGET_KEY, SECRET_CODENAME)),
),
)
.build()
.expect("App::build");
let migration_dir = tempfile::tempdir().expect("migration dir");
let migration_dir_path = migration_dir.path().to_path_buf();
std::mem::forget(migration_dir);
umbral::migrate::make_in(&migration_dir_path)
.await
.expect("make migrations");
umbral::migrate::run_in(&migration_dir_path)
.await
.expect("run migrations");
umbral_permissions::seed_standard_permissions_for_tests()
.await
.expect("seed permissions");
let pool = umbral::db::pool();
sqlx::query(
"INSERT OR IGNORE INTO permissions_contenttype \
(app_label, model) VALUES ('reports', 'secret')",
)
.execute(&pool)
.await
.expect("insert ct reports/secret");
let ct_id: i64 = sqlx::query_scalar(
"SELECT id FROM permissions_contenttype \
WHERE app_label = 'reports' AND model = 'secret'",
)
.fetch_one(&pool)
.await
.expect("fetch ct_id");
sqlx::query(
"INSERT OR IGNORE INTO permissions_permission \
(codename, content_type_id, name) VALUES (?, ?, 'Can view secret report')",
)
.bind(SECRET_CODENAME)
.bind(ct_id)
.execute(&pool)
.await
.expect("insert perm reports.view_secret");
create_user_with_flags("cv_staff", "cv_staff@example.com", "pass123", true, false)
.await
.expect("create cv_staff");
let privileged =
create_user_with_flags("cv_priv", "cv_priv@example.com", "pass123", true, false)
.await
.expect("create cv_priv");
sqlx::query(
"INSERT OR IGNORE INTO permissions_userpermission \
(user_id, permission_id) VALUES (?, ?)",
)
.bind(privileged.id.to_string())
.bind(SECRET_CODENAME)
.execute(&pool)
.await
.expect("grant reports.view_secret");
app.into_router()
})
.await
}
async fn cookie_for(username: &str) -> String {
let pool = umbral::db::pool();
let user = sqlx::query_as::<_, umbral_auth::AuthUser>(
"SELECT id, username, email, password_hash, is_active, is_staff, is_superuser, \
date_joined, last_login, email_verified_at \
FROM auth_user WHERE username = ?",
)
.bind(username)
.fetch_one(&pool)
.await
.unwrap_or_else(|_| panic!("lookup {username}"));
let tok = umbral_sessions::create_session(Some(user.id.to_string()), None)
.await
.expect("session");
format!("umbral_session={tok}")
}
#[tokio::test]
async fn test_custom_view_page_renders() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = cookie_for("cv_staff").await;
let req = Request::builder()
.uri("/admin/custom-views/reports/sales/")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"custom view page must return 200"
);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("Sales report"),
"page must contain the view title 'Sales report'"
);
assert!(
html.contains("id=\"widget-rpt_total\""),
"page must contain a widget cell with id=\"widget-rpt_total\" (widget_grid macro)"
);
assert!(
html.contains("/api/dashboard/widgets/rpt_total/data"),
"page must embed the HTMX data URL for the rpt_total widget"
);
}
#[tokio::test]
async fn test_custom_view_widget_data_served() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = cookie_for("cv_staff").await;
let req = Request::builder()
.uri("/admin/api/dashboard/widgets/rpt_total/data")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"widget registered in a custom view's section must be reachable via the global data endpoint"
);
}
#[tokio::test]
async fn test_custom_view_page_permission_gate_403() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = cookie_for("cv_staff").await;
let req = Request::builder()
.uri("/admin/custom-views/reports/secret/")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"staff user without the codename must get 403 on the gated view's page"
);
}
#[tokio::test]
async fn test_custom_view_page_permission_gate_200_with_codename() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = cookie_for("cv_priv").await;
let req = Request::builder()
.uri("/admin/custom-views/reports/secret/")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"staff user holding the codename must reach the gated view (200)"
);
}
#[tokio::test]
async fn test_custom_view_widget_data_gated_403() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = cookie_for("cv_staff").await;
let req = Request::builder()
.uri(&format!(
"/admin/api/dashboard/widgets/{SECRET_WIDGET_KEY}/data"
))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"staff user without the view codename must get 403 fetching the gated view's widget data"
);
}
#[tokio::test]
async fn test_custom_view_widget_data_gated_200_with_codename() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = cookie_for("cv_priv").await;
let req = Request::builder()
.uri(&format!(
"/admin/api/dashboard/widgets/{SECRET_WIDGET_KEY}/data"
))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"staff user holding the view codename must be served widget data (200)"
);
}
#[tokio::test]
async fn test_widget_permission_filters_dashboard_render() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie_no_perm = cookie_for("cv_staff").await;
let req = Request::builder()
.uri("/admin/?dashboard=1")
.header(header::COOKIE, cookie_no_perm)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"dashboard must load for cv_staff"
);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body);
assert!(
!html.contains(&format!("id=\"widget-{DASH_WIDGET_KEY}\"")),
"dashboard must NOT render the permissioned widget for a user lacking the codename; \
found id=\"widget-{DASH_WIDGET_KEY}\" in body"
);
let cookie_with_perm = cookie_for("cv_priv").await;
let req2 = Request::builder()
.uri("/admin/?dashboard=1")
.header(header::COOKIE, cookie_with_perm)
.body(Body::empty())
.unwrap();
let resp2 = router.clone().oneshot(req2).await.unwrap();
assert_eq!(
resp2.status(),
StatusCode::OK,
"dashboard must load for cv_priv"
);
let body2 = resp2.into_body().collect().await.unwrap().to_bytes();
let html2 = String::from_utf8_lossy(&body2);
assert!(
html2.contains(&format!("id=\"widget-{DASH_WIDGET_KEY}\"")),
"dashboard MUST render the permissioned widget for a user who holds the codename; \
id=\"widget-{DASH_WIDGET_KEY}\" not found in body"
);
}
#[tokio::test]
async fn test_widget_permission_gates_data_endpoint() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie_no_perm = cookie_for("cv_staff").await;
let req = Request::builder()
.uri(&format!(
"/admin/api/dashboard/widgets/{DASH_WIDGET_KEY}/data"
))
.header(header::COOKIE, cookie_no_perm)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"staff user lacking the widget's codename must get 403 from the data endpoint"
);
let cookie_with_perm = cookie_for("cv_priv").await;
let req2 = Request::builder()
.uri(&format!(
"/admin/api/dashboard/widgets/{DASH_WIDGET_KEY}/data"
))
.header(header::COOKIE, cookie_with_perm)
.body(Body::empty())
.unwrap();
let resp2 = router.clone().oneshot(req2).await.unwrap();
assert_eq!(
resp2.status(),
StatusCode::OK,
"staff user holding the widget's codename must be served widget data (200)"
);
}
#[tokio::test]
async fn test_catalog_filters_by_widget_permission() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie_no_perm = cookie_for("cv_staff").await;
let req = Request::builder()
.uri("/admin/api/dashboard/catalog")
.header(header::COOKIE, cookie_no_perm)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "catalog must load for staff");
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json = String::from_utf8_lossy(&body);
assert!(
!json.contains(DASH_WIDGET_KEY),
"catalog must OMIT a permissioned widget for a user without the codename; \
found {DASH_WIDGET_KEY} in {json}"
);
let cookie_with_perm = cookie_for("cv_priv").await;
let req2 = Request::builder()
.uri("/admin/api/dashboard/catalog")
.header(header::COOKIE, cookie_with_perm)
.body(Body::empty())
.unwrap();
let resp2 = router.clone().oneshot(req2).await.unwrap();
assert_eq!(resp2.status(), StatusCode::OK);
let body2 = resp2.into_body().collect().await.unwrap().to_bytes();
let json2 = String::from_utf8_lossy(&body2);
assert!(
json2.contains(DASH_WIDGET_KEY),
"catalog MUST list the permissioned widget for a user who holds the codename"
);
}