diff --git a/SQLX_TESTING_APPROACH.md b/SQLX_TESTING_APPROACH.md new file mode 100644 index 0000000..3ddf634 --- /dev/null +++ b/SQLX_TESTING_APPROACH.md @@ -0,0 +1,155 @@ +# SQLx Testing Approach + +## Problem Statement + +The Rust component system (branch: `feature/rust-sql-tooling`) was built to: +1. Manage SQL file dependencies +2. Generate documentation +3. Enable Rust-based testing + +However, it introduced significant complexity: +- 200+ lines of trait/macro machinery +- Associated types, dependency traits, 9 macro patterns +- Documentation still needs transformation to be useful for customers +- No compile-time safety (SQL files can still be missing/broken) + +## Key Insight + +**We already have working dependency resolution** - the bash build system: + +```bash +# tasks/build.sh +find src -name "*.sql" | while read sql_file; do + # Parse -- REQUIRE: comments + # Build dependency graph +done + +cat src/deps.txt | tsort | tac > src/deps-ordered.txt +cat src/deps-ordered.txt | xargs cat > release/cipherstash-encrypt.sql +``` + +This works perfectly for: +- ✅ Building release files +- ✅ Resolving dependencies +- ✅ Development workflow + +## Proposed Solution + +**Use SQLx for testing, keep bash for builds:** + +1. **Dependencies** - Stay in SQL where they belong (`-- REQUIRE:`) +2. **Build** - Keep existing bash + tsort system +3. **Tests** - Use SQLx to load built SQL and test functionality +4. **Docs** - Generate from SQL comments (not rustdoc) + +## Architecture Comparison + +### Current (Bash + SQL) +``` +SQL files with -- REQUIRE: + ↓ +tasks/build.sh (parse + tsort) + ↓ +release/cipherstash-encrypt.sql + ↓ +psql < release/... +``` + +### Attempted (Rust Component System) +``` +SQL files with -- REQUIRE: + ↓ +Duplicate in Rust (sql_component! macro) + ↓ +Component::collect_dependencies() + ↓ +Load files in Rust tests +``` + +**Problem:** Duplication! Dependencies defined twice (SQL + Rust). + +### Proposed (Bash + SQLx) +``` +SQL files with -- REQUIRE: + ↓ +tasks/build.sh (existing) + ↓ +release/cipherstash-encrypt.sql + ↓ +SQLx tests load pre-built SQL +``` + +**Benefit:** Single source of truth in SQL. + +## Test Example + +### Before (Component System) +```rust +// Need component machinery +let deps = AddColumn::collect_dependencies(); +for file in deps { + let sql = std::fs::read_to_string(file)?; + db.batch_execute(&sql).await?; +} + +let result = db.query_one("SELECT eql_v2.add_column(...)").await?; +db.assert_jsonb_has_key(&result, 0, "tables")?; +``` + +### After (SQLx) +```rust +// Just load built SQL +let pool = setup_test_db().await?; // Loads release/cipherstash-encrypt.sql + +// Type-safe queries +let result = sqlx::query_scalar::<_, serde_json::Value>( + "SELECT eql_v2.add_column($1, $2, $3)" +) +.bind("users") +.bind("email") +.bind("text") +.fetch_one(&pool) +.await?; + +assert!(result.get("tables").is_some()); +``` + +## Benefits + +1. **Simpler** - No 200-line type system +2. **DRY** - Dependencies defined once (in SQL) +3. **Better ergonomics** - SQLx type inference, error messages +4. **Existing build works** - No changes to deployment +5. **Test isolation** - Each test gets fresh database + +## Trade-offs + +### What We Lose +- ❌ Rust type system for dependency graph +- ❌ rustdoc generation + +### What We Keep +- ✅ Working dependency resolution (bash + tsort) +- ✅ Tests in Rust (better than psql scripts) +- ✅ Single source of truth (SQL) + +### What We Gain +- ✅ Simpler codebase (delete complex machinery) +- ✅ Better test ergonomics (SQLx) +- ✅ Can generate docs from SQL comments directly + +## Implementation + +See `rust-tests/` directory for working prototype: +- `src/lib.rs` - Test setup helpers +- `tests/add_column_test.rs` - Example tests using SQLx +- `run-tests.sh` - Simple test runner + +## Decision + +The Rust component system solves a problem we don't have: +- We already have dependency resolution (bash + tsort) +- We don't get compile-time safety anyway (SQL not validated) +- Documentation needs transformation regardless + +**Recommendation:** Use SQLx for testing, keep bash for builds, delete component system. diff --git a/TESTING_FRAMEWORK_COMPARISON.md b/TESTING_FRAMEWORK_COMPARISON.md new file mode 100644 index 0000000..f8637f0 --- /dev/null +++ b/TESTING_FRAMEWORK_COMPARISON.md @@ -0,0 +1,597 @@ +# Testing Framework Comparison for PostgreSQL Extensions + +## Context + +We're testing **EQL** - a PostgreSQL extension for searchable encryption. The question: what's the best testing framework? + +Current prototype uses Rust + SQLx. But is Rust the right tool for testing SQL? + +## What Rust/SQLx Actually Provides + +### Supposed Benefits +- ✅ Type safety - But SQL is still strings, JSON responses are dynamic +- ✅ Memory safety - Tests don't have memory issues anyway +- ✅ Performance - Tests are I/O bound (database calls), not CPU bound +- ✅ Async/await - Doesn't matter for sequential test execution +- ❌ Compile times - Slow feedback loop (30s+ for simple test changes) + +### Actual Benefits +- Connection pooling (nice but not critical for tests) +- Familiar if you're already writing Rust (marginal) +- Good IDE support (LSP, autocomplete) + +### Downsides +- Slow compile times kill feedback loop +- Type system doesn't help much (SQL is dynamic) +- Overkill for testing database functions +- Small ecosystem for PostgreSQL testing + +## Alternative Testing Frameworks + +### Option 1: pgTAP (PostgreSQL-Native Testing) + +**What is it:** PostgreSQL extension for writing tests in SQL, inspired by Perl's TAP protocol. + +**Example:** +```sql +-- tests/add_column_test.sql +BEGIN; +SELECT plan(5); + +-- Load extension +\i release/cipherstash-encrypt.sql + +-- Setup +CREATE TABLE users (id int, email eql_v2_encrypted); + +-- Test: add_column succeeds +SELECT lives_ok( + 'SELECT eql_v2.add_column(''users'', ''email'', ''text'')', + 'add_column should succeed' +); + +-- Test: Configuration has expected structure +SELECT results_eq( + 'SELECT (data->>''tables'') IS NOT NULL FROM eql_v2_configuration WHERE state = ''active''', + ARRAY[true], + 'Config should have tables key' +); + +SELECT results_eq( + 'SELECT (data->>''v'') IS NOT NULL FROM eql_v2_configuration WHERE state = ''active''', + ARRAY[true], + 'Config should have version key' +); + +-- Test: Constraint was added +SELECT has_constraint( + 'users', + 'eql_v2_encrypted_check_email', + 'Should have encrypted constraint on email column' +); + +-- Test: Duplicate fails +SELECT throws_ok( + 'SELECT eql_v2.add_column(''users'', ''email'', ''text'')', + 'Column already configured', + 'Duplicate add_column should fail' +); + +SELECT * FROM finish(); +ROLLBACK; +``` + +**Running tests:** +```bash +# Install pgTAP +psql -c "CREATE EXTENSION pgtap;" + +# Run all tests +pg_prove -d testdb tests/*.sql + +# Run with verbose output +pg_prove -v -d testdb tests/add_column_test.sql +``` + +**Pros:** +- ✅ SQL-native - No language impedance mismatch +- ✅ Tests run IN the database - Can test internal state +- ✅ Designed for PostgreSQL extensions - Used by pg core +- ✅ Fast feedback - No compilation +- ✅ TAP output - CI-friendly +- ✅ Rich assertion library - `has_table`, `has_constraint`, `results_eq`, etc. +- ✅ Automatic rollback - Clean test isolation + +**Cons:** +- ❌ Less flexible than general-purpose languages +- ❌ Limited for complex test orchestration +- ❌ Less familiar to non-SQL developers + +**Best for:** +- Testing PostgreSQL functions, triggers, constraints +- Validating database schema changes +- Extension development (exactly our use case) + +--- + +### Option 2: Python + pytest + +**What is it:** Python's premier testing framework with excellent PostgreSQL support. + +**Example:** +```python +# tests/test_add_column.py +import pytest +import psycopg + +@pytest.fixture(scope="session") +def eql_sql(): + """Load EQL SQL once per session""" + with open("release/cipherstash-encrypt.sql") as f: + return f.read() + +@pytest.fixture +def db(eql_sql): + """Fresh database with EQL loaded for each test""" + conn = psycopg.connect( + "host=localhost port=7432 user=cipherstash password=password dbname=postgres" + ) + conn.autocommit = True + + # Create test database + test_db = f"eql_test_{uuid.uuid4().hex[:8]}" + conn.execute(f"CREATE DATABASE {test_db}") + + # Connect to test database + test_conn = psycopg.connect(f".../{test_db}") + test_conn.execute(eql_sql) + + yield test_conn + + # Cleanup + test_conn.close() + conn.execute(f"DROP DATABASE {test_db}") + conn.close() + +def test_add_column_creates_config(db): + """Test that add_column creates configuration""" + db.execute("CREATE TABLE users (id int, email eql_v2_encrypted)") + + result = db.execute( + "SELECT eql_v2.add_column('users', 'email', 'text')" + ).fetchone()[0] + + assert "tables" in result, "Config should have 'tables' key" + assert "v" in result, "Config should have version key" + + # Verify persisted config + config = db.execute( + "SELECT data FROM eql_v2_configuration WHERE state = 'active'" + ).fetchone()[0] + + assert config["tables"]["users"]["columns"]["email"] is not None + +def test_add_column_rejects_duplicate(db): + """Test that duplicate add_column fails""" + db.execute("CREATE TABLE users (id int, email eql_v2_encrypted)") + db.execute("SELECT eql_v2.add_column('users', 'email', 'text')") + + with pytest.raises(psycopg.Error) as exc: + db.execute("SELECT eql_v2.add_column('users', 'email', 'text')") + + assert "already configured" in str(exc.value) + +def test_multiple_encrypted_columns(db): + """Test configuring multiple encrypted columns""" + db.execute(""" + CREATE TABLE users ( + id int, + email eql_v2_encrypted, + phone eql_v2_encrypted, + ssn eql_v2_encrypted + ) + """) + + columns = ["email", "phone", "ssn"] + for col in columns: + db.execute(f"SELECT eql_v2.add_column('users', '{col}', 'text')") + + # Verify all constraints exist + constraints = db.execute(""" + SELECT conname + FROM pg_constraint + WHERE conname LIKE 'eql_v2_encrypted_check_%' + """).fetchall() + + constraint_names = [row[0] for row in constraints] + for col in columns: + expected = f"eql_v2_encrypted_check_{col}" + assert expected in constraint_names, f"Missing constraint for {col}" +``` + +**Running tests:** +```bash +# Install dependencies +pip install pytest psycopg[binary] + +# Run all tests +pytest tests/ + +# Run with verbose output +pytest -v tests/test_add_column.py + +# Run specific test +pytest tests/test_add_column.py::test_add_column_creates_config + +# Run with coverage +pytest --cov=. tests/ +``` + +**Pros:** +- ✅ Excellent fixture system - Setup/teardown is elegant +- ✅ Fast feedback - No compilation step +- ✅ Huge ecosystem - pytest plugins for everything +- ✅ Great for data validation - Python is good at JSON/dict manipulation +- ✅ Familiar to most developers - Low learning curve +- ✅ Parametrized tests - Easy to test multiple scenarios +- ✅ Rich assertions - Many assertion helpers available + +**Cons:** +- ❌ Not SQL-native - Extra layer of abstraction +- ❌ Runtime errors - No compile-time type checking +- ❌ Dependency management - pip/venv overhead + +**Best for:** +- Complex test orchestration +- Data validation and transformation +- Integration testing across multiple systems +- Teams familiar with Python + +--- + +### Option 3: TypeScript + Deno/Bun + +**What is it:** Modern JavaScript runtime with built-in testing and PostgreSQL support. + +**Example:** +```typescript +// tests/add_column.test.ts +import { assertEquals, assertRejects } from "@std/assert"; +import postgres from "postgres"; + +// Setup database connection +const sql = postgres("postgres://cipherstash:password@localhost:7432/postgres"); + +// Load EQL once +let eqlLoaded = false; +async function setupEQL() { + if (!eqlLoaded) { + const eql = await Deno.readTextFile("../release/cipherstash-encrypt.sql"); + await sql.unsafe(eql); + eqlLoaded = true; + } +} + +Deno.test("add_column creates configuration", async () => { + await setupEQL(); + + await sql`CREATE TABLE users (id int, email eql_v2_encrypted)`; + + const [result] = await sql` + SELECT eql_v2.add_column('users', 'email', 'text') as config + `; + + assertEquals(typeof result.config.tables, "object", "Config should have tables"); + assertEquals(typeof result.config.v, "string", "Config should have version"); + + // Verify persisted config + const [config] = await sql` + SELECT data FROM eql_v2_configuration WHERE state = 'active' + `; + + assertEquals(config.data.tables.users.columns.email !== undefined, true); + + // Cleanup + await sql`DROP TABLE users`; +}); + +Deno.test("add_column rejects duplicate", async () => { + await setupEQL(); + + await sql`CREATE TABLE users (id int, email eql_v2_encrypted)`; + await sql`SELECT eql_v2.add_column('users', 'email', 'text')`; + + await assertRejects( + async () => { + await sql`SELECT eql_v2.add_column('users', 'email', 'text')`; + }, + Error, + "already configured" + ); + + // Cleanup + await sql`DROP TABLE users`; +}); + +Deno.test("multiple encrypted columns", async () => { + await setupEQL(); + + await sql` + CREATE TABLE users ( + id int, + email eql_v2_encrypted, + phone eql_v2_encrypted, + ssn eql_v2_encrypted + ) + `; + + const columns = ["email", "phone", "ssn"]; + for (const col of columns) { + await sql`SELECT eql_v2.add_column('users', ${col}, 'text')`; + } + + // Verify constraints + const constraints = await sql` + SELECT conname + FROM pg_constraint + WHERE conname LIKE 'eql_v2_encrypted_check_%' + `; + + for (const col of columns) { + const expected = `eql_v2_encrypted_check_${col}`; + const found = constraints.some(c => c.conname === expected); + assertEquals(found, true, `Missing constraint for ${col}`); + } + + // Cleanup + await sql`DROP TABLE users`; +}); +``` + +**Running tests:** +```bash +# Using Deno (no install needed beyond deno itself) +deno test --allow-read --allow-net tests/ + +# Using Bun +bun test tests/ + +# Watch mode +deno test --watch tests/ +``` + +**Pros:** +- ✅ Fast startup - Instant feedback (Deno/Bun) +- ✅ Good type inference - TypeScript catches errors +- ✅ Modern syntax - Async/await, template strings +- ✅ No build step - Direct execution +- ✅ Built-in test runner - No extra dependencies +- ✅ Great DX - Fast iteration + +**Cons:** +- ❌ Smaller ecosystem than Python +- ❌ Not SQL-native +- ❌ TypeScript complexity for simple tests + +**Best for:** +- Teams already using TypeScript +- Fast iteration cycles +- Modern development workflow + +--- + +### Option 4: Rust + SQLx (Current Prototype) + +**Example:** +```rust +use sqlx::PgPool; + +#[sqlx::test] +async fn test_add_column_creates_config(pool: PgPool) { + // Load EQL + let eql = std::fs::read_to_string("../release/cipherstash-encrypt.sql").unwrap(); + sqlx::raw_sql(&eql).execute(&pool).await.unwrap(); + + sqlx::query("CREATE TABLE users (id int, email eql_v2_encrypted)") + .execute(&pool) + .await + .unwrap(); + + let result = sqlx::query_scalar::<_, serde_json::Value>( + "SELECT eql_v2.add_column('users', 'email', 'text')" + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert!(result.get("tables").is_some()); + assert!(result.get("v").is_some()); +} +``` + +**Pros:** +- ✅ Type safety - Compile-time checks (limited for SQL strings) +- ✅ Memory safety - Guaranteed at compile time +- ✅ Performance - Fastest runtime (doesn't matter for tests) +- ✅ Familiar for Rust devs + +**Cons:** +- ❌ Slow compile times - 30s+ for simple changes +- ❌ Complex toolchain - cargo, rustc, etc. +- ❌ Overkill for SQL testing - Type system doesn't help much +- ❌ Verbose error handling - .unwrap() everywhere +- ❌ Small testing ecosystem - Fewer tools than Python/JS + +**Best for:** +- Projects already in Rust +- When compile-time guarantees matter (not really for SQL tests) + +--- + +## Comparison Matrix + +| Feature | pgTAP | Python + pytest | TypeScript + Deno | Rust + SQLx | +|---------|-------|-----------------|-------------------|-------------| +| **SQL-native** | ✅✅✅ | ❌ | ❌ | ❌ | +| **Compile time** | None | None | None | **Slow (30s+)** | +| **Type safety** | N/A | Runtime | Good | Excellent (limited benefit) | +| **Test framework** | Excellent | Excellent | Good | Basic | +| **Learning curve** | Medium | Low | Low | High | +| **Feedback loop** | **Fast** | **Fast** | **Fast** | Slow | +| **For PG extensions** | ✅✅✅ | ✅✅ | ✅ | ❌ | +| **Ecosystem** | PostgreSQL-focused | Huge (pytest plugins) | Growing | Small | +| **Fixture system** | Built-in (BEGIN/ROLLBACK) | Excellent (@pytest.fixture) | Good | Basic | +| **CI integration** | Excellent (TAP) | Excellent | Good | Good | +| **Data assertions** | SQL-based | Excellent (Python dicts) | Good (JS objects) | Verbose (JSON) | +| **Team familiarity** | Low (unless SQL-heavy) | High | Medium | Low | +| **Development speed** | Fast | Fast | Fast | Slow | + +## Recommendations + +### Primary Recommendation: **pgTAP** + +**Rationale:** +- Designed specifically for testing PostgreSQL extensions +- Used by PostgreSQL core and major extensions (PostGIS, TimescaleDB) +- SQL-native - No impedance mismatch +- Fast feedback loop +- Can test database internals directly +- Automatic transaction rollback for isolation +- TAP output integrates with standard CI tools + +**When to use:** +- Testing PostgreSQL functions, types, operators +- Validating schema migrations +- Extension development (**exactly this use case**) +- When tests are primarily SQL-focused + +**Example workflow:** +```bash +# Write test +vim tests/add_column_test.sql + +# Run test (instant feedback) +pg_prove -v tests/add_column_test.sql + +# Run all tests in CI +pg_prove -r tests/ +``` + +--- + +### Secondary Recommendation: **Python + pytest** + +**Rationale:** +- Excellent when you need complex test orchestration +- Better for data validation/transformation +- Huge ecosystem (coverage, mocking, fixtures) +- Most developers know Python +- Great for integration tests across multiple systems + +**When to use:** +- Need to test interactions with external systems +- Complex data validation required +- Team is Python-heavy +- Want to combine SQL tests with API/service tests + +**Example workflow:** +```bash +# Write test +vim tests/test_add_column.py + +# Run test with auto-reload +pytest-watch tests/ + +# Run all tests with coverage +pytest --cov=. tests/ +``` + +--- + +### Not Recommended: **Rust + SQLx** + +**Rationale:** +- Compile times kill the feedback loop (30s+ for simple changes) +- Type system provides minimal benefit (SQL is dynamic) +- Overkill for testing database functions +- Verbose error handling +- Small ecosystem for PostgreSQL testing + +**Only use Rust if:** +- Already committed to Rust for other parts of the system +- Need to share test utilities with Rust application code +- Team is 100% Rust and nothing else + +--- + +## Decision Framework + +Ask yourself: + +1. **What are you primarily testing?** + - SQL functions/types → **pgTAP** + - Complex workflows → **Python** + - Mixed → **Python** or **TypeScript** + +2. **What does your team know?** + - SQL experts → **pgTAP** + - General purpose → **Python** + - JavaScript/TypeScript → **Deno/Bun** + - Rust → Stick with **Rust** (but recognize trade-offs) + +3. **What's your priority?** + - Fast feedback → **pgTAP** or **Python** + - Type safety → **Rust** or **TypeScript** (limited benefit) + - Simplicity → **pgTAP** + - Flexibility → **Python** + +4. **What's your test complexity?** + - Mostly SQL validation → **pgTAP** + - Multi-system integration → **Python** + - Somewhere in between → **Python** or **TypeScript** + +## Conclusion + +**For testing EQL (PostgreSQL extension):** + +**Best choice: pgTAP** +- SQL-native testing for SQL extensions +- Fast feedback loop +- Industry standard for PostgreSQL extensions +- Can test internal database state + +**Solid alternative: Python + pytest** +- When you need more flexibility +- Complex test orchestration +- Better data validation tools + +**Avoid: Rust + SQLx** +- Slow compile times +- Overkill for the task +- Type system doesn't help much +- Wrong tool for the job + +--- + +## Next Steps + +To prototype pgTAP: +```bash +# Install pgTAP extension +psql -c "CREATE EXTENSION pgtap;" + +# Create first test +cat > tests/add_column_test.sql << 'EOF' +BEGIN; +SELECT plan(3); +\i release/cipherstash-encrypt.sql +-- ... tests here +SELECT * FROM finish(); +ROLLBACK; +EOF + +# Run it +pg_prove -v tests/add_column_test.sql +``` + +The question isn't "which language is best" - it's "which tool fits the job." For testing SQL, use SQL. diff --git a/rust-tests/Cargo.toml b/rust-tests/Cargo.toml new file mode 100644 index 0000000..637d0fe --- /dev/null +++ b/rust-tests/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "eql-sqlx-tests" +version = "0.1.0" +edition = "2021" + +[dependencies] +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "macros"] } +tokio = { version = "1", features = ["full"] } +anyhow = "1.0" +uuid = { version = "1.0", features = ["v4"] } +serde_json = "1.0" + +[dev-dependencies] +tokio-test = "0.4" diff --git a/rust-tests/README.md b/rust-tests/README.md new file mode 100644 index 0000000..3006eca --- /dev/null +++ b/rust-tests/README.md @@ -0,0 +1,93 @@ +# EQL SQLx Testing Demo + +This demonstrates a simpler approach to testing EQL using SQLx instead of the complex Rust component system. + +## Approach + +1. **Build SQL first** - Use existing bash build system to generate `release/cipherstash-encrypt.sql` +2. **Load in tests** - Each test loads the built SQL file into a fresh PostgreSQL database +3. **Test with SQLx** - Write Rust tests using SQLx's ergonomic API + +## Key Benefits + +- ✅ **Simple** - No complex type system, no component traits, no macros +- ✅ **Single source of truth** - Dependencies stay in SQL with `-- REQUIRE:` comments +- ✅ **Existing build works** - Bash script with tsort already resolves dependencies +- ✅ **Better test ergonomics** - SQLx provides type-safe queries and better error messages +- ✅ **Easy isolation** - Each test gets a fresh database + +## Comparison + +### Old Approach (Component System) +```rust +// 200+ lines of trait/macro machinery to get this: +let deps = AddColumn::collect_dependencies(); +for file in deps { + db.load(file).await; +} +``` + +### New Approach (SQLx + Bash Build) +```rust +// Just load the built release file: +let pool = setup_test_db().await; // Loads release/cipherstash-encrypt.sql +``` + +## Setup + +1. **Start PostgreSQL** (if not already running): + ```bash + mise run postgres:up + ``` + +2. **Build the SQL extension**: + ```bash + mise run build + ``` + This creates `release/cipherstash-encrypt.sql` with all dependencies resolved. + +3. **Run tests**: + ```bash + cd rust-tests + cargo test + ``` + +## How It Works + +### Build Time (Bash) +```bash +# tasks/build.sh already does this: +# 1. Parse -- REQUIRE: from SQL files +# 2. Build dependency graph +# 3. Topological sort with tsort +# 4. Concatenate in order -> release/cipherstash-encrypt.sql +``` + +### Test Time (Rust + SQLx) +```rust +// Load the pre-built SQL +let pool = setup_test_db().await; + +// Test specific functionality +let result = sqlx::query_scalar("SELECT eql_v2.add_column(...)") + .fetch_one(&pool) + .await?; + +assert!(result.get("tables").is_some()); +``` + +## What This Demonstrates + +1. **No need for Rust dependency resolution** - Bash + tsort already works +2. **Tests focus on functionality** - Not on component plumbing +3. **Simpler codebase** - Delete 200+ lines of trait/macro code +4. **Better DX** - SQLx error messages, type inference, connection pooling + +## Next Steps + +If this approach works: +- Keep SQL files with `-- REQUIRE:` comments +- Keep bash build system (`tasks/build.sh`) +- Delete the Rust component system +- Write more tests using this SQLx pattern +- Generate docs from SQL comments (not rustdoc) diff --git a/rust-tests/run-tests.sh b/rust-tests/run-tests.sh new file mode 100755 index 0000000..3790d74 --- /dev/null +++ b/rust-tests/run-tests.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ensure PostgreSQL is running +if ! pg_isready -h localhost -p 7432 -U cipherstash > /dev/null 2>&1; then + echo "❌ PostgreSQL not running on localhost:7432" + echo " Start it with: mise run postgres:up" + exit 1 +fi + +# Ensure release SQL is built +if [ ! -f "../release/cipherstash-encrypt.sql" ]; then + echo "📦 Building EQL release..." + (cd .. && mise run build) +fi + +echo "🧪 Running SQLx tests..." +cargo test "$@" diff --git a/rust-tests/src/lib.rs b/rust-tests/src/lib.rs new file mode 100644 index 0000000..b0e3cb6 --- /dev/null +++ b/rust-tests/src/lib.rs @@ -0,0 +1,52 @@ +use sqlx::{PgPool, postgres::PgPoolOptions}; +use std::path::Path; + +/// Create a test database with EQL extension loaded from release build +/// +/// This connects to a test PostgreSQL instance and loads the pre-built +/// SQL file, giving us a clean database for testing. +pub async fn setup_test_db() -> anyhow::Result { + // Connect to postgres to create test DB + let pool = PgPoolOptions::new() + .max_connections(5) + .connect("postgres://cipherstash:password@localhost:7432/postgres") + .await?; + + // Create a fresh test database + let test_db = format!("eql_test_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(&format!("CREATE DATABASE {}", test_db)) + .execute(&pool) + .await?; + + // Connect to the new database + let test_pool = PgPoolOptions::new() + .max_connections(5) + .connect(&format!("postgres://cipherstash:password@localhost:7432/{}", test_db)) + .await?; + + // Load the built EQL extension + let release_sql = std::fs::read_to_string("../release/cipherstash-encrypt.sql") + .expect("Release SQL file should exist. Run 'mise run build' first."); + + sqlx::raw_sql(&release_sql) + .execute(&test_pool) + .await?; + + Ok(test_pool) +} + +/// Drop the test database after tests complete +pub async fn cleanup_test_db(pool: PgPool, db_name: &str) -> anyhow::Result<()> { + pool.close().await; + + let admin_pool = PgPoolOptions::new() + .max_connections(1) + .connect("postgres://cipherstash:password@localhost:7432/postgres") + .await?; + + sqlx::query(&format!("DROP DATABASE IF EXISTS {} WITH (FORCE)", db_name)) + .execute(&admin_pool) + .await?; + + Ok(()) +} diff --git a/rust-tests/tests/add_column_test.rs b/rust-tests/tests/add_column_test.rs new file mode 100644 index 0000000..fb21ade --- /dev/null +++ b/rust-tests/tests/add_column_test.rs @@ -0,0 +1,152 @@ +use sqlx::PgPool; +use eql_sqlx_tests::setup_test_db; + +#[tokio::test] +async fn test_add_column_creates_configuration() { + // Setup: Load EQL from built release file + let pool = setup_test_db() + .await + .expect("Failed to setup test database"); + + // Create a test table with encrypted column + sqlx::query( + "CREATE TABLE users ( + id INTEGER, + email eql_v2_encrypted + )" + ) + .execute(&pool) + .await + .expect("Failed to create table"); + + // Execute: Call add_column function + let result = sqlx::query_scalar::<_, serde_json::Value>( + "SELECT eql_v2.add_column('users', 'email', 'text')" + ) + .fetch_one(&pool) + .await + .expect("Failed to call add_column"); + + // Assert: Configuration has expected structure + assert!(result.get("tables").is_some(), "Config should have 'tables' key"); + assert!(result.get("v").is_some(), "Config should have 'v' (version) key"); + + // Assert: Configuration was persisted + let stored_config = sqlx::query_scalar::<_, serde_json::Value>( + "SELECT data FROM public.eql_v2_configuration WHERE state = 'active'" + ) + .fetch_one(&pool) + .await + .expect("Should have active configuration"); + + assert!(stored_config.get("tables").is_some(), "Stored config should have 'tables'"); + + // Assert: Encrypted constraint was added + let constraint_exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'eql_v2_encrypted_check_email' + )" + ) + .fetch_one(&pool) + .await + .expect("Failed to check constraint"); + + assert!(constraint_exists, "Encrypted constraint should exist"); +} + +#[tokio::test] +async fn test_add_column_rejects_duplicate() { + let pool = setup_test_db() + .await + .expect("Failed to setup test database"); + + // Setup table + sqlx::query("CREATE TABLE users (id INTEGER, email eql_v2_encrypted)") + .execute(&pool) + .await + .expect("Failed to create table"); + + // First call succeeds + sqlx::query_scalar::<_, serde_json::Value>( + "SELECT eql_v2.add_column('users', 'email', 'text')" + ) + .fetch_one(&pool) + .await + .expect("First add_column should succeed"); + + // Second call should fail + let result = sqlx::query_scalar::<_, serde_json::Value>( + "SELECT eql_v2.add_column('users', 'email', 'text')" + ) + .fetch_one(&pool) + .await; + + assert!(result.is_err(), "Duplicate add_column should fail"); +} + +#[tokio::test] +async fn test_multiple_encrypted_columns() { + let pool = setup_test_db() + .await + .expect("Failed to setup test database"); + + // Create table with multiple encrypted columns + sqlx::query( + "CREATE TABLE users ( + id INTEGER, + email eql_v2_encrypted, + phone eql_v2_encrypted, + ssn eql_v2_encrypted + )" + ) + .execute(&pool) + .await + .expect("Failed to create table"); + + // Configure all three columns + for (column, cast_type) in [("email", "text"), ("phone", "text"), ("ssn", "text")] { + sqlx::query_scalar::<_, serde_json::Value>( + &format!("SELECT eql_v2.add_column('users', '{}', '{}')", column, cast_type) + ) + .fetch_one(&pool) + .await + .expect(&format!("Failed to add_column for {}", column)); + } + + // Verify all constraints exist + for column in ["email", "phone", "ssn"] { + let constraint_name = format!("eql_v2_encrypted_check_{}", column); + let exists = sqlx::query_scalar::<_, bool>( + &format!( + "SELECT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = '{}' + )", + constraint_name + ) + ) + .fetch_one(&pool) + .await + .expect("Failed to check constraint"); + + assert!(exists, "Constraint for {} should exist", column); + } + + // Verify configuration has all three columns + let config = sqlx::query_scalar::<_, serde_json::Value>( + "SELECT data FROM public.eql_v2_configuration WHERE state = 'active'" + ) + .fetch_one(&pool) + .await + .expect("Should have active configuration"); + + let tables = config.get("tables") + .and_then(|t| t.get("users")) + .and_then(|u| u.get("columns")) + .expect("Config should have users.columns"); + + assert!(tables.get("email").is_some(), "Config should have email column"); + assert!(tables.get("phone").is_some(), "Config should have phone column"); + assert!(tables.get("ssn").is_some(), "Config should have ssn column"); +}