#![allow(dead_code)]
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::{Mutex, OnceCell};
use tower::ServiceExt;
use umbral_admin::AdminPlugin;
use umbral_auth::{AuthPlugin, AuthUser, create_user};
use umbral_permissions::PermissionsPlugin;
use umbral_sessions::SessionsPlugin;
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "sbsecret")]
pub struct Secret {
pub id: i64,
pub data: String,
}
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "sbpublic")]
pub struct Public {
pub id: i64,
pub name: String,
}
static BOOT: OnceCell<axum::Router> = OnceCell::const_new();
static LOCK: Mutex<()> = Mutex::const_new(());
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("sidebar_perm_gate.sqlite");
std::mem::forget(tmp);
let pool = 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)
.plugin(AuthPlugin::<AuthUser>::default())
.plugin(SessionsPlugin::default().without_auto_layer())
.plugin(PermissionsPlugin)
.plugin(AdminPlugin::default())
.model::<Secret>()
.model::<Public>()
.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 ('app', 'sbsecret')",
)
.execute(&pool)
.await
.expect("insert ct sbsecret");
let ct_secret_id: i64 = sqlx::query_scalar(
"SELECT id FROM permissions_contenttype WHERE app_label = 'app' AND model = 'sbsecret'",
)
.fetch_one(&pool)
.await
.expect("fetch ct_secret_id");
sqlx::query(
"INSERT OR IGNORE INTO permissions_contenttype (app_label, model) VALUES ('app', 'sbpublic')",
)
.execute(&pool)
.await
.expect("insert ct sbpublic");
let ct_public_id: i64 = sqlx::query_scalar(
"SELECT id FROM permissions_contenttype WHERE app_label = 'app' AND model = 'sbpublic'",
)
.fetch_one(&pool)
.await
.expect("fetch ct_public_id");
sqlx::query(
"INSERT OR IGNORE INTO permissions_permission (codename, content_type_id, name) \
VALUES ('app.view_sbsecret', ?, 'Can view sbsecret')",
)
.bind(ct_secret_id)
.execute(&pool)
.await
.expect("insert perm view_sbsecret");
sqlx::query(
"INSERT OR IGNORE INTO permissions_permission (codename, content_type_id, name) \
VALUES ('app.view_sbpublic', ?, 'Can view sbpublic')",
)
.bind(ct_public_id)
.execute(&pool)
.await
.expect("insert perm view_sbpublic");
let restricted = create_user("sb_restricted", "sb_r@example.com", "pass123")
.await
.expect("create sb_restricted");
sqlx::query("UPDATE auth_user SET is_staff = 1 WHERE id = ?")
.bind(restricted.id)
.execute(&pool)
.await
.expect("set staff");
sqlx::query(
"INSERT OR IGNORE INTO permissions_userpermission (user_id, permission_id) \
VALUES (?, 'app.view_sbpublic')",
)
.bind(restricted.id.to_string())
.execute(&pool)
.await
.expect("grant view_sbpublic");
let privileged = create_user("sb_privileged", "sb_p@example.com", "pass123")
.await
.expect("create sb_privileged");
sqlx::query("UPDATE auth_user SET is_staff = 1 WHERE id = ?")
.bind(privileged.id)
.execute(&pool)
.await
.expect("set privileged staff");
sqlx::query(
"INSERT OR IGNORE INTO permissions_userpermission (user_id, permission_id) \
VALUES (?, 'app.view_sbpublic')",
)
.bind(privileged.id.to_string())
.execute(&pool)
.await
.expect("grant view_sbpublic to privileged");
sqlx::query(
"INSERT OR IGNORE INTO permissions_userpermission (user_id, permission_id) \
VALUES (?, 'app.view_sbsecret')",
)
.bind(privileged.id.to_string())
.execute(&pool)
.await
.expect("grant view_sbsecret to privileged");
let superuser = create_user("sb_super", "sb_s@example.com", "pass123")
.await
.expect("create sb_super");
sqlx::query("UPDATE auth_user SET is_staff = 1, is_superuser = 1 WHERE id = ?")
.bind(superuser.id)
.execute(&pool)
.await
.expect("set superuser");
app.into_router()
})
.await
}
async fn send_get(router: axum::Router, uri: &str, session: &str) -> (StatusCode, String) {
let resp = router
.oneshot(
Request::builder()
.uri(uri)
.header(header::COOKIE, format!("umbral_session={session}"))
.body(Body::empty())
.unwrap(),
)
.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())
}
fn extract_csrf(html: &str) -> String {
let marker = r#"name="csrf_token""#;
let pos = html.find(marker).unwrap_or(0);
let window = &html[pos..(pos + 200).min(html.len())];
let val = r#"value=""#;
let vpos = window.find(val).unwrap_or(0);
let after = &window[vpos + val.len()..];
let end = after.find('"').unwrap_or(0);
after[..end].to_string()
}
fn extract_cookie(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(router: axum::Router, username: &str) -> String {
let resp = router
.clone()
.oneshot(
Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await
.expect("get login");
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(&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);
let form = serde_urlencoded::to_string([
("username", username),
("password", "pass123"),
("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))
.unwrap(),
)
.await
.expect("post login");
resp2
.headers()
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.map(extract_cookie)
.unwrap_or(anon_cookie)
}
#[tokio::test]
async fn sidebar_hides_model_when_view_perm_absent() {
let _g = LOCK.lock().await;
let router = boot().await.clone();
let session = login(router.clone(), "sb_restricted").await;
let (_status, html) = send_get(router, "/admin/", &session).await;
assert!(
!html.contains("sbsecret"),
"model without view perm must NOT appear in sidebar; \
found 'sbsecret' in dashboard HTML"
);
assert!(
html.contains("sbpublic"),
"model with view perm must appear in sidebar"
);
}
#[tokio::test]
async fn sidebar_shows_model_when_view_perm_present() {
let _g = LOCK.lock().await;
let router = boot().await.clone();
let session = login(router.clone(), "sb_privileged").await;
let (_status, html) = send_get(router, "/admin/", &session).await;
assert!(
html.contains("sbsecret"),
"privileged user must see sbsecret"
);
assert!(
html.contains("sbpublic"),
"privileged user must see sbpublic"
);
}
#[tokio::test]
async fn sidebar_shows_all_models_for_superuser() {
let _g = LOCK.lock().await;
let router = boot().await.clone();
let session = login(router.clone(), "sb_super").await;
let (_status, html) = send_get(router, "/admin/", &session).await;
assert!(html.contains("sbsecret"), "superuser must see sbsecret");
assert!(html.contains("sbpublic"), "superuser must see sbpublic");
}