use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use http_body_util::BodyExt;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::sync::OnceCell;
use tower::ServiceExt;
use umbral::db::{RouteContext, TenantKey};
use umbral::plugin::Plugin;
use umbral_auth::{AuthPlugin, AuthUser, hash_password};
static BOOT: OnceCell<()> = OnceCell::const_new();
async fn boot() {
BOOT.get_or_init(|| async {
let settings = umbral::Settings::from_env().expect("figment defaults");
let tmp = tempfile::tempdir().expect("tempdir");
let db_path = tmp.path().join("umbral_db_session_var.sqlite");
std::mem::forget(tmp);
let opts = SqliteConnectOptions::new()
.busy_timeout(std::time::Duration::from_secs(5))
.filename(&db_path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(opts)
.await
.expect("sqlite connect");
umbral::App::builder()
.settings(settings)
.database("default", pool)
.plugin(umbral_sessions::SessionsPlugin::default().without_auto_layer())
.plugin(AuthPlugin::<AuthUser>::default())
.build()
.expect("App::build");
umbral::migrate::create_tables_for_tests()
.await
.expect("create the test schema");
})
.await;
}
async fn insert_user(username: &str, is_active: bool) -> i64 {
let pool = umbral::db::pool();
let hash = hash_password("testpass").expect("hash");
let now = chrono::Utc::now().to_rfc3339();
let row: (i64,) = sqlx::query_as(
"INSERT INTO auth_user
(username, email, password_hash, is_active, is_staff, is_superuser, date_joined)
VALUES (?, ?, ?, ?, 0, 0, ?)
RETURNING id",
)
.bind(username)
.bind(format!("{username}@example.com"))
.bind(&hash)
.bind(is_active)
.bind(&now)
.fetch_one(&pool)
.await
.expect("insert user");
row.0
}
fn hash_token(raw: &str) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(raw.as_bytes());
format!("{:x}", h.finalize())
}
async fn create_session_for(user_id: Option<i64>) -> String {
let pool = umbral::db::pool();
let raw = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let expires = now + chrono::Duration::days(14);
sqlx::query(
"INSERT INTO session (id, user_id, data, created_at, expires_at) VALUES (?, ?, '{}', ?, ?)",
)
.bind(hash_token(&raw))
.bind(user_id.map(|id| id.to_string()))
.bind(now.to_rfc3339())
.bind(expires.to_rfc3339())
.execute(&pool)
.await
.expect("insert session");
raw
}
async fn echo_session_vars() -> String {
umbral::db::route_context::current()
.session_vars()
.iter()
.map(|(n, v)| format!("{n}={v};"))
.collect()
}
fn app(plugin: AuthPlugin<AuthUser>) -> Router {
plugin.wrap_router(Router::new().route("/vars", get(echo_session_vars)))
}
async fn vars_for(app: Router, cookie: Option<&str>) -> String {
let mut b = Request::builder().uri("/vars");
if let Some(raw) = cookie {
b = b.header("cookie", format!("umbral_session={raw}"));
}
let resp = app
.oneshot(b.body(Body::empty()).unwrap())
.await
.expect("request");
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test(flavor = "multi_thread")]
async fn a_logged_in_request_publishes_its_user_id() {
boot().await;
let id = insert_user("dbvar_alice", true).await;
let raw = create_session_for(Some(id)).await;
let vars = vars_for(
app(AuthPlugin::<AuthUser>::default().with_db_session_var("app.user_id")),
Some(&raw),
)
.await;
assert_eq!(vars, format!("app.user_id={id};"), "got: {vars}");
}
#[tokio::test(flavor = "multi_thread")]
async fn an_anonymous_request_still_sets_the_variable_empty() {
boot().await;
let plugin = || AuthPlugin::<AuthUser>::default().with_db_session_var("app.user_id");
assert_eq!(vars_for(app(plugin()), None).await, "app.user_id=;");
let raw = create_session_for(None).await;
assert_eq!(vars_for(app(plugin()), Some(&raw)).await, "app.user_id=;");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_deactivated_user_publishes_no_identity() {
boot().await;
let id = insert_user("dbvar_banned", false).await;
let raw = create_session_for(Some(id)).await;
let vars = vars_for(
app(AuthPlugin::<AuthUser>::default().with_db_session_var("app.user_id")),
Some(&raw),
)
.await;
assert_eq!(
vars, "app.user_id=;",
"an inactive user must look anonymous"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn without_the_builder_no_variable_is_set() {
boot().await;
let id = insert_user("dbvar_optout", true).await;
let raw = create_session_for(Some(id)).await;
let vars = vars_for(app(AuthPlugin::<AuthUser>::default()), Some(&raw)).await;
assert_eq!(vars, "", "got: {vars}");
}
#[tokio::test(flavor = "multi_thread")]
async fn the_ambient_route_context_survives() {
boot().await;
let id = insert_user("dbvar_tenant", true).await;
let raw = create_session_for(Some(id)).await;
async fn echo_tenant_and_vars() -> String {
let ctx = umbral::db::route_context::current();
let tenant = ctx.tenant().map(|t| t.as_str()).unwrap_or("<none>");
let vars: String = ctx
.session_vars()
.iter()
.map(|(n, v)| format!("{n}={v};"))
.collect();
format!("tenant={tenant} {vars}")
}
let inner = AuthPlugin::<AuthUser>::default()
.with_db_session_var("app.user_id")
.wrap_router(Router::new().route("/vars", get(echo_tenant_and_vars)));
let outer = inner.layer(axum::middleware::from_fn(
|req: axum::extract::Request, next: axum::middleware::Next| async move {
let ctx = RouteContext::new()
.with_tenant(TenantKey::new("acme"))
.with_session_var("app.tenant", "acme");
umbral::db::route_context_scope(ctx, next.run(req)).await
},
));
let body = vars_for(outer, Some(&raw)).await;
assert_eq!(
body,
format!("tenant=acme app.tenant=acme;app.user_id={id};"),
"the auth layer must add to the context, not replace it",
);
}