use corteq::{JwtService, MemoryTenantCache, TenantCache, TenantContext, TenantDatabase};
use serial_test::serial;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
const ACME_TENANT_ID: &str = "11111111-1111-1111-1111-111111111111";
const TEST_TENANT_ID: &str = "22222222-2222-2222-2222-222222222222";
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()
})
}
async fn create_test_pool() -> PgPool {
PgPool::connect(&get_database_url())
.await
.expect("Failed to connect to test database")
}
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_security_sql_injection_in_tenant_context() {
let pool = create_test_pool().await;
let malicious_tenant_id = "11111111-1111-1111-1111-111111111111' OR '1'='1";
let result = Uuid::parse_str(malicious_tenant_id);
assert!(result.is_err(), "Malformed UUID should not parse");
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 injection_attempt = "' OR '1'='1' --";
let result =
sqlx::query_as::<_, (Uuid, String)>("SELECT id, title FROM documents WHERE title = $1")
.bind(injection_attempt)
.fetch_all(db.connection())
.await;
assert!(result.is_ok());
assert_eq!(
result.unwrap().len(),
0,
"Parameterized query should treat injection attempt as literal string"
);
let valid_docs: Vec<(Uuid, String)> =
sqlx::query_as("SELECT id, title FROM documents WHERE deleted_at IS NULL")
.fetch_all(db.connection())
.await
.expect("Valid query should work");
assert_eq!(valid_docs.len(), 2, "Should see 2 Acme documents");
db.rollback().await.expect("Failed to rollback");
}
#[tokio::test]
async fn test_security_malformed_tenant_id_uuid() {
let pool = create_test_pool().await;
let malformed_ids = vec![
"not-a-uuid",
"00000000-0000-0000-0000-000000000000", "ffffffff-ffff-ffff-ffff-ffffffffffff", "",
"12345",
"../../../etc/passwd",
];
for malformed in malformed_ids {
let parse_result = Uuid::parse_str(malformed);
if let Ok(uuid) = parse_result {
let tenant_ctx = create_tenant_context(uuid);
let mut db = TenantDatabase::begin(&pool, &tenant_ctx)
.await
.expect("Failed to begin transaction");
let docs: Vec<(Uuid,)> =
sqlx::query_as("SELECT id FROM documents WHERE deleted_at IS NULL")
.fetch_all(db.connection())
.await
.expect("Query should succeed");
assert_eq!(
docs.len(),
0,
"Non-existent tenant should see no documents: {malformed}"
);
db.rollback().await.expect("Failed to rollback");
} else {
assert!(
parse_result.is_err(),
"Malformed UUID should not parse: {malformed}"
);
}
}
}
#[tokio::test]
#[serial]
async fn test_security_tenant_context_isolation_between_transactions() {
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 acme_ctx = create_tenant_context(acme_tenant_id);
let mut acme_db = TenantDatabase::begin(&pool, &acme_ctx)
.await
.expect("Failed to begin Acme 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");
assert_eq!(acme_docs.len(), 2, "Acme should see 2 documents");
let test_ctx = create_tenant_context(test_tenant_id);
let mut test_db = TenantDatabase::begin(&pool, &test_ctx)
.await
.expect("Failed to begin Test 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 documents");
assert_eq!(test_docs.len(), 2, "Test should see 2 documents");
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)),
"Concurrent transactions should not see each other's data"
);
acme_db.commit().await.expect("Acme commit failed");
test_db.commit().await.expect("Test commit failed");
}
#[tokio::test]
async fn test_security_jwt_token_manipulation() {
let jwt_service = JwtService::new(b"test-secret-key");
let tenant_id = Uuid::parse_str(ACME_TENANT_ID).unwrap();
let user_id = Uuid::new_v4();
let valid_claims = corteq::auth::Claims {
sub: user_id,
tenant_id,
roles: vec!["user".to_string()],
exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp(),
iat: chrono::Utc::now().timestamp(),
};
let valid_token = jwt_service
.encode(&valid_claims)
.expect("Failed to encode token");
let malformed_tokens = vec![
"not.a.token",
"invalid",
"",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature",
];
for malformed in malformed_tokens {
let result = jwt_service.decode(malformed);
assert!(
result.is_err(),
"Malformed token should not decode: {malformed}"
);
}
let wrong_key_service = JwtService::new(b"different-secret-key");
let wrong_key_token = wrong_key_service
.encode(&valid_claims)
.expect("Failed to encode with wrong key");
let result = jwt_service.decode(&wrong_key_token);
assert!(
result.is_err(),
"Token signed with wrong key should not validate"
);
let expired_claims = corteq::auth::Claims {
sub: user_id,
tenant_id,
roles: vec!["user".to_string()],
exp: (chrono::Utc::now() - chrono::Duration::hours(1)).timestamp(), iat: (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp(),
};
let expired_token = jwt_service
.encode(&expired_claims)
.expect("Failed to encode expired token");
let result = jwt_service.decode(&expired_token);
assert!(result.is_err(), "Expired token should not validate");
let result = jwt_service.decode(&valid_token);
assert!(result.is_ok(), "Valid token should decode successfully");
}
#[tokio::test]
async fn test_security_cache_poisoning_attempt() {
let cache: Arc<dyn TenantCache> = Arc::new(MemoryTenantCache::default());
let acme_tenant_id = Uuid::parse_str(ACME_TENANT_ID).unwrap();
let test_tenant_id = Uuid::parse_str(TEST_TENANT_ID).unwrap();
let acme_ctx = TenantContext::new(
acme_tenant_id,
"acme-corp".to_string(),
"acme-key".to_string(),
);
let test_ctx = TenantContext::new(
test_tenant_id,
"test-tenant".to_string(),
"test-key".to_string(),
);
cache.set(acme_tenant_id, acme_ctx.clone()).await;
let result = cache.get(&test_tenant_id).await;
assert!(
result.is_none(),
"Cache should not return wrong tenant context"
);
let result = cache.get(&acme_tenant_id).await;
assert!(result.is_some());
let cached = result.unwrap();
assert_eq!(cached.tenant_id, acme_tenant_id);
assert_eq!(cached.tenant_slug, "acme-corp");
cache.set(test_tenant_id, test_ctx.clone()).await;
let acme_cached = cache.get(&acme_tenant_id).await.unwrap();
let test_cached = cache.get(&test_tenant_id).await.unwrap();
assert_ne!(acme_cached.tenant_id, test_cached.tenant_id);
assert_ne!(acme_cached.tenant_slug, test_cached.tenant_slug);
assert_ne!(acme_cached.encryption_key_id, test_cached.encryption_key_id);
}
#[tokio::test]
#[serial]
async fn test_security_concurrent_writes_no_cross_contamination() {
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 doc_id_1 = Uuid::new_v4();
let doc_id_2 = Uuid::new_v4();
let user_id = Uuid::new_v4();
let pool_clone = pool.clone();
let handle1 = tokio::spawn(async move {
let acme_ctx = create_tenant_context(acme_tenant_id);
let mut db = TenantDatabase::begin(&pool_clone, &acme_ctx)
.await
.expect("Failed to begin Acme transaction");
sqlx::query(
"INSERT INTO documents (id, tenant_id, title, content, created_by)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(doc_id_1)
.bind(acme_tenant_id)
.bind("Acme Concurrent Doc")
.bind("Content from concurrent write")
.bind(user_id)
.execute(db.connection())
.await
.expect("Failed to insert Acme document");
db.commit()
.await
.expect("Failed to commit Acme transaction");
});
let pool_clone = pool.clone();
let handle2 = tokio::spawn(async move {
let test_ctx = create_tenant_context(test_tenant_id);
let mut db = TenantDatabase::begin(&pool_clone, &test_ctx)
.await
.expect("Failed to begin Test transaction");
sqlx::query(
"INSERT INTO documents (id, tenant_id, title, content, created_by)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(doc_id_2)
.bind(test_tenant_id)
.bind("Test Concurrent Doc")
.bind("Content from concurrent write")
.bind(user_id)
.execute(db.connection())
.await
.expect("Failed to insert Test document");
db.commit()
.await
.expect("Failed to commit Test transaction");
});
handle1.await.expect("Handle 1 panicked");
handle2.await.expect("Handle 2 panicked");
let acme_ctx = create_tenant_context(acme_tenant_id);
let mut acme_db = TenantDatabase::begin(&pool, &acme_ctx)
.await
.expect("Failed to begin Acme verification");
let acme_doc: Option<(String,)> = sqlx::query_as("SELECT title FROM documents WHERE id = $1")
.bind(doc_id_1)
.fetch_optional(acme_db.connection())
.await
.expect("Failed to query Acme document");
assert!(acme_doc.is_some());
assert_eq!(acme_doc.unwrap().0, "Acme Concurrent Doc");
let wrong_doc: Option<(String,)> = sqlx::query_as("SELECT title FROM documents WHERE id = $1")
.bind(doc_id_2)
.fetch_optional(acme_db.connection())
.await
.expect("Query should succeed");
assert!(wrong_doc.is_none(), "Acme should not see Test document");
acme_db
.commit()
.await
.expect("Failed to commit verification");
let acme_ctx = create_tenant_context(acme_tenant_id);
let mut acme_cleanup = TenantDatabase::begin(&pool, &acme_ctx).await.unwrap();
sqlx::query("DELETE FROM documents WHERE id = $1")
.bind(doc_id_1)
.execute(acme_cleanup.connection())
.await
.ok();
acme_cleanup.commit().await.ok();
let test_ctx = create_tenant_context(test_tenant_id);
let mut test_cleanup = TenantDatabase::begin(&pool, &test_ctx).await.unwrap();
sqlx::query("DELETE FROM documents WHERE id = $1")
.bind(doc_id_2)
.execute(test_cleanup.connection())
.await
.ok();
test_cleanup.commit().await.ok();
}
#[tokio::test]
async fn test_security_zero_uuid_tenant_id() {
let pool = create_test_pool().await;
let zero_uuid = Uuid::nil();
let tenant_ctx = create_tenant_context(zero_uuid);
let mut db = TenantDatabase::begin(&pool, &tenant_ctx)
.await
.expect("Failed to begin transaction");
let docs: Vec<(Uuid,)> = sqlx::query_as("SELECT id FROM documents WHERE deleted_at IS NULL")
.fetch_all(db.connection())
.await
.expect("Query should succeed");
assert_eq!(docs.len(), 0, "Nil UUID tenant should see no documents");
db.rollback().await.expect("Failed to rollback");
}
#[tokio::test]
#[serial]
async fn test_security_transaction_rollback_does_not_leak_data() {
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");
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("Rollback Test Document")
.bind("This should not persist")
.bind(user_id)
.execute(db.connection())
.await
.expect("Insert should succeed");
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(), "Document should be visible in transaction");
db.rollback().await.expect("Failed to rollback");
let mut verify_db = TenantDatabase::begin(&pool, &tenant_ctx)
.await
.expect("Failed to begin verification transaction");
let doc_after_rollback: Option<(String,)> =
sqlx::query_as("SELECT title FROM documents WHERE id = $1")
.bind(new_doc_id)
.fetch_optional(verify_db.connection())
.await
.expect("Query should succeed");
assert!(
doc_after_rollback.is_none(),
"Document should not exist after rollback"
);
verify_db
.commit()
.await
.expect("Failed to commit verification");
}
#[tokio::test]
async fn test_security_empty_tenant_context_denies_access() {
let pool = create_test_pool().await;
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 (default deny)"
);
}
#[tokio::test]
async fn test_security_session_variable_isolation() {
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 current_tenant: Option<(String,)> =
sqlx::query_as("SELECT current_setting('app.current_tenant_id', true)")
.fetch_optional(db.connection())
.await
.expect("Query should succeed");
assert!(current_tenant.is_some());
assert_eq!(current_tenant.unwrap().0, acme_tenant_id.to_string());
db.commit().await.expect("Failed to commit");
let leaked_tenant: Option<(String,)> = sqlx::query_as(
"SELECT COALESCE(current_setting('app.current_tenant_id', true), '') AS tenant_id",
)
.fetch_optional(&pool)
.await
.expect("Query should succeed");
assert!(leaked_tenant.is_some());
let tenant_value = leaked_tenant.unwrap().0;
assert!(
tenant_value.is_empty(),
"Tenant context should not leak across transactions, got: {tenant_value}"
);
}