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 intests/common/ - E2E tests: Shell-based tests in
sawabona-cli/tests/, real-DB lifecycle tests insawabona-api/tests/, full API E2E insawabona-core/tests/e2e_device_lifecycle_test.rs, and Stripe payment E2E insawabona-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¶
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)¶
Run specific test¶
Run with output¶
Coverage (per-crate)¶
Run benchmarks¶
Run benchmarks for specific package¶
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¶
test_gpv_dual_proof_with_real_db— Full lifecycle: insert product withrequire_gpv=true, create license, generate challenge, compute both HMAC and GPV proofs, verify both on the server side.test_hmac_only_product_in_db— Product withrequire_gpv=false: only HMAC proof is needed, GPV is skipped.test_gpv_proof_fails_wrong_key_with_db— Client computes GPV proof with a wrong license key; server rejects it.test_require_gpv_db_roundtrip— Verifies therequire_gpvboolean persists correctly through DB INSERT → SELECT (by ID and by slug).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:
- Ensure PostgreSQL is running (
pg_isready -h db -p 5432) - Run migrations:
cargo test --package sawabona-core --test gpv_db_integration_tests test_require_gpv_db_roundtrip -- --test-threads=1 - Create a product with GPV enabled via API:
- Verify the response includes
"require_gpv": true - Generate a challenge for a license bound to this product — response should include
"require_gpv": true - Submit proof without
gpv_prooffield — should return 400 (GPV proof missing) - 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(DevContainerdbservice) - Redis at
localhost:6379(install withsudo 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¶
test_revocation_cache_roundtrip_real_redis— RevocationCache.revoke() writes to Redis; is_revoked() reads it back.test_revocation_cache_ttl_expiry— Revocation cache entries expire after TTL (1-second TTL test).test_revoked_key_db_tombstone_roundtrip— RevokedKeyRepository.insert() + is_revoked() with real DB. Verifies idempotent inserts (ON CONFLICT DO NOTHING).test_revoked_key_all_hashes— all_hashes() returns SHA-256 hashes for bloom filter loading.test_bloom_filter_loaded_from_db— Bloom filter populated from revoked_keys table detects revoked keys with no false negatives.test_validate_active_license_revoked_in_redis— Active license in DB, revoked in Redis cache: validate() returns invalid.test_validate_active_license_not_in_redis— Active license not in Redis: validate() returns valid.test_validate_with_bloom_filter_catches_revoked— Bloom filter + DB tombstone catches revoked key even without Redis.test_full_revocation_pipeline_redis_bloom_db— All three layers (Redis + bloom filter + DB) with two licenses: one revoked, one valid.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¶
- Product + plan + license creation via real API (server-generated keys)
- 3 device activations — including the real machine's hardware fingerprint (SHA-256 of machine-id, CPU, hostname, kernel)
- Activation limit enforcement — 4th device correctly rejected with HTTP 403
- Re-validation of existing device — does NOT increment activation count
- License revocation — status changes, DB tombstone created, devices preserved
- Post-revocation rejection — challenge request for revoked license fails
- License portability — same real device activates on a NEW license after old one was revoked
- 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:
- PostgreSQL at
db:5432
Running the E2E Stripe test¶
What the test covers¶
- Stripe config verification — confirms
stripe_configwas auto-seeded from env vars - Product + plan creation via admin API
- Stripe product mapping — inserts
stripe_product_mappingsrow (stripe price → sawabona product) - Self-signed webhook — crafts
invoice.payment_succeededpayload, computes HMAC-SHA256 signature matching the StripeProvider's format - License auto-creation — webhook response includes
license_id, license is active with correctmax_activations - 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:
- Code Quality: Clippy and Rustfmt checks
- Unit Tests: All library and binary tests
- Integration Tests: Tests with database and Redis services
- Benchmarks: Performance regression detection
See .github/workflows/rust-ci.yml for details.
Best Practices¶
- Test Organization: Keep tests close to the code they test
- Naming: Use descriptive test names that explain what is being tested
- Isolation: Each test should be independent and not rely on other tests
- Fixtures: Use factory functions for consistent test data
- Assertions: Use specific assertions with clear error messages
- Mocking: Mock external dependencies to isolate units under test
- Properties: Use property-based tests for invariants and mathematical properties
- 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.