Skip to content

Testing Guide

Version: 2.1 Last Updated: 2026-03-04 Architecture: Single-Tenant Rust

This guide documents the testing infrastructure and best practices for sawabona.

Test Overview (~581 tests)

Crate / Layer Tests Type
sawabona-core 440 Unit tests (models, crypto, services, DB, targets, challenge engine, GPV) + integration (revocation cache, GPV DB, E2E device lifecycle)
sawabona-api 97 30 unit + 67 integration (endpoints, auth middleware, real-DB lifecycle)
payment-stripe 18 wiremock-based provider tests
sawabona-cli 26 16 unit + 10 shell E2E (real DB)

Test Organization

The testing infrastructure is organized into four main categories:

  • Unit tests: Embedded in source files using #[cfg(test)] modules
  • Integration tests: Located in tests/ directories with shared utilities in tests/common/
  • E2E tests: Shell-based tests in sawabona-cli/tests/, real-DB lifecycle tests in sawabona-api/tests/, full API E2E in sawabona-core/tests/e2e_device_lifecycle_test.rs, and Stripe payment E2E in sawabona-core/tests/e2e_stripe_payment_test.rs
  • Benchmarks: Located in benches/ directories using Criterion
  • Property-based tests: Using proptest for invariant verification

Running Tests

Run all Rust tests

cargo test --workspace

Run per-crate tests

cargo test -p sawabona-core          # Core: 440 tests
cargo test -p sawabona-api           # API: 97 tests
cargo test -p sawabona-payment-stripe # Stripe: 18 tests
cargo test -p sawabona-cli           # CLI unit: 16 tests

Run CLI E2E tests (requires PostgreSQL)

./sawabona-cli/tests/cli_e2e_test.sh

Run specific test

cargo test test_name

Run with output

cargo test -- --nocapture

Coverage (per-crate)

cargo tarpaulin -p sawabona-core --skip-clean --out Stdout

Run benchmarks

cargo bench

Run benchmarks for specific package

cargo bench -p sawabona-core

Test Fixtures

The tests/common/fixtures.rs module provides factory functions for creating test data:

use common::fixtures::*;

let license = create_test_license();
let tenant = create_test_tenant();
let product = create_test_product();
let device = create_test_device();
let challenge = create_test_challenge();

Test Helpers

The tests/common/helpers.rs module provides assertion helpers:

use common::helpers::*;

// Floating-point comparison with epsilon
assert_approx_eq(1.0, 1.0000001, 1e-5);

// Default epsilon (1e-6)
assert_approx_eq_default(1.0, 1.0000001);

// Range assertions
assert_in_range(value, min, max);
assert_positive(value);
assert_non_negative(value);

// Test logging setup
setup_tracing();

Database Testing

Use TestDatabase for isolated database tests:

use common::db::TestDatabase;

#[tokio::test]
async fn test_with_database() {
    let db = TestDatabase::setup().await;
    let pool = db.pool();

    // ... test code ...

    db.teardown().await;
}

Mock Services

Mock implementations are available in tests/common/mocks.rs:

use common::mocks::*;

// Mock Redis client
let redis = MockRedisClient::new();
redis.set("key", "value", 3600).await.unwrap();

// Mock challenge storage
let storage = MockChallengeStorage::new();
storage.store("id", "challenge").await.unwrap();

Property-Based Testing

Use proptest for invariant testing:

use proptest::prelude::*;

proptest! {
    #[test]
    fn prop_area_positive(side in 0.1f64..100.0) {
        let square = Square::new(Point2D::new(0.0, 0.0), side);
        prop_assert!(square.area() > 0.0);
    }
}

Payment Provider Testing

Payment provider integrations require special testing considerations:

Mock Payment Providers

use common::mocks::*;

// Mock Stripe provider
let stripe = MockStripeProvider::new();
stripe.set_success_response(true);
let result = stripe.charge(100, "usd").await;
assert!(result.is_ok());

// Mock Paddle provider
let paddle = MockPaddleProvider::new();
paddle.set_webhook_secret("test_secret");
let verified = paddle.verify_webhook(payload, signature).await;
assert!(verified);

Webhook Testing

#[tokio::test]
async fn test_stripe_webhook_verification() {
    let payload = r#"{"type": "payment_intent.succeeded"}"#;
    let secret = "whsec_test";
    let signature = compute_signature(payload, secret);

    let result = verify_stripe_signature(payload, &signature, secret);
    assert!(result.is_ok());
}

Provider Health Checks

#[tokio::test]
async fn test_provider_health_check() {
    let provider = StripeProvider::new("sk_test_...");
    let health = provider.health_check().await;

    assert_eq!(health.status, "healthy");
    assert!(health.response_time_ms < 1000);
}

GPV (Geometric Proof Vector) Testing

GPV is an optional second proof factor alongside HMAC. When a product has require_gpv = true and the global config gpv.enabled = true, both proofs must pass during verification.

GPV test files

File Tests Description
sawabona-core/tests/gpv_dual_proof_tests.rs 6 Self-contained dual-proof logic (no DB)
sawabona-core/tests/gpv_db_integration_tests.rs 5 Full flow with real PostgreSQL

Running GPV tests

# Self-contained tests (no DB required)
cargo test --package sawabona-core --test gpv_dual_proof_tests

# Real-DB integration tests (requires PostgreSQL at db:5432)
cargo test --package sawabona-core --test gpv_db_integration_tests -- --test-threads=1

Note: DB integration tests use #[serial] and should run with --test-threads=1 to avoid table truncation conflicts.

What the DB integration tests cover

  1. test_gpv_dual_proof_with_real_db — Full lifecycle: insert product with require_gpv=true, create license, generate challenge, compute both HMAC and GPV proofs, verify both on the server side.
  2. test_hmac_only_product_in_db — Product with require_gpv=false: only HMAC proof is needed, GPV is skipped.
  3. test_gpv_proof_fails_wrong_key_with_db — Client computes GPV proof with a wrong license key; server rejects it.
  4. test_require_gpv_db_roundtrip — Verifies the require_gpv boolean persists correctly through DB INSERT → SELECT (by ID and by slug).
  5. test_mixed_gpv_products_in_db — Two products (GPV on/off) in the same DB; verifies per-product challenge flags and dual-proof for the GPV-enabled product.

GPV test procedure (manual)

To manually verify GPV wiring end-to-end:

  1. Ensure PostgreSQL is running (pg_isready -h db -p 5432)
  2. Run migrations: cargo test --package sawabona-core --test gpv_db_integration_tests test_require_gpv_db_roundtrip -- --test-threads=1
  3. Create a product with GPV enabled via API:
    curl -X POST http://localhost:8000/api/v1/products \
      -H "Content-Type: application/json" \
      -d '{"name":"GPV Product","slug":"gpv-prod","version":"1.0","require_gpv":true}'
    
  4. Verify the response includes "require_gpv": true
  5. Generate a challenge for a license bound to this product — response should include "require_gpv": true
  6. Submit proof without gpv_proof field — should return 400 (GPV proof missing)
  7. Submit proof with correct gpv_proof — should return 200

Revocation Cache Integration Tests

Real-object tests (NO mocks) for the full license revocation pipeline using real Redis + real PostgreSQL.

Test files

File Tests Description
sawabona-core/tests/revocation_cache_integration_tests.rs 10 Real Redis + real PostgreSQL revocation pipeline

Prerequisites

  • PostgreSQL at db:5432 (DevContainer db service)
  • Redis at localhost:6379 (install with sudo apt-get install redis-server)

Running revocation cache tests

# Requires both PostgreSQL and Redis running
cargo test --package sawabona-core --test revocation_cache_integration_tests -- --test-threads=1

What the revocation cache tests cover

  1. test_revocation_cache_roundtrip_real_redis — RevocationCache.revoke() writes to Redis; is_revoked() reads it back.
  2. test_revocation_cache_ttl_expiry — Revocation cache entries expire after TTL (1-second TTL test).
  3. test_revoked_key_db_tombstone_roundtrip — RevokedKeyRepository.insert() + is_revoked() with real DB. Verifies idempotent inserts (ON CONFLICT DO NOTHING).
  4. test_revoked_key_all_hashes — all_hashes() returns SHA-256 hashes for bloom filter loading.
  5. test_bloom_filter_loaded_from_db — Bloom filter populated from revoked_keys table detects revoked keys with no false negatives.
  6. test_validate_active_license_revoked_in_redis — Active license in DB, revoked in Redis cache: validate() returns invalid.
  7. test_validate_active_license_not_in_redis — Active license not in Redis: validate() returns valid.
  8. test_validate_with_bloom_filter_catches_revoked — Bloom filter + DB tombstone catches revoked key even without Redis.
  9. test_full_revocation_pipeline_redis_bloom_db — All three layers (Redis + bloom filter + DB) with two licenses: one revoked, one valid.
  10. test_concurrent_validation_with_redis — 10 concurrent validate() calls with real Redis: no data races.

E2E Device Lifecycle Test

Full end-to-end test against a real running Sawabona API server. Uses server-generated license keys, real hardware fingerprints, and the complete challenge-response-validate-device-binding pipeline.

Test file

File Tests Description
sawabona-core/tests/e2e_device_lifecycle_test.rs 1 (multi-step) Full device lifecycle against real API

Prerequisites

  • Sawabona API server running on port 8888: SAWABONA_SERVER__PORT=8888 cargo run --bin sawabona-api
  • PostgreSQL at db:5432

Running the E2E test

# Start the server first
SAWABONA_SERVER__PORT=8888 cargo run --bin sawabona-api &

# Run the test
cargo test --package sawabona-core --test e2e_device_lifecycle_test -- --nocapture --test-threads=1

What the E2E test covers

  1. Product + plan + license creation via real API (server-generated keys)
  2. 3 device activations — including the real machine's hardware fingerprint (SHA-256 of machine-id, CPU, hostname, kernel)
  3. Activation limit enforcement — 4th device correctly rejected with HTTP 403
  4. Re-validation of existing device — does NOT increment activation count
  5. License revocation — status changes, DB tombstone created, devices preserved
  6. Post-revocation rejection — challenge request for revoked license fails
  7. License portability — same real device activates on a NEW license after old one was revoked
  8. Multi-device on new license — both real device and emulated iPhone activate on new license

Device fingerprint computation

The test computes a real hardware fingerprint from the machine running the test:

let input = format!("{}:{}:{}:{}", machine_id, cpu_model, hostname, kernel_version);
let fingerprint = sha256_hex(input.as_bytes());

This ensures the test exercises the same fingerprint path that real devices use in production.

E2E Stripe Payment Test

Full end-to-end test proving the Stripe payment → webhook → license creation → device activation pipeline.

Test file

File Tests Description
sawabona-core/tests/e2e_stripe_payment_test.rs 1 (multi-step) Stripe payment → license → device activation

Prerequisites

  • Sawabona API server running on port 8888 with Stripe env vars:
    export SAWABONA_STRIPE_SECRET_KEY=sk_test_...
    export SAWABONA_STRIPE_WEBHOOK_SECRET=whsec_...
    export SAWABONA_ENCRYPTION_KEY=<base64-32-bytes>
    SAWABONA_SERVER__PORT=8888 cargo run --bin sawabona-api
    
  • PostgreSQL at db:5432

Running the E2E Stripe test

cargo test --package sawabona-core --test e2e_stripe_payment_test -- --nocapture

What the test covers

  1. Stripe config verification — confirms stripe_config was auto-seeded from env vars
  2. Product + plan creation via admin API
  3. Stripe product mapping — inserts stripe_product_mappings row (stripe price → sawabona product)
  4. Self-signed webhook — crafts invoice.payment_succeeded payload, computes HMAC-SHA256 signature matching the StripeProvider's format
  5. License auto-creation — webhook response includes license_id, license is active with correct max_activations
  6. Device activation — requests geometric proof challenge, computes proof, activates a device on the payment-created license

TestDatabase isolation note

TestDatabase::truncate_all() excludes provider config tables (stripe_config, paddle_config, etc.) to prevent wiping server-level configuration seeded on startup. This avoids cross-test-binary interference when integration tests and E2E tests share the same database.

Benchmarking

Benchmarks use Criterion and are located in benches/ directories:

# Run all benchmarks
cargo bench

# Run specific benchmark
cargo bench geometry_benchmarks

# View benchmark results
# Results are in target/criterion/

CI/CD Pipeline

The Rust CI pipeline runs:

  1. Code Quality: Clippy and Rustfmt checks
  2. Unit Tests: All library and binary tests
  3. Integration Tests: Tests with database and Redis services
  4. Benchmarks: Performance regression detection

See .github/workflows/rust-ci.yml for details.

Best Practices

  1. Test Organization: Keep tests close to the code they test
  2. Naming: Use descriptive test names that explain what is being tested
  3. Isolation: Each test should be independent and not rely on other tests
  4. Fixtures: Use factory functions for consistent test data
  5. Assertions: Use specific assertions with clear error messages
  6. Mocking: Mock external dependencies to isolate units under test
  7. Properties: Use property-based tests for invariants and mathematical properties
  8. Performance: Use benchmarks to detect performance regressions

Troubleshooting

Tests fail with database connection errors

Ensure PostgreSQL is running and accessible at the configured connection string.

Tests timeout

Increase the timeout or check for deadlocks in the test code.

Benchmark results vary significantly

Run benchmarks multiple times and check for system load variations.