use corteq::{TenantContext, TenantDatabase};
use sqlx::PgPool;
use uuid::Uuid;
const ACME_TENANT_ID: &str = "11111111-1111-1111-1111-111111111111";
const TEST_TENANT_ID: &str = "22222222-2222-2222-2222-222222222222";
const ACME_DOC_1: &str = "aaaaaaaa-1111-1111-1111-111111111111";
const ACME_DOC_2: &str = "aaaaaaaa-2222-2222-2222-222222222222";
const TEST_DOC_1: &str = "cccccccc-1111-1111-1111-111111111111";
fn get_database_url() -> String {
std::env::var("RLS_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| {
"postgres://corteq_app:corteq_app_pass@172.24.0.3:5432/corteq_test".to_string()
})
}
async fn create_test_pool() -> PgPool {
PgPool::connect(&get_database_url())
.await
.expect("Failed to connect to test database")
}
fn create_tenant_context(tenant_id: Uuid) -> TenantContext {
TenantContext::new(tenant_id, "test-tenant".to_string(), "test-key".to_string())
}
#[tokio::test]
async fn test_rls_query_returns_only_tenant_data() {
let pool = create_test_pool().await;
let acme_tenant_id = Uuid::parse_str(ACME_TENANT_ID).unwrap();
let tenant_ctx = create_tenant_context(acme_tenant_id);
let mut db = TenantDatabase::begin(&pool, &tenant_ctx)
.await
.expect("Failed to begin transaction");
let docs: Vec<(Uuid, String)> =
sqlx::query_as("SELECT id, title FROM documents WHERE deleted_at IS NULL ORDER BY title")
.fetch_all(db.connection())
.await
.expect("Failed to query documents");
assert_eq!(docs.len(), 2);
assert_eq!(docs[0].0, Uuid::parse_str(ACME_DOC_2).unwrap());
assert!(docs[0].1.contains("Acme Corp"));
assert_eq!(docs[1].0, Uuid::parse_str(ACME_DOC_1).unwrap());
assert!(docs[1].1.contains("Acme Corp"));
db.commit().await.expect("Failed to commit");
}
#[tokio::test]
async fn test_rls_blocks_cross_tenant_access_by_id() {
let pool = create_test_pool().await;
let acme_tenant_id = Uuid::parse_str(ACME_TENANT_ID).unwrap();
let test_doc_id = Uuid::parse_str(TEST_DOC_1).unwrap();
let tenant_ctx = create_tenant_context(acme_tenant_id);
let mut db = TenantDatabase::begin(&pool, &tenant_ctx)
.await
.expect("Failed to begin transaction");
let result: Option<(Uuid, String)> =
sqlx::query_as("SELECT id, title FROM documents WHERE id = $1 AND deleted_at IS NULL")
.bind(test_doc_id)
.fetch_optional(db.connection())
.await
.expect("Query should succeed but return no rows");
assert!(
result.is_none(),
"RLS should block access to Test Tenant document"
);
db.commit().await.expect("Failed to commit");
}
#[tokio::test]
async fn test_rls_different_tenant_sees_different_data() {
let pool = create_test_pool().await;
let acme_tenant_id = Uuid::parse_str(ACME_TENANT_ID).unwrap();
let test_tenant_id = Uuid::parse_str(TEST_TENANT_ID).unwrap();
let acme_ctx = create_tenant_context(acme_tenant_id);
let mut acme_db = TenantDatabase::begin(&pool, &acme_ctx)
.await
.expect("Failed to begin transaction");
let acme_docs: Vec<(Uuid,)> =
sqlx::query_as("SELECT id FROM documents WHERE deleted_at IS NULL")
.fetch_all(acme_db.connection())
.await
.expect("Failed to query Acme documents");
acme_db.commit().await.expect("Failed to commit");
let test_ctx = create_tenant_context(test_tenant_id);
let mut test_db = TenantDatabase::begin(&pool, &test_ctx)
.await
.expect("Failed to begin transaction");
let test_docs: Vec<(Uuid,)> =
sqlx::query_as("SELECT id FROM documents WHERE deleted_at IS NULL")
.fetch_all(test_db.connection())
.await
.expect("Failed to query Test Tenant documents");
test_db.commit().await.expect("Failed to commit");
assert_eq!(acme_docs.len(), 2);
assert_eq!(test_docs.len(), 2);
let acme_ids: Vec<Uuid> = acme_docs.into_iter().map(|d| d.0).collect();
let test_ids: Vec<Uuid> = test_docs.into_iter().map(|d| d.0).collect();
assert!(
!acme_ids.iter().any(|id| test_ids.contains(id)),
"Tenants should not see each other's documents"
);
}
#[tokio::test]
async fn test_rls_blocks_insert_for_wrong_tenant() {
let pool = create_test_pool().await;
let acme_tenant_id = Uuid::parse_str(ACME_TENANT_ID).unwrap();
let test_tenant_id = Uuid::parse_str(TEST_TENANT_ID).unwrap();
let new_doc_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let acme_ctx = create_tenant_context(acme_tenant_id);
let mut acme_db = TenantDatabase::begin(&pool, &acme_ctx)
.await
.expect("Failed to begin transaction");
let result = sqlx::query(
"INSERT INTO documents (id, tenant_id, title, content, created_by)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(new_doc_id)
.bind(test_tenant_id)
.bind("Malicious Document")
.bind("Should not be inserted")
.bind(user_id)
.execute(acme_db.connection())
.await;
assert!(result.is_err(), "RLS should block insert for wrong tenant");
acme_db.rollback().await.expect("Failed to rollback");
let test_ctx = create_tenant_context(test_tenant_id);
let mut test_db = TenantDatabase::begin(&pool, &test_ctx)
.await
.expect("Failed to begin transaction");
let doc_exists: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM documents WHERE id = $1")
.bind(new_doc_id)
.fetch_optional(test_db.connection())
.await
.expect("Query should succeed");
assert!(
doc_exists.is_none(),
"Document should not have been inserted"
);
test_db.commit().await.expect("Failed to commit");
}
#[tokio::test]
async fn test_rls_allows_insert_for_correct_tenant() {
let pool = create_test_pool().await;
let acme_tenant_id = Uuid::parse_str(ACME_TENANT_ID).unwrap();
let new_doc_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let tenant_ctx = create_tenant_context(acme_tenant_id);
let mut db = TenantDatabase::begin(&pool, &tenant_ctx)
.await
.expect("Failed to begin transaction");
let result = sqlx::query(
"INSERT INTO documents (id, tenant_id, title, content, created_by)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(new_doc_id)
.bind(acme_tenant_id)
.bind("New Acme Document")
.bind("This should be inserted")
.bind(user_id)
.execute(db.connection())
.await;
assert!(result.is_ok(), "RLS should allow insert for correct tenant");
let doc: Option<(String,)> = sqlx::query_as("SELECT title FROM documents WHERE id = $1")
.bind(new_doc_id)
.fetch_optional(db.connection())
.await
.expect("Query should succeed");
assert!(doc.is_some());
assert_eq!(doc.unwrap().0, "New Acme Document");
sqlx::query("DELETE FROM documents WHERE id = $1")
.bind(new_doc_id)
.execute(db.connection())
.await
.expect("Cleanup failed");
db.commit().await.expect("Failed to commit");
}
#[tokio::test]
async fn test_rls_blocks_update_of_other_tenant_data() {
let pool = create_test_pool().await;
let acme_tenant_id = Uuid::parse_str(ACME_TENANT_ID).unwrap();
let test_doc_id = Uuid::parse_str(TEST_DOC_1).unwrap();
let acme_ctx = create_tenant_context(acme_tenant_id);
let mut acme_db = TenantDatabase::begin(&pool, &acme_ctx)
.await
.expect("Failed to begin transaction");
let result = sqlx::query("UPDATE documents SET title = $1 WHERE id = $2")
.bind("Hacked Title")
.bind(test_doc_id)
.execute(acme_db.connection())
.await;
assert!(result.is_ok());
assert_eq!(
result.unwrap().rows_affected(),
0,
"RLS should prevent updating other tenant's documents"
);
acme_db.commit().await.expect("Failed to commit");
let test_tenant_id = Uuid::parse_str(TEST_TENANT_ID).unwrap();
let test_ctx = create_tenant_context(test_tenant_id);
let mut test_db = TenantDatabase::begin(&pool, &test_ctx)
.await
.expect("Failed to begin transaction");
let doc: Option<(String,)> = sqlx::query_as("SELECT title FROM documents WHERE id = $1")
.bind(test_doc_id)
.fetch_optional(test_db.connection())
.await
.expect("Query should succeed");
assert!(doc.is_some());
assert_eq!(
doc.unwrap().0,
"Test Tenant - Meeting Notes",
"Document title should be unchanged"
);
test_db.commit().await.expect("Failed to commit");
}
#[tokio::test]
async fn test_rls_blocks_delete_of_other_tenant_data() {
let pool = create_test_pool().await;
let acme_tenant_id = Uuid::parse_str(ACME_TENANT_ID).unwrap();
let test_doc_id = Uuid::parse_str(TEST_DOC_1).unwrap();
let acme_ctx = create_tenant_context(acme_tenant_id);
let mut acme_db = TenantDatabase::begin(&pool, &acme_ctx)
.await
.expect("Failed to begin transaction");
let result = sqlx::query("DELETE FROM documents WHERE id = $1")
.bind(test_doc_id)
.execute(acme_db.connection())
.await;
assert!(result.is_ok());
assert_eq!(
result.unwrap().rows_affected(),
0,
"RLS should prevent deleting other tenant's documents"
);
acme_db.commit().await.expect("Failed to commit");
let test_tenant_id = Uuid::parse_str(TEST_TENANT_ID).unwrap();
let test_ctx = create_tenant_context(test_tenant_id);
let mut test_db = TenantDatabase::begin(&pool, &test_ctx)
.await
.expect("Failed to begin transaction");
let doc_exists: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM documents WHERE id = $1")
.bind(test_doc_id)
.fetch_optional(test_db.connection())
.await
.expect("Query should succeed");
assert!(doc_exists.is_some(), "Document should still exist");
test_db.commit().await.expect("Failed to commit");
}
#[tokio::test]
async fn test_rls_no_data_visible_without_tenant_context() {
let pool = create_test_pool().await;
let docs: Vec<(Uuid,)> = sqlx::query_as("SELECT id FROM documents WHERE deleted_at IS NULL")
.fetch_all(&pool)
.await
.expect("Query should succeed");
assert_eq!(
docs.len(),
0,
"No documents should be visible without tenant context"
);
}
#[tokio::test]
async fn test_rls_count_queries_are_tenant_scoped() {
let pool = create_test_pool().await;
let acme_tenant_id = Uuid::parse_str(ACME_TENANT_ID).unwrap();
let test_tenant_id = Uuid::parse_str(TEST_TENANT_ID).unwrap();
let acme_ctx = create_tenant_context(acme_tenant_id);
let mut acme_db = TenantDatabase::begin(&pool, &acme_ctx)
.await
.expect("Failed to begin transaction");
let acme_count: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM documents WHERE deleted_at IS NULL")
.fetch_one(acme_db.connection())
.await
.expect("Count query failed");
acme_db.commit().await.expect("Failed to commit");
let test_ctx = create_tenant_context(test_tenant_id);
let mut test_db = TenantDatabase::begin(&pool, &test_ctx)
.await
.expect("Failed to begin transaction");
let test_count: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM documents WHERE deleted_at IS NULL")
.fetch_one(test_db.connection())
.await
.expect("Count query failed");
test_db.commit().await.expect("Failed to commit");
assert_eq!(acme_count.0, 2);
assert_eq!(test_count.0, 2);
}