Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-test-push.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
14 changes: 14 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions signer/src/integration_test_db.rs
Original file line number Diff line number Diff line change
@@ -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."
);
})
}
3 changes: 3 additions & 0 deletions signer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
20 changes: 5 additions & 15 deletions signer/src/signer_daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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(
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<dyn KeyManager> =
Box::new(keycast_core::encryption::file_key_manager::FileKeyManager::new().unwrap());
let (_tx, rx) = tokio::sync::mpsc::channel(100);
Expand Down Expand Up @@ -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<dyn KeyManager> =
Box::new(keycast_core::encryption::file_key_manager::FileKeyManager::new().unwrap());
let (_tx, rx) = tokio::sync::mpsc::channel(100);
Expand Down
30 changes: 9 additions & 21 deletions signer/tests/client_pubkey_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading