#![cfg(test)]
#![allow(clippy::manual_let_else, clippy::print_stdout, clippy::print_stderr)] #![allow(clippy::panic)] use fraiseql_core::{
cache::{CacheConfig, CachedDatabaseAdapter, QueryResultCache, RlsEnforcement},
db::{DatabaseAdapter, WhereClause, WhereOperator, postgres::PostgresAdapter},
error::FraiseQLError,
};
use serde_json::json;
fn test_db_url() -> Option<String> {
std::env::var("TEST_DATABASE_URL").ok()
}
const SETUP_SQL: &str = "
DROP TABLE IF EXISTS tenant_items CASCADE;
CREATE TABLE tenant_items (
pk_item BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id UUID NOT NULL,
name TEXT NOT NULL
);
CREATE OR REPLACE VIEW v_tenant_item AS
SELECT jsonb_build_object(
'pk_item', pk_item,
'tenant_id', tenant_id,
'name', name
) AS data
FROM tenant_items;
ALTER TABLE tenant_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_items FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON tenant_items;
CREATE POLICY tenant_isolation ON tenant_items
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
";
const TENANT_A: &str = "11111111-1111-1111-1111-111111111111";
const TENANT_B: &str = "22222222-2222-2222-2222-222222222222";
async fn setup_raw_connection(db_url: &str) -> tokio_postgres::Client {
let (client, connection) = tokio_postgres::connect(db_url, tokio_postgres::NoTls)
.await
.expect("raw tokio_postgres connection");
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("Setup connection error: {e}");
}
});
client
}
#[tokio::test]
async fn test_cache_does_not_leak_across_tenant_boundaries() {
let db_url = if let Some(url) = test_db_url() {
url
} else {
eprintln!("Skipping: TEST_DATABASE_URL not set");
return;
};
let setup_client = setup_raw_connection(&db_url).await;
setup_client.batch_execute(SETUP_SQL).await.expect("setup SQL");
for i in 0..3_usize {
setup_client
.execute(
"INSERT INTO tenant_items (tenant_id, name) VALUES ($1::uuid, $2)",
&[&TENANT_A, &format!("item-a-{i}")],
)
.await
.expect("insert tenant A");
}
for i in 0..5_usize {
setup_client
.execute(
"INSERT INTO tenant_items (tenant_id, name) VALUES ($1::uuid, $2)",
&[&TENANT_B, &format!("item-b-{i}")],
)
.await
.expect("insert tenant B");
}
let adapter = PostgresAdapter::new(&db_url).await.expect("PostgresAdapter");
let cache = QueryResultCache::new(CacheConfig::enabled());
let cached = CachedDatabaseAdapter::new(adapter, cache, "test-1.0.0".to_string());
cached.validate_rls_active().await.expect(
"RLS must be active for this test. \
Check that the test user has RLS enabled and the policy was created.",
);
let where_a = WhereClause::Field {
path: vec!["tenant_id".to_string()],
operator: WhereOperator::Eq,
value: json!(TENANT_A),
};
let where_b = WhereClause::Field {
path: vec!["tenant_id".to_string()],
operator: WhereOperator::Eq,
value: json!(TENANT_B),
};
let result_a = cached
.execute_where_query("v_tenant_item", Some(&where_a), None, None, None)
.await
.expect("query tenant A");
assert_eq!(
result_a.len(),
3,
"Tenant A must see exactly 3 items, got {}: {:?}",
result_a.len(),
result_a
);
let result_b = cached
.execute_where_query("v_tenant_item", Some(&where_b), None, None, None)
.await
.expect("query tenant B");
assert_eq!(
result_b.len(),
5,
"Tenant B must see exactly 5 items (not 3 from tenant A's cache), got {}: {:?}",
result_b.len(),
result_b
);
let result_a_cached = cached
.execute_where_query("v_tenant_item", Some(&where_a), None, None, None)
.await
.expect("cached query tenant A");
assert_eq!(result_a_cached.len(), 3, "Cached result for tenant A must still be 3");
eprintln!(
"✅ Cache RLS isolation verified: \
tenant A saw {}, tenant B saw {} (independent cache entries)",
result_a.len(),
result_b.len()
);
}
#[tokio::test]
async fn test_validate_rls_active_fails_without_rls() {
let db_url = if let Some(url) = test_db_url() {
url
} else {
eprintln!("Skipping: TEST_DATABASE_URL not set");
return;
};
let adapter = PostgresAdapter::new(&db_url).await.expect("PostgresAdapter");
let cache = QueryResultCache::new(CacheConfig::enabled());
let cached = CachedDatabaseAdapter::new(adapter, cache, "test".to_string());
match cached.validate_rls_active().await {
Ok(()) => eprintln!("RLS is active on this connection (default on)"),
Err(FraiseQLError::Configuration { message }) => {
eprintln!("RLS not active: {message}");
assert!(message.contains("Row-Level Security"), "error message must mention RLS");
},
Err(other) => panic!("unexpected error type: {other:?}"),
}
}
#[tokio::test]
async fn test_enforce_rls_off_skips_check() {
let db_url = if let Some(url) = test_db_url() {
url
} else {
eprintln!("Skipping: TEST_DATABASE_URL not set");
return;
};
let adapter = PostgresAdapter::new(&db_url).await.expect("PostgresAdapter");
let cache = QueryResultCache::new(CacheConfig::enabled());
let cached = CachedDatabaseAdapter::new(adapter, cache, "test".to_string());
cached
.enforce_rls(RlsEnforcement::Off)
.await
.expect("enforce_rls(Off) must never error");
}