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};
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";
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_sidebar.sqlite");
std::mem::forget(tmp);
let pool_obj = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::new().busy_timeout(std::time::Duration::from_secs(5))
.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"))
.view(AdminView::new("internal/debug", "Debug panel").hide())
.view(
AdminView::new("reports/secret", "Secret report")
.with_permission(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");
let denied = create_user_with_flags(
"sidebar_staff",
"sidebar@example.com",
"pass123",
true,
false,
)
.await
.expect("create sidebar_staff");
let _ = denied;
let privileged = create_user_with_flags(
"sidebar_priv",
"sidebar_priv@example.com",
"pass123",
true,
false,
)
.await
.expect("create sidebar_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}")
}
async fn dashboard_html(router: &axum::Router, cookie: String) -> String {
let req = Request::builder()
.uri("/admin/?dashboard=1")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
String::from_utf8_lossy(&body).into_owned()
}
#[tokio::test]
async fn visible_view_appears_in_sidebar() {
let _guard = LOCK.lock().await;
let router = boot().await;
let html = dashboard_html(router, cookie_for("sidebar_staff").await).await;
assert!(
html.contains("href=\"/admin/custom-views/reports/sales/\""),
"sidebar must contain href for visible view"
);
assert!(
html.contains("data-lucide=\"bar-chart\""),
"sidebar must render the view's icon"
);
assert!(
html.contains("Sales report"),
"sidebar must show the view title"
);
}
#[tokio::test]
async fn hidden_view_absent_from_sidebar() {
let _guard = LOCK.lock().await;
let router = boot().await;
let html = dashboard_html(router, cookie_for("sidebar_staff").await).await;
assert!(
!html.contains("href=\"/admin/custom-views/internal/debug/\""),
"hidden view must not appear in the sidebar"
);
}
#[tokio::test]
async fn gated_view_hidden_for_user_without_codename() {
let _guard = LOCK.lock().await;
let router = boot().await;
let html = dashboard_html(router, cookie_for("sidebar_staff").await).await;
assert!(
!html.contains("href=\"/admin/custom-views/reports/secret/\""),
"permission-gated view must be filtered out for a staff user without the codename"
);
assert!(
html.contains("href=\"/admin/custom-views/reports/sales/\""),
"non-gated view must remain visible in the same response (filter is selective)"
);
}
#[tokio::test]
async fn gated_view_shown_for_user_with_codename() {
let _guard = LOCK.lock().await;
let router = boot().await;
let html = dashboard_html(router, cookie_for("sidebar_priv").await).await;
assert!(
html.contains("href=\"/admin/custom-views/reports/secret/\""),
"staff user holding the codename must see the gated view"
);
}