#![allow(dead_code)]
use sqlx::PgPool;
use umbral::db::{DatabaseRouter, RouteContext, Schema, TenantKey};
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]
#[umbral(table = "sq_pg_widget")]
pub struct SqPgWidget {
pub id: i64,
pub name: String,
}
struct TenantSchemaRouter;
impl DatabaseRouter for TenantSchemaRouter {
fn schema_for(&self, ctx: &RouteContext) -> Option<Schema> {
match ctx.tenant().map(|t| t.as_str()) {
Some("acme") => Schema::new("t_a"),
Some("globex") => Schema::new("t_b"),
_ => None,
}
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "needs UMBRAL_TEST_POSTGRES_URL pointing at a Postgres server"]
async fn schema_router_isolates_tenants_on_postgres() {
let Ok(url) = std::env::var("UMBRAL_TEST_POSTGRES_URL") else {
eprintln!("skipping: UMBRAL_TEST_POSTGRES_URL not set");
return;
};
let pool = PgPool::connect(&url).await.expect("connect Postgres");
let mut settings = umbral::Settings::from_env().expect("settings");
settings.database_url = url.clone();
umbral::App::builder()
.settings(settings)
.database("default", pool.clone())
.router(TenantSchemaRouter)
.model::<SqPgWidget>()
.build()
.expect("App::build");
for ddl in [
"DROP SCHEMA IF EXISTS t_a CASCADE",
"DROP SCHEMA IF EXISTS t_b CASCADE",
"CREATE SCHEMA t_a",
"CREATE SCHEMA t_b",
"CREATE TABLE t_a.sq_pg_widget (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL)",
"CREATE TABLE t_b.sq_pg_widget (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL)",
] {
sqlx::query(ddl).execute(&pool).await.expect("ddl");
}
let tenant_a = RouteContext::new().with_tenant(TenantKey::new("acme"));
let tenant_b = RouteContext::new().with_tenant(TenantKey::new("globex"));
umbral::db::route_context_scope(tenant_a.clone(), async {
SqPgWidget::objects()
.create(SqPgWidget {
id: 0,
name: "a-only".into(),
})
.await
.expect("insert as tenant A");
})
.await;
umbral::db::route_context_scope(tenant_b.clone(), async {
SqPgWidget::objects()
.create(SqPgWidget {
id: 0,
name: "b-only".into(),
})
.await
.expect("insert as tenant B");
})
.await;
let a_rows = umbral::db::route_context_scope(tenant_a.clone(), async {
SqPgWidget::objects()
.fetch()
.await
.expect("read as tenant A")
})
.await;
assert_eq!(a_rows.len(), 1, "tenant A sees exactly its own row");
assert_eq!(a_rows[0].name, "a-only");
let b_rows = umbral::db::route_context_scope(tenant_b.clone(), async {
SqPgWidget::objects()
.fetch()
.await
.expect("read as tenant B")
})
.await;
assert_eq!(b_rows.len(), 1, "tenant B sees exactly its own row");
assert_eq!(b_rows[0].name, "b-only");
assert!(
a_rows.iter().all(|w| w.name != "b-only"),
"tenant A must NOT see tenant B's rows"
);
assert!(
b_rows.iter().all(|w| w.name != "a-only"),
"tenant B must NOT see tenant A's rows"
);
let a_count: i64 = sqlx::query_scalar("SELECT count(*) FROM t_a.sq_pg_widget")
.fetch_one(&pool)
.await
.expect("count t_a");
let b_count: i64 = sqlx::query_scalar("SELECT count(*) FROM t_b.sq_pg_widget")
.fetch_one(&pool)
.await
.expect("count t_b");
assert_eq!(a_count, 1, "t_a holds exactly the tenant-A row");
assert_eq!(b_count, 1, "t_b holds exactly the tenant-B row");
for ddl in [
"DROP SCHEMA IF EXISTS t_a CASCADE",
"DROP SCHEMA IF EXISTS t_b CASCADE",
] {
sqlx::query(ddl).execute(&pool).await.expect("cleanup");
}
}