corteq 0.1.0

Enterprise-grade multi-tenant SaaS framework for Rust with security-first design
Documentation
//! Integration tests for Row-Level Security (RLS) tenant isolation
//!
//! These tests verify that PostgreSQL RLS policies enforce strict tenant data isolation:
//! - Queries only return data for the current tenant
//! - Cross-tenant access is blocked at the database level
//! - INSERT/UPDATE/DELETE operations are tenant-scoped

use corteq::{TenantContext, TenantDatabase};
use sqlx::PgPool;
use uuid::Uuid;

/// Test tenant IDs from migration
const ACME_TENANT_ID: &str = "11111111-1111-1111-1111-111111111111";
const TEST_TENANT_ID: &str = "22222222-2222-2222-2222-222222222222";

/// Acme Corp document IDs from migration
const ACME_DOC_1: &str = "aaaaaaaa-1111-1111-1111-111111111111";
const ACME_DOC_2: &str = "aaaaaaaa-2222-2222-2222-222222222222";

/// Test Tenant document IDs from migration
const TEST_DOC_1: &str = "cccccccc-1111-1111-1111-111111111111";

/// Get database URL from environment or use default
/// RLS tests require a non-superuser role to properly test RLS enforcement
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()
        })
}

/// Create a database connection pool for testing
/// Uses a non-superuser role to properly test RLS enforcement
async fn create_test_pool() -> PgPool {
    PgPool::connect(&get_database_url())
        .await
        .expect("Failed to connect to test database")
}

/// Create a tenant context for testing
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();

    // Query as Acme Corp
    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");

    // Query as Test Tenant
    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");

    // Verify document was NOT created
    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");

    // Cleanup
    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");

    // Verify the Test Tenant document was not modified
    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");

    // Verify the Test Tenant document still exists
    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;

    // Query without setting tenant context (no TenantDatabase wrapper)
    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();

    // Count documents as Acme Corp
    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");

    // Count documents as Test Tenant
    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);
}