From 8417d68dce66c1c8883412d9e818e41adfafc246 Mon Sep 17 00:00:00 2001 From: "Sergey B." Date: Wed, 6 May 2026 18:19:10 +0300 Subject: [PATCH] test(signer): isolate integration tests with TEST_DATABASE_URL --- .github/workflows/build-test-push.yaml | 1 + docs/DEVELOPMENT.md | 14 ++ signer/src/integration_test_db.rs | 141 ++++++++++++++++++++ signer/src/lib.rs | 3 + signer/src/signer_daemon.rs | 20 +-- signer/tests/client_pubkey_tests.rs | 30 ++--- signer/tests/permission_validation_tests.rs | 60 ++++----- 7 files changed, 196 insertions(+), 73 deletions(-) create mode 100644 signer/src/integration_test_db.rs diff --git a/.github/workflows/build-test-push.yaml b/.github/workflows/build-test-push.yaml index b28583a6..77236763 100644 --- a/.github/workflows/build-test-push.yaml +++ b/.github/workflows/build-test-push.yaml @@ -80,6 +80,7 @@ jobs: - name: Run tests env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/keycast_test + TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/keycast_test REDIS_URL: redis://localhost:16379 TEST_REDIS_URL: redis://localhost:16379 run: | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e7044e45..3b368402 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -48,6 +48,20 @@ cargo test --workspace # Unit tests ./tests/e2e/test-frontend.sh # E2E frontend tests ``` +### Signer and database integration tests (`keycast_signer`) + +`cargo test -p keycast_signer --features integration-tests` needs Postgres with migrations applied to a **dedicated** database (not your normal app DB). Start dependencies with `bun run deps:up`, then use `bun run test` or `bun run test:ci` for the full Rust integration flow, or migrate manually (see below). + +- **`TEST_DATABASE_URL`** (recommended): connection string for that database, e.g. `postgres://postgres:password@localhost/keycast_test`. +- If you only set **`DATABASE_URL`**, the database name must end with `_test` (so `.../keycast` is rejected). +- If neither is set, tests default to `postgres://postgres:password@localhost/keycast_test`. + +Run migrations against the same URL before tests, for example: + +```bash +sqlx migrate run --database-url "$TEST_DATABASE_URL" --source database/migrations +``` + **Production testing:** ```bash API_URL=https://login.divine.video ./tests/integration/test-api.sh diff --git a/signer/src/integration_test_db.rs b/signer/src/integration_test_db.rs new file mode 100644 index 00000000..fafec4d7 --- /dev/null +++ b/signer/src/integration_test_db.rs @@ -0,0 +1,141 @@ +//! Postgres URL resolution and safety checks for signer integration tests. +//! +//! Prefer [`connect_pool`] so tests use an isolated database, not the normal app DB. +//! +//! - Set **`TEST_DATABASE_URL`** to a dedicated Postgres database (recommended). +//! - If only **`DATABASE_URL`** is set, the database name must end with `_test` so +//! accidental use of a `.../keycast` dev database is rejected. +//! - If neither is set, defaults to `postgres://postgres:password@localhost/keycast_test`. +//! +//! Does not run migrations; run `sqlx migrate run` (or project scripts) against the same URL first. +//! CI applies `database/migrations` before running tests. + +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use std::time::Duration; + +const DEFAULT_URL: &str = "postgres://postgres:password@localhost/keycast_test"; + +/// Resolves the connection string used by signer integration tests. +pub fn resolved_database_url() -> String { + let from_test_env = std::env::var("TEST_DATABASE_URL").ok(); + let from_database_env = std::env::var("DATABASE_URL").ok(); + let used_explicit_test_url = from_test_env.is_some(); + + let url = match (from_test_env, from_database_env) { + (Some(u), _) => u, + (None, Some(u)) => u, + (None, None) => DEFAULT_URL.to_string(), + }; + + assert_safe_for_integration_tests(&url); + + if !used_explicit_test_url { + assert_database_name_suggests_test_db(&url); + } + + url +} + +/// Validates that the URL points to a local database only (mirrors API integration test guards). +pub fn assert_safe_for_integration_tests(url: &str) { + let host_info = url + .split('@') + .nth(1) + .unwrap_or(url) + .split('/') + .next() + .unwrap_or("unknown"); + + let production_indicators = [ + "keycast-db", + "cloudsql", + "prod", + "130.211.", + "35.192.", + "35.188.", + "35.193.", + "34.66.", + "34.67.", + ".gcp.", + ".cloud.", + "rds.amazonaws", + "azure", + ]; + + let url_lower = url.to_lowercase(); + for indicator in production_indicators { + assert!( + !url_lower.contains(indicator), + "\n\n\ + ╔══════════════════════════════════════════════════════════════════╗\n\ + ║ REFUSING TO RUN: database URL appears to be a production DB ║\n\ + ║ ║\n\ + ║ Detected production indicator: {:<32} ║\n\ + ║ ║\n\ + ║ Tests must NEVER run against production databases. ║\n\ + ╚══════════════════════════════════════════════════════════════════╝\n\n", + indicator + ); + } + + let is_local = url_lower.contains("localhost") + || url_lower.contains("127.0.0.1") + || url_lower.contains("host.docker.internal") + || (host_info.contains("postgres") && !host_info.contains('.')); + + assert!( + is_local, + "\n\n\ + ╔══════════════════════════════════════════════════════════════════╗\n\ + ║ REFUSING TO RUN: database URL must point to a local database ║\n\ + ║ Host: {:<55} ║\n\ + ╚══════════════════════════════════════════════════════════════════╝\n\n", + host_info + ); +} + +/// When `TEST_DATABASE_URL` is not set, require a database name ending with `_test`. +fn assert_database_name_suggests_test_db(url: &str) { + let Some(name) = postgres_database_name(url) else { + panic!( + "Could not parse database name from URL; set TEST_DATABASE_URL explicitly.\n\ + URL (host only): {}", + url.split('@').nth(1).unwrap_or("?") + ); + }; + assert!( + name.ends_with("_test"), + "\n\n\ + Signer integration tests require an isolated database.\n\ + Database name `{name}` does not end with `_test`.\n\ + Set TEST_DATABASE_URL to a dedicated test database, or set DATABASE_URL to one whose name ends with `_test` (e.g. keycast_test).\n" + ); +} + +fn postgres_database_name(url: &str) -> Option<&str> { + let path = url.split('@').nth(1)?.split('/').nth(1)?; + let name = path.split('?').next()?; + if name.is_empty() { + None + } else { + Some(name) + } +} + +/// Connects after URL resolution and safety checks. Does not run migrations. +pub async fn connect_pool() -> PgPool { + let database_url = resolved_database_url(); + PgPoolOptions::new() + .max_connections(3) + .acquire_timeout(Duration::from_secs(60)) + .connect(&database_url) + .await + .unwrap_or_else(|e| { + panic!( + "Failed to connect for signer integration tests: {e}\n\ + URL resolved from TEST_DATABASE_URL / DATABASE_URL / default.\n\ + Ensure Postgres is running and migrations have been applied." + ); + }) +} diff --git a/signer/src/lib.rs b/signer/src/lib.rs index 333414dd..967e034f 100644 --- a/signer/src/lib.rs +++ b/signer/src/lib.rs @@ -5,6 +5,9 @@ pub mod error; pub mod signer_daemon; pub mod work_queue; +#[cfg(feature = "integration-tests")] +pub mod integration_test_db; + // Re-export main types for convenience pub use error::{SignerError, SignerResult}; pub use signer_daemon::{Nip46Handler, UnifiedSigner}; diff --git a/signer/src/signer_daemon.rs b/signer/src/signer_daemon.rs index c049df63..5ca9361c 100644 --- a/signer/src/signer_daemon.rs +++ b/signer/src/signer_daemon.rs @@ -1866,16 +1866,6 @@ impl UnifiedSigner { mod tests { use super::*; - /// Helper to create test database connection - /// Note: Requires DATABASE_URL env var or running postgres at localhost - /// CI runs migrations automatically, so we just need to connect - async fn create_test_db() -> PgPool { - let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://postgres:password@localhost/keycast_test".to_string()); - PgPool::connect(&database_url).await.unwrap() - } - - /// Helper to create test keys fn create_test_keys() -> Keys { Keys::generate() } @@ -1950,7 +1940,7 @@ mod tests { #[tokio::test] async fn test_sign_event_direct_creates_valid_signature() { // Arrange - let pool = create_test_db().await; + let pool = crate::integration_test_db::connect_pool().await; let handler = create_test_handler_with_db(pool).await; let unsigned_event = UnsignedEvent::new( @@ -1977,7 +1967,7 @@ mod tests { #[tokio::test] async fn test_sign_event_direct_preserves_tags() { // Arrange - let pool = create_test_db().await; + let pool = crate::integration_test_db::connect_pool().await; let handler = create_test_handler_with_db(pool).await; let tag1 = Tag::parse(vec!["e", "event_id_123"]).unwrap(); @@ -2012,7 +2002,7 @@ mod tests { // "Invalid signature" failure mode. #[tokio::test] async fn test_sign_event_direct_canonicalizes_mismatched_pubkey() { - let pool = create_test_db().await; + let pool = crate::integration_test_db::connect_pool().await; let handler = create_test_handler_with_db(pool).await; let stale_keys = Keys::generate(); @@ -2049,7 +2039,7 @@ mod tests { #[tokio::test] async fn test_get_handler_for_user_returns_none_when_not_cached() { // Arrange - let pool = create_test_db().await; + let pool = crate::integration_test_db::connect_pool().await; let key_manager: Box = Box::new(keycast_core::encryption::file_key_manager::FileKeyManager::new().unwrap()); let (_tx, rx) = tokio::sync::mpsc::channel(100); @@ -2078,7 +2068,7 @@ mod tests { #[tokio::test] async fn test_handlers_clone_shares_cache() { // Arrange - let pool = create_test_db().await; + let pool = crate::integration_test_db::connect_pool().await; let key_manager: Box = Box::new(keycast_core::encryption::file_key_manager::FileKeyManager::new().unwrap()); let (_tx, rx) = tokio::sync::mpsc::channel(100); diff --git a/signer/tests/client_pubkey_tests.rs b/signer/tests/client_pubkey_tests.rs index f9a43b34..1ec77c35 100644 --- a/signer/tests/client_pubkey_tests.rs +++ b/signer/tests/client_pubkey_tests.rs @@ -8,24 +8,12 @@ use keycast_core::encryption::{file_key_manager::FileKeyManager, KeyManager}; use keycast_core::signing_handler::SigningHandler; use keycast_core::types::oauth_authorization::OAuthAuthorization; -use keycast_signer::Nip46Handler; +use keycast_signer::{integration_test_db, Nip46Handler}; use nostr_sdk::prelude::*; use serde_json::json; use sqlx::PgPool; use uuid::Uuid; -/// Helper to create test database with schema -async fn setup_test_db() -> PgPool { - let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://postgres:password@localhost/keycast".to_string()); - - let pool = PgPool::connect(&database_url) - .await - .expect("Failed to connect to database. Make sure PostgreSQL is running."); - - pool -} - /// Helper to create OAuth authorization for testing client pubkey tracking async fn create_oauth_authorization_for_client_test( pool: &PgPool, @@ -102,7 +90,7 @@ async fn create_oauth_authorization_for_client_test( // ============================================================================ #[tokio::test] async fn test_connect_stores_client_pubkey() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); let tenant_id = 1; @@ -155,7 +143,7 @@ async fn test_connect_stores_client_pubkey() { // ============================================================================ #[tokio::test] async fn test_connect_rejects_reused_secret() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); let tenant_id = 1; @@ -204,7 +192,7 @@ async fn test_connect_rejects_reused_secret() { // ============================================================================ #[tokio::test] async fn test_same_client_can_reconnect() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); let tenant_id = 1; @@ -238,7 +226,7 @@ async fn test_same_client_can_reconnect() { // ============================================================================ #[tokio::test] async fn test_request_from_connected_client_succeeds() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); let tenant_id = 1; @@ -283,7 +271,7 @@ async fn test_request_from_connected_client_succeeds() { // ============================================================================ #[tokio::test] async fn test_request_from_unknown_client_rejected() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); let tenant_id = 1; @@ -330,7 +318,7 @@ async fn test_request_from_unknown_client_rejected() { // ============================================================================ #[tokio::test] async fn test_first_request_without_connect_allowed() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); let tenant_id = 1; @@ -391,7 +379,7 @@ async fn test_first_request_without_connect_allowed() { // ============================================================================ #[tokio::test] async fn test_revocation_clears_client_pubkey() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); let tenant_id = 1; @@ -450,7 +438,7 @@ async fn test_revocation_clears_client_pubkey() { // ============================================================================ #[tokio::test] async fn test_connected_at_timestamp_set() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); let tenant_id = 1; diff --git a/signer/tests/permission_validation_tests.rs b/signer/tests/permission_validation_tests.rs index ae915542..e4415ede 100644 --- a/signer/tests/permission_validation_tests.rs +++ b/signer/tests/permission_validation_tests.rs @@ -8,26 +8,12 @@ use keycast_core::encryption::{file_key_manager::FileKeyManager, KeyManager}; use keycast_core::signing_handler::SigningHandler; use keycast_core::types::authorization::Authorization; use keycast_core::types::oauth_authorization::OAuthAuthorization; -use keycast_signer::Nip46Handler; +use keycast_signer::{integration_test_db, Nip46Handler}; use nostr_sdk::prelude::*; use serde_json::json; use sqlx::PgPool; use uuid::Uuid; -/// Helper to create test database with schema -async fn setup_test_db() -> PgPool { - // Use development database for tests - // TODO: Use test-specific database with isolation - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set to run database tests"); - - let pool = PgPool::connect(&database_url).await.expect( - "Failed to connect to database. Make sure PostgreSQL is running and DATABASE_URL is set.", - ); - - pool -} - /// Helper to create policy with specified permissions /// Returns (policy_id, team_id). async fn create_policy_with_permissions( @@ -228,7 +214,7 @@ async fn create_oauth_authorization( #[tokio::test] async fn test_1_no_policy_allows_all() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create empty policy (no permissions) @@ -261,7 +247,7 @@ async fn test_1_no_policy_allows_all() { #[tokio::test] async fn test_2_allowed_kinds_permits_matching_kind() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create policy allowing only kind 1 @@ -296,7 +282,7 @@ async fn test_2_allowed_kinds_permits_matching_kind() { #[tokio::test] async fn test_3_allowed_kinds_denies_non_matching_kind() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create policy allowing only kind 1 @@ -337,7 +323,7 @@ async fn test_3_allowed_kinds_denies_non_matching_kind() { #[tokio::test] async fn test_4_content_filter_allows_clean_content() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Block words containing "spam" @@ -370,7 +356,7 @@ async fn test_4_content_filter_allows_clean_content() { #[tokio::test] async fn test_5_content_filter_denies_blocked_words() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Block words containing "spam" @@ -406,7 +392,7 @@ async fn test_5_content_filter_denies_blocked_words() { #[tokio::test] async fn test_6_multiple_permissions_all_must_pass() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Policy with TWO permissions (AND logic): @@ -463,7 +449,7 @@ async fn test_6_multiple_permissions_all_must_pass() { #[tokio::test] async fn test_7_oauth_no_policy_allows_all() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // OAuth auth with NULL policy_id @@ -494,7 +480,7 @@ async fn test_7_oauth_no_policy_allows_all() { #[tokio::test] async fn test_8_oauth_with_policy_enforces_restrictions() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create policy only allowing kind 1 @@ -530,7 +516,7 @@ async fn test_8_oauth_with_policy_enforces_restrictions() { #[tokio::test] async fn test_8b_permission_loading_failure_returns_error() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Start with a valid policy, then attach a malformed permission config row. @@ -669,7 +655,7 @@ async fn create_oauth_authorization_with_expiry( #[tokio::test] async fn test_9_expired_oauth_authorization_not_loaded() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create OAuth authorization that expired 1 hour ago @@ -700,7 +686,7 @@ async fn test_9_expired_oauth_authorization_not_loaded() { #[tokio::test] async fn test_10_non_expired_oauth_authorization_loads() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create OAuth authorization that expires in 1 hour (still valid) @@ -731,7 +717,7 @@ async fn test_10_non_expired_oauth_authorization_loads() { #[tokio::test] async fn test_11_null_expiry_oauth_authorization_loads() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create OAuth authorization with NULL expires_at (never expires) @@ -761,7 +747,7 @@ async fn test_11_null_expiry_oauth_authorization_loads() { #[tokio::test] async fn test_12_revoked_oauth_authorization_not_loaded() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create OAuth authorization (not expired) @@ -872,7 +858,7 @@ async fn create_team_authorization_with_expiry( #[tokio::test] async fn test_13_expired_team_authorization_not_loaded() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create team authorization that expired 1 hour ago @@ -901,7 +887,7 @@ async fn test_13_expired_team_authorization_not_loaded() { #[tokio::test] async fn test_14_non_expired_team_authorization_loads() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create team authorization that expires in 1 hour (still valid) @@ -930,7 +916,7 @@ async fn test_14_non_expired_team_authorization_loads() { #[tokio::test] async fn test_15_null_expiry_team_authorization_loads() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Create team authorization with NULL expires_at (never expires) @@ -958,7 +944,7 @@ async fn test_15_null_expiry_team_authorization_loads() { #[tokio::test] async fn test_16_decrypt_only_policy_denies_encrypt_allows_decrypt() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // decrypt_only: can_decrypt=true, can_encrypt=false @@ -1004,7 +990,7 @@ async fn test_16_decrypt_only_policy_denies_encrypt_allows_decrypt() { /// Mirrors test_16 but exercises the `is_oauth=true` path that loads via OAuthAuthorization::find. #[tokio::test] async fn test_16b_oauth_decrypt_only_policy_denies_encrypt_allows_decrypt() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); let (policy_id, _team_id) = @@ -1047,7 +1033,7 @@ async fn test_16b_oauth_decrypt_only_policy_denies_encrypt_allows_decrypt() { #[tokio::test] async fn test_17_encrypt_to_self_policy_denies_decrypt_from_others() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // encrypt_to_self requires sender_pubkey == recipient_pubkey @@ -1084,7 +1070,7 @@ async fn test_17_encrypt_to_self_policy_denies_decrypt_from_others() { /// allow the operation. Locks in the `permissions.is_empty() → Ok(())` fallback branch. #[tokio::test] async fn test_18_no_permissions_allows_encrypt_and_decrypt() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // Policy with zero linked permissions. @@ -1125,7 +1111,7 @@ async fn test_18_no_permissions_allows_encrypt_and_decrypt() { /// containing blocked words denied. This test pins the behavior. #[tokio::test] async fn test_19_content_filter_blocks_encrypt_of_blocked_plaintext() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); let (policy_id, team_id) = create_policy_with_permissions( @@ -1178,7 +1164,7 @@ async fn test_19_content_filter_blocks_encrypt_of_blocked_plaintext() { /// See divinevideo/keycast#141 for the open question of whether this should fail closed. #[tokio::test] async fn test_20_oauth_invalid_policy_id_allows_signing() { - let pool = setup_test_db().await; + let pool = integration_test_db::connect_pool().await; let key_manager = FileKeyManager::new().expect("Failed to create key manager"); // OAuth authorizations can reference a non-existent policy_id.