use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::routing::any;
use axum::Router;
use postrust_core::{AppConfig, SchemaCache};
use postrust_server::{handle_request, AppState};
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::PgPool;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::RwLock;
use tower::ServiceExt;
const NOINHERIT: &str = "postrust_test_noinherit_authenticator";
const INHERIT: &str = "postrust_test_inherit_authenticator";
const READER: &str = "postrust_test_reader_authenticator";
const PASSWORD: &str = "postrust_test_authenticator";
const SCHEMAS: [&str; 4] = ["public", "api", "noinherit_a", "noinherit_b"];
const FIXTURE: &str = r#"
DO $setup$
BEGIN
PERFORM pg_advisory_xact_lock(hashtext('postrust_noinherit_fixture'));
IF obj_description(to_regnamespace('noinherit_a'), 'pg_namespace')
IS NOT DISTINCT FROM 'postrust noinherit fixture v1' THEN
RETURN;
END IF;
DROP SCHEMA IF EXISTS noinherit_a CASCADE;
DROP SCHEMA IF EXISTS noinherit_b CASCADE;
CREATE SCHEMA noinherit_a;
CREATE SCHEMA noinherit_b;
GRANT USAGE ON SCHEMA noinherit_a, noinherit_b TO web_anon;
CREATE TABLE noinherit_a.column_only (id int PRIMARY KEY, shown text, hidden text);
GRANT SELECT (id, shown) ON noinherit_a.column_only TO web_anon;
CREATE VIEW noinherit_a.read_only_view AS SELECT id, shown FROM noinherit_a.column_only;
GRANT SELECT ON noinherit_a.read_only_view TO web_anon;
CREATE TABLE noinherit_a.ungranted (id int);
CREATE TYPE noinherit_a.mood AS ENUM ('calm', 'grumpy');
CREATE TYPE noinherit_b.mood AS ENUM ('sunny', 'stormy', 'foggy');
CREATE TABLE noinherit_a.moods (id int PRIMARY KEY, mood noinherit_a.mood);
CREATE TABLE noinherit_b.moods (id int PRIMARY KEY, mood noinherit_b.mood);
GRANT SELECT ON noinherit_a.moods, noinherit_b.moods TO web_anon;
CREATE TABLE noinherit_a.status (value text PRIMARY KEY, comment text);
INSERT INTO noinherit_a.status VALUES ('open', 'Not done'), ('closed', NULL);
GRANT SELECT ON noinherit_a.status TO web_anon;
CREATE DOMAIN noinherit_a.color AS integer;
CREATE FUNCTION noinherit_a.color_json(noinherit_a.color) RETURNS json
LANGUAGE sql IMMUTABLE AS $f$ SELECT to_json(lpad(to_hex($1), 6, '0')) $f$;
CREATE CAST (noinherit_a.color AS json)
WITH FUNCTION noinherit_a.color_json(noinherit_a.color) AS IMPLICIT;
REVOKE EXECUTE ON FUNCTION noinherit_a.color_json(noinherit_a.color) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION noinherit_a.color_json(noinherit_a.color) TO web_anon;
COMMENT ON SCHEMA noinherit_a IS 'postrust noinherit fixture v1';
END
$setup$
"#;
fn database_url() -> String {
std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/postrust_test".to_string())
}
fn schemas() -> Vec<String> {
SCHEMAS.iter().map(|s| s.to_string()).collect()
}
async fn admin() -> PgPool {
PgPoolOptions::new()
.max_connections(1)
.connect(&database_url())
.await
.expect("failed to connect to test database")
}
fn url_as(role: &str) -> String {
let url = database_url();
let (scheme, rest) = url.split_once("://").expect("DATABASE_URL is a URL");
let host = rest.split_once('@').map_or(rest, |(_, host)| host);
format!("{scheme}://{role}:{PASSWORD}@{host}")
}
async fn connect_as(role: &str) -> PgPool {
let options = PgConnectOptions::from_str(&database_url())
.expect("DATABASE_URL is not a valid connection string")
.username(role)
.password(PASSWORD);
PgPoolOptions::new()
.max_connections(2)
.connect_with(options)
.await
.expect("failed to connect as an authenticator")
}
async fn authenticators() -> (PgPool, PgPool) {
let admin = admin().await;
sqlx::query(FIXTURE)
.execute(&admin)
.await
.expect("failed to create the fixture schemas");
for (role, inherit) in [(NOINHERIT, "NOINHERIT"), (INHERIT, "INHERIT")] {
sqlx::query(&format!(
"DO $$ BEGIN \
PERFORM pg_advisory_xact_lock(hashtext('{role}')); \
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{role}') THEN \
CREATE ROLE {role}; \
END IF; \
ALTER ROLE {role} LOGIN {inherit} PASSWORD '{PASSWORD}'; \
GRANT web_anon, web_user, web_admin TO {role}; \
END $$"
))
.execute(&admin)
.await
.expect("failed to create an authenticator");
}
let (inheriting, noinherit) = (connect_as(INHERIT).await, connect_as(NOINHERIT).await);
let alone: Result<(i64,), _> = sqlx::query_as("SELECT count(*) FROM public.users")
.fetch_one(&noinherit)
.await;
assert!(
alone.is_err(),
"the authenticator can read public.users without switching role, \
so it is not a NOINHERIT authenticator and this test proves nothing"
);
(inheriting, noinherit)
}
fn describe(cache: &SchemaCache) -> Vec<String> {
let mut tables: Vec<String> = cache
.tables
.values()
.map(|t| {
let columns: Vec<String> = t
.columns
.values()
.map(|c| format!("{}:{}:{}", c.name, c.data_type, c.nullable))
.collect();
format!(
"{}.{} view={} insert={} update={} delete={} pk={:?} columns={:?}",
t.schema,
t.name,
t.is_view,
t.insertable,
t.updatable,
t.deletable,
t.pk_cols,
columns
)
})
.collect();
tables.sort();
tables
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn a_noinherit_authenticator_sees_the_schema_an_inheriting_one_does() {
let (inheriting, noinherit) = authenticators().await;
let expected = describe(
&SchemaCache::load(&inheriting, &schemas())
.await
.expect("schema cache as the inheriting authenticator"),
);
assert!(
expected.iter().any(|t| t.starts_with("public.users ")),
"the fixtures are not loaded: {expected:#?}"
);
let actual = describe(
&SchemaCache::load(&noinherit, &schemas())
.await
.expect("schema cache as the NOINHERIT authenticator"),
);
assert_eq!(actual, expected);
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn a_request_is_answered_through_the_authenticator() {
let (_, authenticator) = authenticators().await;
let mut config = AppConfig::default();
config.db_schemas = schemas();
config.db_anon_role = Some("web_anon".to_string());
config.db_uri = url_as(NOINHERIT);
let schema_cache = SchemaCache::load(&authenticator, &config.db_schemas)
.await
.expect("schema cache as the authenticator");
let jwt_config = postrust_auth::JwtConfig {
secret: config.jwt_secret.clone(),
secret_is_base64: config.jwt_secret_is_base64,
audience: config.jwt_aud.clone(),
role_claim_key: config.jwt_role_claim_key.clone(),
anon_role: config.db_anon_role.clone(),
};
let state = Arc::new(AppState {
pool: authenticator,
schema_cache: RwLock::new(schema_cache),
config,
jwt_config,
jwt_cache: None,
});
let api: Router<Arc<AppState>> = Router::new()
.route("/", any(handle_request))
.route("/{*path}", any(handle_request));
let app = Router::new().nest("/api", api).with_state(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/users?select=id&limit=1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
let rows: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(rows.as_array().map(Vec::len), Some(1), "{rows}");
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn the_schema_cache_holds_what_the_grants_say() {
let (_, noinherit) = authenticators().await;
let cache = SchemaCache::load(&noinherit, &schemas())
.await
.expect("schema cache as the NOINHERIT authenticator");
let table = |schema: &str, name: &str| {
cache
.tables
.values()
.find(|t| t.schema == schema && t.name == name)
};
let column_only = table("noinherit_a", "column_only").expect("column_only is visible");
let names: Vec<&str> = column_only.columns.keys().map(String::as_str).collect();
assert_eq!(names, ["id", "shown"]);
assert!(!column_only.insertable && !column_only.updatable && !column_only.deletable);
let view = table("noinherit_a", "read_only_view").expect("read_only_view is visible");
assert!(view.is_view);
assert!(!view.insertable && !view.updatable && !view.deletable);
assert!(table("noinherit_a", "ungranted").is_none());
let labels = |schema: &str| {
table(schema, "moods")
.and_then(|t| t.columns.get("mood"))
.map(|c| c.enum_values.clone())
.unwrap_or_default()
};
assert_eq!(labels("noinherit_a"), ["calm", "grumpy"]);
assert_eq!(labels("noinherit_b"), ["sunny", "stormy", "foggy"]);
assert!(
cache
.representations
.keys()
.any(|(source, target)| source == "noinherit_a.color" && target == "json"),
"{:?}",
cache.representations.keys().collect::<Vec<_>>()
);
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn an_enum_table_is_read_as_a_role_that_may_read_it() {
let (_, noinherit) = authenticators().await;
let values = postrust_core::schema_cache::enum_values(
&noinherit,
"noinherit_a",
"status",
"value",
true,
)
.await
.expect("enum values through the NOINHERIT authenticator");
assert_eq!(
values,
[
("closed".to_string(), None),
("open".to_string(), Some("Not done".to_string()))
]
);
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn a_member_of_pg_read_all_data_sees_every_table() {
let admin = admin().await;
sqlx::query(FIXTURE)
.execute(&admin)
.await
.expect("failed to create the fixture schemas");
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT FROM pg_roles WHERE rolname = 'pg_read_all_data')",
)
.fetch_one(&admin)
.await
.unwrap();
if !exists {
eprintln!("pg_read_all_data needs PostgreSQL 14 or later; skipped");
return;
}
sqlx::query(&format!(
"DO $$ BEGIN \
PERFORM pg_advisory_xact_lock(hashtext('{READER}')); \
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{READER}') THEN \
CREATE ROLE {READER}; \
END IF; \
ALTER ROLE {READER} LOGIN INHERIT PASSWORD '{PASSWORD}'; \
GRANT pg_read_all_data TO {READER}; \
END $$"
))
.execute(&admin)
.await
.expect("failed to create the reader");
let cache = SchemaCache::load(&connect_as(READER).await, &schemas())
.await
.expect("schema cache as the reader");
assert!(cache
.tables
.values()
.any(|t| t.schema == "noinherit_a" && t.name == "ungranted"));
}