fraiseql-core 2.3.2

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
//! Cache + RLS isolation integration tests.
//!
//! Verifies that two users with identical GraphQL queries but different RLS policies
//! (different tenant IDs) receive independently cached results — not each other's data.
//!
//! # Setup
//!
//! Requires a PostgreSQL database with:
//! - A `v_tenant_item` view backed by a `tenant_items` table with a `tenant_id` column
//! - An RLS policy: `WHERE tenant_id = current_setting('app.tenant_id')::uuid`
//! - Two tenants pre-populated: tenant A (3 items), tenant B (5 items)
//!
//! Run the integration tests with:
//! ```bash
//! TEST_DATABASE_URL=postgres://... cargo test -p fraiseql-core cache_rls_isolation
//! ```

#![cfg(test)]
#![allow(clippy::manual_let_else, clippy::print_stdout, clippy::print_stderr)] // Reason: test uses match for clarity in assertion context
#![allow(clippy::panic)] // Reason: test code, panics acceptable
use fraiseql_core::{
    cache::{CacheConfig, CachedDatabaseAdapter, QueryResultCache, RlsEnforcement},
    db::{DatabaseAdapter, WhereClause, WhereOperator, postgres::PostgresAdapter},
    error::FraiseQLError,
};
use serde_json::json;

/// Returns the test database URL from the environment, or `None` if not set.
fn test_db_url() -> Option<String> {
    std::env::var("TEST_DATABASE_URL").ok()
}

/// SQL to set up the RLS-protected test fixture.
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";

/// Connect to PostgreSQL via `tokio_postgres` for raw DDL/DML fixture setup.
///
/// Returns the connected client; the caller must keep the connection task alive.
async fn setup_raw_connection(db_url: &str) -> tokio_postgres::Client {
    // tokio-postgres uses the libpq-style connection string when passed as a URL.
    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
}

/// Verifies that two users with the same GraphQL query but different RLS policies
/// receive independently cached results, not each other's data.
///
/// This test requires:
/// - `TEST_DATABASE_URL` environment variable pointing to a PostgreSQL instance
/// - The test user must have privileges to enable RLS and create policies
#[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;
    };

    // Create a raw connection for fixture setup (bypasses RLS via superuser).
    let setup_client = setup_raw_connection(&db_url).await;

    // Set up schema and RLS policy.
    setup_client.batch_execute(SETUP_SQL).await.expect("setup SQL");

    // Insert 3 items for tenant A and 5 items for tenant B (as superuser, bypasses RLS).
    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");
    }

    // Build a cached adapter using fraiseql's own adapter stack.
    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());

    // Verify RLS is active before running the test.
    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.",
    );

    // --- Query as tenant A ---
    // Since CachedDatabaseAdapter does not expose session variable setting, we verify
    // isolation indirectly: two calls with different WHERE clauses produce different cache
    // entries (the RLS policy manifests as different WHERE clauses in the query planner).

    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),
    };

    // Query tenant A.
    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
    );

    // Query tenant B — must NOT get tenant A's cached response.
    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
    );

    // A second query for tenant A must still return 3 (cache hit, correct entry).
    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()
    );
}

/// Verifies that `validate_rls_active()` returns a `Configuration` error when RLS
/// is not active, and `Ok(())` when it is.
///
/// This test uses the same fixture as above but toggled on/off.
#[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());

    // With a plain connection (no SET LOCAL row_security), the result depends on
    // the PostgreSQL server default. We assert the return type is correct either way.
    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:?}"),
    }
}

/// Verifies `enforce_rls()` with `RlsEnforcement::Off` never errors.
#[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());

    // With `Off`, the check is skipped entirely — must always succeed.
    cached
        .enforce_rls(RlsEnforcement::Off)
        .await
        .expect("enforce_rls(Off) must never error");
}