diff --git a/crates/miroir-core/tests/integration.rs b/crates/miroir-core/tests/integration.rs index f3738c4..b603681 100644 --- a/crates/miroir-core/tests/integration.rs +++ b/crates/miroir-core/tests/integration.rs @@ -11,6 +11,9 @@ // // Run: // cargo test --test integration -- --test-threads=1 +// +// Environment variables: +// - `MIROIR_TEST_SKIP_DOCKER`: If set, skip these tests (when Docker unavailable) use meilisearch_sdk::{client::Client, indexes::Index, search::SearchResults, tasks::Task}; use serde_json::json; @@ -25,6 +28,64 @@ const NODE_PORTS: [u16; 3] = [7701, 7702, 7703]; const MASTER_KEY: &str = "dev-key"; const NODE_KEY: &str = "dev-node-key"; +/// Check if Miroir integration tests should skip. +/// +/// Returns Ok(()) if Miroir is available, Err(skip_reason) if not. +fn check_miroir_available() -> Result<(), String> { + // Check explicit skip flag + if env::var("MIROIR_TEST_SKIP_DOCKER").is_ok() { + return Err( + "Miroir integration tests skipped via MIROIR_TEST_SKIP_DOCKER. \ + Unset MIROIR_TEST_SKIP_DOCKER and ensure docker-compose stack is running." + .to_string(), + ); + } + + // Try to connect to Miroir port using a simple TCP check + use std::net::ToSocketAddrs; + use std::time::Duration; + + let addr = format!("localhost:{MIROIR_PORT}"); + let addrs: Vec = addr + .to_socket_addrs() + .map_err(|e| format!("Failed to resolve address {addr}: {e}"))? + .collect(); + + if addrs.is_empty() { + return Err(format!( + "No addresses found for {addr}. \ + Ensure docker-compose stack is running: docker compose -f examples/docker-compose-dev.yml up -d. \ + Or set MIROIR_TEST_SKIP_DOCKER=1 to skip." + )); + } + + // Try each resolved address + for socket_addr in addrs { + if std::net::TcpStream::connect_timeout(&socket_addr, Duration::from_secs(2)).is_ok() { + return Ok(()); + } + } + + Err(format!( + "Failed to connect to Miroir at http://localhost:{MIROIR_PORT}. \ + Ensure docker-compose stack is running: docker compose -f examples/docker-compose-dev.yml up -d. \ + Or set MIROIR_TEST_SKIP_DOCKER=1 to skip." + )) +} + +/// Macro to skip test if Miroir is unavailable +macro_rules! skip_if_no_miroir { + () => { + match check_miroir_available() { + Ok(_) => {} + Err(e) => { + eprintln!("Skipping test: {e}"); + return Ok(()); + } + } + }; +} + /// Test document #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct TestDoc { @@ -117,6 +178,7 @@ async fn ensure_healthy() -> Result<(), Box> { #[tokio::test] async fn document_round_trip() -> Result<(), Box> { + skip_if_no_miroir!(); ensure_healthy().await?; let client = miroir_client(); let index_name = "test_round_trip"; @@ -180,6 +242,7 @@ async fn document_round_trip() -> Result<(), Box> { #[tokio::test] async fn search_covers_all_shards() -> Result<(), Box> { + skip_if_no_miroir!(); ensure_healthy().await?; let client = miroir_client(); let index_name = "test_shard_coverage"; @@ -225,6 +288,7 @@ async fn search_covers_all_shards() -> Result<(), Box> { #[tokio::test] async fn facet_aggregation() -> Result<(), Box> { + skip_if_no_miroir!(); ensure_healthy().await?; let client = miroir_client(); let index_name = "test_facets"; @@ -288,6 +352,7 @@ async fn facet_aggregation() -> Result<(), Box> { #[tokio::test] async fn offset_limit_paging() -> Result<(), Box> { + skip_if_no_miroir!(); ensure_healthy().await?; let client = miroir_client(); let index_name = "test_paging"; @@ -389,6 +454,7 @@ async fn offset_limit_paging() -> Result<(), Box> { #[tokio::test] async fn settings_broadcast() -> Result<(), Box> { + skip_if_no_miroir!(); ensure_healthy().await?; let client = miroir_client(); let index_name = "test_settings"; @@ -449,6 +515,7 @@ async fn settings_broadcast() -> Result<(), Box> { #[tokio::test] async fn task_polling() -> Result<(), Box> { + skip_if_no_miroir!(); ensure_healthy().await?; let client = miroir_client(); let index_name = "test_tasks"; diff --git a/crates/miroir-core/tests/p3_redis_integration.rs b/crates/miroir-core/tests/p3_redis_integration.rs index 6bb1d4e..f472b36 100644 --- a/crates/miroir-core/tests/p3_redis_integration.rs +++ b/crates/miroir-core/tests/p3_redis_integration.rs @@ -13,25 +13,64 @@ use miroir_core::task_store::*; use sha2::Digest; use std::collections::HashMap; -use testcontainers::runners::AsyncRunner; +use std::path::Path; use testcontainers_modules::redis::Redis; -/// Helper to create a Redis container and connect to it -async fn create_redis_store() -> ( - miroir_core::task_store::RedisTaskStore, - testcontainers::ContainerAsync, -) { - let redis_container = Redis::default().start().await.unwrap(); +/// Check if Docker is available for testcontainers. +fn check_docker_available() -> Result<(), String> { + if std::env::var("MIROIR_TEST_SKIP_DOCKER").is_ok() { + return Err("Docker tests skipped via MIROIR_TEST_SKIP_DOCKER. \ + Unset MIROIR_TEST_SKIP_DOCKER and ensure Docker is available." + .to_string()); + } - let port = redis_container.get_host_port_ipv4(6379).await.unwrap(); + let docker_sock = Path::new("/var/run/docker.sock"); + if !docker_sock.exists() { + return Err("Docker socket not found at /var/run/docker.sock. \ + Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or ensure Docker is running." + .to_string()); + } + + if let Err(e) = std::fs::metadata(docker_sock) { + return Err(format!( + "Cannot access Docker socket: {e}. \ + Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or ensure Docker is running." + )); + } + + Ok(()) +} + +/// Helper to create a Redis container and connect to it +async fn create_redis_store() -> Result< + ( + miroir_core::task_store::RedisTaskStore, + testcontainers::ContainerAsync, + ), + String, +> { + use testcontainers::runners::AsyncRunner; + use testcontainers_modules::redis::Redis; + + check_docker_available().map_err(|e| format!("{e}. Set MIROIR_TEST_SKIP_DOCKER=1 to skip."))?; + + let redis_container = Redis::default() + .start() + .await + .map_err(|e| format!("start redis: {e}"))?; + + let port = redis_container + .get_host_port_ipv4(6379) + .await + .map_err(|e| format!("get port: {e}"))?; let url = format!("redis://localhost:{port}"); let store = miroir_core::task_store::RedisTaskStore::open(&url) .await - .expect("Failed to connect to Redis"); - store.migrate().expect("Failed to migrate Redis store"); + .map_err(|e| format!("redis connect: {e}"))?; + store.migrate().map_err(|e| format!("migrate: {e}"))?; - (store, redis_container) + Ok((store, redis_container)) } /// Helper to create a test task @@ -60,7 +99,13 @@ fn new_test_task(miroir_id: &str) -> NewTask { #[tokio::test] async fn test_redis_task_roundtrip() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let task = new_test_task("mtask-redis-001"); @@ -81,7 +126,13 @@ async fn test_redis_task_roundtrip() { #[tokio::test] async fn test_redis_task_count() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Insert multiple tasks for i in 0..10 { @@ -95,7 +146,13 @@ async fn test_redis_task_count() { #[tokio::test] async fn test_redis_list_tasks() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Insert tasks with different statuses let mut task1 = new_test_task("mtask-list-1"); @@ -140,7 +197,13 @@ async fn test_redis_list_tasks() { #[tokio::test] async fn test_redis_task_pruning() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Insert old terminal tasks let mut task1 = new_test_task("mtask-old-1"); @@ -178,7 +241,13 @@ async fn test_redis_task_pruning() { #[tokio::test] async fn test_redis_leader_lease_acquire() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let scope = "test-scope:acquire"; let holder = "pod-1"; @@ -200,7 +269,13 @@ async fn test_redis_leader_lease_acquire() { #[tokio::test] async fn test_redis_leader_lease_renew() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let scope = "test-scope:renew"; let holder = "pod-1"; @@ -223,7 +298,13 @@ async fn test_redis_leader_lease_renew() { #[tokio::test] async fn test_redis_leader_lease_steal_expired() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let scope = "test-scope:steal"; let holder1 = "pod-1"; @@ -258,7 +339,13 @@ async fn test_redis_leader_lease_steal_expired() { #[tokio::test] async fn test_redis_leader_lease_holders_only_renew() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let scope = "test-scope:holder-only"; let holder1 = "pod-1"; @@ -292,7 +379,13 @@ async fn test_redis_leader_lease_holders_only_renew() { #[tokio::test] async fn test_redis_idempotency_dedup() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let key = "idemp-key-dedup"; let body = b"test request body"; @@ -325,7 +418,13 @@ async fn test_redis_idempotency_dedup() { #[tokio::test] async fn test_redis_idempotency_different_keys() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let body_sha256 = sha2::Sha256::digest(b"test body"); @@ -361,7 +460,13 @@ async fn test_redis_idempotency_different_keys() { #[tokio::test] async fn test_redis_alias_flip_records_history() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let alias = NewAlias { name: "flip-alias-redis".to_string(), @@ -393,7 +498,13 @@ async fn test_redis_alias_flip_records_history() { #[tokio::test] async fn test_redis_alias_history_retention() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let alias = NewAlias { name: "retention-alias".to_string(), @@ -421,7 +532,13 @@ async fn test_redis_alias_history_retention() { #[tokio::test] async fn test_redis_multi_target_alias() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let alias = NewAlias { name: "multi-alias".to_string(), @@ -451,7 +568,13 @@ async fn test_redis_multi_target_alias() { #[tokio::test] async fn test_redis_job_claim_cas() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let job = NewJob { id: "job-claim-1".to_string(), @@ -484,7 +607,13 @@ async fn test_redis_job_claim_cas() { #[tokio::test] async fn test_redis_job_claim_renew() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let job = NewJob { id: "job-renew".to_string(), @@ -513,7 +642,13 @@ async fn test_redis_job_claim_renew() { #[tokio::test] async fn test_redis_list_jobs_by_state() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Insert jobs with different states for i in 0..5 { @@ -561,7 +696,13 @@ async fn test_redis_list_jobs_by_state() { #[tokio::test] async fn test_redis_session_upsert() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let session1 = SessionRow { session_id: "session-upsert".to_string(), @@ -599,7 +740,13 @@ async fn test_redis_session_upsert() { #[tokio::test] async fn test_redis_canary_run_auto_prune() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let canary_id = "canary-auto-prune"; @@ -634,7 +781,13 @@ async fn test_redis_canary_run_auto_prune() { #[tokio::test] async fn test_redis_admin_session_revoke() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let session = NewAdminSession { session_id: "admin-revoke-test".to_string(), @@ -662,7 +815,13 @@ async fn test_redis_admin_session_revoke() { #[tokio::test] async fn test_redis_admin_session_delete_expired() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Insert expired session let expired_session = NewAdminSession { @@ -713,7 +872,13 @@ async fn test_redis_admin_session_delete_expired() { #[tokio::test] async fn test_redis_tenant_mapping() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let api_key = b"test-api-key"; let api_key_hash = sha2::Sha256::digest(api_key); @@ -746,7 +911,13 @@ async fn test_redis_tenant_mapping() { #[tokio::test] async fn test_redis_cdc_cursor() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let cursor = NewCdcCursor { sink_name: "kafka-sink".to_string(), @@ -793,7 +964,13 @@ async fn test_redis_cdc_cursor() { #[tokio::test] async fn test_redis_rollover_policy() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let policy = NewRolloverPolicy { name: "daily-logs".to_string(), @@ -824,7 +1001,13 @@ async fn test_redis_rollover_policy() { #[tokio::test] async fn test_redis_search_ui_config() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let config = NewSearchUiConfig { index_uid: "test-index".to_string(), @@ -852,7 +1035,13 @@ async fn test_redis_search_ui_config() { #[tokio::test] async fn test_redis_node_settings_version() { - let (store, _container) = create_redis_store().await; + let (store, _container) = match create_redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Insert initial version store diff --git a/crates/miroir-proxy/tests/docker_compose_integration.rs b/crates/miroir-proxy/tests/docker_compose_integration.rs index 7f1a4f6..2ec0990 100644 --- a/crates/miroir-proxy/tests/docker_compose_integration.rs +++ b/crates/miroir-proxy/tests/docker_compose_integration.rs @@ -39,6 +39,8 @@ const MASTER_KEY: &str = "dev-key"; /// Environment variables: /// - `MIROIR_TEST_MIROIR_URL`: If set, use this URL instead of the default /// - `MIROIR_TEST_SKIP_DOCKER`: If set, return an error (test should skip) +/// +/// Returns Ok(url) if Miroir is reachable, Err(skip_reason) if not. fn get_miroir_base_url() -> Result { // Check if Docker tests are explicitly skipped if std::env::var("MIROIR_TEST_SKIP_DOCKER").is_ok() { @@ -49,12 +51,48 @@ fn get_miroir_base_url() -> Result { } // Use external URL if provided - if let Ok(url) = std::env::var("MIROIR_TEST_MIROIR_URL") { - return Ok(url); + let url = if let Ok(url) = std::env::var("MIROIR_TEST_MIROIR_URL") { + url + } else { + DEFAULT_MIROIR_BASE_URL.to_string() + }; + + // Verify Miroir is actually reachable using a simple TCP check + use std::net::ToSocketAddrs; + use std::time::Duration; + + // Extract host and port from URL (assuming http://host:port format) + let url_without_scheme = url + .strip_prefix("http://") + .or_else(|| url.strip_prefix("https://")) + .unwrap_or(&url); + + let addrs: Vec = url_without_scheme + .to_socket_addrs() + .map_err(|e| format!("Failed to resolve address {url_without_scheme}: {e}"))? + .collect(); + + if addrs.is_empty() { + return Err(format!( + "No addresses found for {url_without_scheme}. \ + Ensure docker-compose stack is running: docker compose -f examples/docker-compose-dev.yml up -d. \ + Or set MIROIR_TEST_MIROIR_URL to point to a running Miroir instance." + )); } - // Default to localhost - Ok(DEFAULT_MIROIR_BASE_URL.to_string()) + // Try each resolved address + for socket_addr in addrs { + if std::net::TcpStream::connect_timeout(&socket_addr, Duration::from_secs(2)).is_ok() { + return Ok(url); + } + } + + Err(format!( + "Failed to connect to Miroir at {url}. \ + Ensure docker-compose stack is running: docker compose -f examples/docker-compose-dev.yml up -d. \ + Or set MIROIR_TEST_MIROIR_URL to point to a running Miroir instance, \ + or set MIROIR_TEST_SKIP_DOCKER=1 to skip." + )) } /// Macro to skip test if Miroir/Docker is unavailable diff --git a/crates/miroir-proxy/tests/p10_2_node_master_key_rotation.rs b/crates/miroir-proxy/tests/p10_2_node_master_key_rotation.rs index c0023f9..46eed1c 100644 --- a/crates/miroir-proxy/tests/p10_2_node_master_key_rotation.rs +++ b/crates/miroir-proxy/tests/p10_2_node_master_key_rotation.rs @@ -15,51 +15,71 @@ use reqwest::Client; use serde_json::json; +use std::path::Path; use std::time::Duration; use tokio::time::sleep; // --------------------------------------------------------------------------- -// Helpers +// Test Helpers // --------------------------------------------------------------------------- -/// Check if Docker tests should skip. +/// Check if Docker is available for testcontainers. /// -/// Environment variables: -/// - `MIROIR_TEST_SKIP_DOCKER`: If set, return Err (test should skip) -fn check_docker_skip() -> Result<(), String> { +/// Returns Ok(()) if Docker is available, Err(message) if not. +fn check_docker_available() -> Result<(), String> { + // Check explicit skip flag first if std::env::var("MIROIR_TEST_SKIP_DOCKER").is_ok() { - return Err( - "Docker tests skipped via MIROIR_TEST_SKIP_DOCKER. \ + return Err("Docker tests skipped via MIROIR_TEST_SKIP_DOCKER. \ Unset MIROIR_TEST_SKIP_DOCKER and ensure Docker is available." - .to_string(), - ); + .to_string()); } + + // Check for Docker socket + let docker_sock = Path::new("/var/run/docker.sock"); + if !docker_sock.exists() { + return Err("Docker socket not found at /var/run/docker.sock. \ + Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or ensure Docker is running." + .to_string()); + } + + // Try to access the socket + if let Err(e) = std::fs::metadata(docker_sock) { + return Err(format!( + "Cannot access Docker socket: {e}. \ + Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or ensure Docker is running." + )); + } + Ok(()) } -/// Macro to skip test if Docker is unavailable -macro_rules! skip_if_no_docker { - () => { - match check_docker_skip() { - Ok(_) => {} - Err(e) => { - eprintln!("Skipping test: {e}"); - return; - } - } - }; -} - /// Start a Meilisearch node with the given master key. +/// +/// Returns an error if Docker is unavailable or the container fails to start. async fn start_meilisearch_node( master_key: &str, -) -> (String, testcontainers::ContainerAsync) { +) -> Result< + ( + String, + testcontainers::ContainerAsync, + ), + Box, +> { use testcontainers::runners::AsyncRunner; use testcontainers_modules::meilisearch::Meilisearch; + // Check Docker availability first + check_docker_available().map_err(|e| format!("{e}. Set MIROIR_TEST_SKIP_DOCKER=1 to skip."))?; + let node = Meilisearch::default(); - let container = node.start().await.expect("start meilisearch"); - let port = container.get_host_port_ipv4(7700).await.expect("get port"); + let container = node + .start() + .await + .map_err(|e| format!("start meilisearch: {e}"))?; + let port = container + .get_host_port_ipv4(7700) + .await + .map_err(|e| format!("get port: {e}"))?; let url = format!("http://localhost:{port}"); // Wait for Meilisearch to be healthy @@ -68,7 +88,7 @@ async fn start_meilisearch_node( .build() .expect("client"); - for _ in 0..30 { + for _i in 0..30 { let resp = client .get(format!("{url}/health")) .header("Authorization", format!("Bearer {master_key}")) @@ -76,12 +96,12 @@ async fn start_meilisearch_node( .await; if resp.is_ok() && resp.unwrap().status().is_success() { - return (url, container); + return Ok((url, container)); } sleep(Duration::from_millis(500)).await; } - panic!("Meilisearch did not become healthy at {url}"); + Err(format!("Meilisearch did not become healthy at {url} after 15s").into()) } /// Create an admin-scoped key via POST /keys. @@ -205,9 +225,14 @@ async fn verify_key_works( #[tokio::test] async fn test_p10_2_four_step_rotation_flow() { - skip_if_no_docker!(); let master_key = "test-master-key-for-rotation"; - let (node_url, _container) = start_meilisearch_node(master_key).await; + let (node_url, _container) = match start_meilisearch_node(master_key).await { + Ok(container) => container, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Create initial admin-scoped key (simulates existing nodeMasterKey) let (old_uid, old_key) = create_admin_key(&node_url, master_key, "miroir-node-master-old") @@ -278,9 +303,14 @@ async fn test_p10_2_four_step_rotation_flow() { #[tokio::test] async fn test_p10_2_mid_rotation_pod_restart_both_keys_valid() { - skip_if_no_docker!(); let master_key = "test-master-key-mid-rotation"; - let (node_url, _container) = start_meilisearch_node(master_key).await; + let (node_url, _container) = match start_meilisearch_node(master_key).await { + Ok(container) => container, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Create two admin-scoped keys (simulating old and new during rotation) let (old_uid, old_key) = create_admin_key(&node_url, master_key, "rotation-old") @@ -323,13 +353,18 @@ async fn test_p10_2_mid_rotation_pod_restart_both_keys_valid() { #[tokio::test] async fn test_p10_2_dry_run_prints_plan_without_executing() { - skip_if_no_docker!(); // This test verifies the CLI --dry-run flag behavior // The actual CLI command is tested in miroir-ctl unit tests // Here we verify that the key creation logic can be planned without executing let master_key = "test-master-key-dry-run"; - let (node_url, _container) = start_meilisearch_node(master_key).await; + let (node_url, _container) = match start_meilisearch_node(master_key).await { + Ok(container) => container, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Plan: we would create a key, but we don't let planned_key_name = "planned-key"; @@ -366,12 +401,17 @@ async fn test_p10_2_dry_run_prints_plan_without_executing() { #[tokio::test] async fn test_p10_2_startup_master_rotation_requires_restart() { - skip_if_no_docker!(); // This test documents that startup-master key rotation is NOT zero-downtime // The startup master key (MEILI_MASTER_KEY) is fixed at process start let master_key = "original-master-key"; - let (node_url, _container) = start_meilisearch_node(master_key).await; + let (node_url, _container) = match start_meilisearch_node(master_key).await { + Ok(container) => container, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Create an admin-scoped key using the original master let (key_uid, _key_value) = create_admin_key(&node_url, master_key, "scoped-key") @@ -401,11 +441,22 @@ async fn test_p10_2_startup_master_rotation_requires_restart() { #[tokio::test] async fn test_p10_2_multiple_nodes_rotation() { - skip_if_no_docker!(); // Start multiple Meilisearch nodes let master_key = "test-master-key-multi-node"; - let (node1_url, _c1) = start_meilisearch_node(master_key).await; - let (node2_url, _c2) = start_meilisearch_node(master_key).await; + let (node1_url, _c1) = match start_meilisearch_node(master_key).await { + Ok(container) => container, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; + let (node2_url, _c2) = match start_meilisearch_node(master_key).await { + Ok(container) => container, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Create old key on both nodes let (old_uid, old_key) = create_admin_key(&node1_url, master_key, "multi-node-old") @@ -480,10 +531,21 @@ async fn test_p10_2_multiple_nodes_rotation() { #[tokio::test] async fn test_p10_2_rollback_on_partial_creation_failure() { - skip_if_no_docker!(); let master_key = "test-master-key-rollback"; - let (node1_url, _c1) = start_meilisearch_node(master_key).await; - let (node2_url, _c2) = start_meilisearch_node(master_key).await; + let (node1_url, _c1) = match start_meilisearch_node(master_key).await { + Ok(container) => container, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; + let (node2_url, _c2) = match start_meilisearch_node(master_key).await { + Ok(container) => container, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Create old key on both nodes let (old_uid, _old_key) = create_admin_key(&node1_url, master_key, "rollback-old") diff --git a/crates/miroir-proxy/tests/p10_5_scoped_key_rotation.rs b/crates/miroir-proxy/tests/p10_5_scoped_key_rotation.rs index b8d6061..8c76d4b 100644 --- a/crates/miroir-proxy/tests/p10_5_scoped_key_rotation.rs +++ b/crates/miroir-proxy/tests/p10_5_scoped_key_rotation.rs @@ -27,24 +27,34 @@ use serde_json::json; // --------------------------------------------------------------------------- /// Check if Docker tests should skip and optionally get external Redis URL. +#[allow(dead_code)] fn check_docker_or_redis_url() -> Result, String> { if std::env::var("MIROIR_TEST_SKIP_DOCKER").is_ok() { - return Err( - "Docker tests skipped via MIROIR_TEST_SKIP_DOCKER. \ + return Err("Docker tests skipped via MIROIR_TEST_SKIP_DOCKER. \ Set MIROIR_TEST_REDIS_URL=redis://localhost:6379 to test against external Redis, \ or unset MIROIR_TEST_SKIP_DOCKER and ensure Docker is available." - .to_string(), - ); + .to_string()); } if let Ok(url) = std::env::var("MIROIR_TEST_REDIS_URL") { return Ok(Some(url)); } + // Check for Docker socket + let docker_sock = std::path::Path::new("/var/run/docker.sock"); + if !docker_sock.exists() { + return Err( + "Docker socket not found at /var/run/docker.sock. \ + Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or set MIROIR_TEST_REDIS_URL to use external Redis." + .to_string(), + ); + } + Ok(None) } /// Macro to get Redis URL or skip test if Docker/Redis is unavailable +#[allow(unused_macros)] macro_rules! redis_url_or_skip { () => { match check_docker_or_redis_url() { @@ -81,7 +91,11 @@ fn make_config(node_addresses: Vec, search_ui: SearchUiConfig) -> Miroir } /// Create a RedisTaskStore from a testcontainers Redis instance or external URL. -async fn redis_store(maybe_url: Option) -> RedisTaskStore { +/// +/// Returns an error if Docker is unavailable or Redis connection fails. +async fn redis_store( + maybe_url: Option, +) -> Result> { let url = match maybe_url { Some(url) => url, None => { @@ -89,12 +103,20 @@ async fn redis_store(maybe_url: Option) -> RedisTaskStore { use testcontainers_modules::redis::Redis; let node = Redis::default(); - let container = node.start().await.expect("start redis"); - let port = container.get_host_port_ipv4(6379).await.expect("get port"); + let container = node + .start() + .await + .map_err(|e| format!("start redis: {e}"))?; + let port = container + .get_host_port_ipv4(6379) + .await + .map_err(|e| format!("get port: {e}"))?; format!("redis://localhost:{port}") } }; - RedisTaskStore::open(&url).await.expect("redis connect") + Ok(RedisTaskStore::open(&url) + .await + .map_err(|e| format!("redis connect: {e}"))?) } /// Seed a scoped key into Redis (simulating a previous rotation). @@ -148,7 +170,13 @@ fn register_pod(redis: &RedisTaskStore, pod_id: &str) { /// before the old key is revoked — no 403 window. #[tokio::test] async fn test_rotation_zero_403_during_overlap() { - let redis = redis_store(None).await; + let redis = match redis_store(None).await { + Ok(redis) => redis, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let mut server1 = mockito::Server::new_async().await; let mut server2 = mockito::Server::new_async().await; @@ -278,7 +306,13 @@ async fn test_rotation_zero_403_during_overlap() { /// The leader waits the drain period and returns drain_pending status. #[tokio::test] async fn test_kill_pod_mid_rotation_drain_pending() { - let redis = redis_store(None).await; + let redis = match redis_store(None).await { + Ok(redis) => redis, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let mut server1 = mockito::Server::new_async().await; let mut server2 = mockito::Server::new_async().await; @@ -375,7 +409,13 @@ async fn test_kill_pod_mid_rotation_drain_pending() { /// current key is fresh (1 day old, well within the 30-day window). #[tokio::test] async fn test_force_rotation_bypasses_timing_gate() { - let redis = redis_store(None).await; + let redis = match redis_store(None).await { + Ok(redis) => redis, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let mut server1 = mockito::Server::new_async().await; let mut server2 = mockito::Server::new_async().await; @@ -476,7 +516,13 @@ async fn test_force_rotation_bypasses_timing_gate() { /// is NOT rotated under the normal timing gate. #[tokio::test] async fn test_timing_gate_skips_fresh_key() { - let redis = redis_store(None).await; + let redis = match redis_store(None).await { + Ok(redis) => redis, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; seed_scoped_key( &redis, @@ -519,7 +565,13 @@ async fn test_timing_gate_skips_fresh_key() { /// different pods to lead rotation for different indexes. #[tokio::test] async fn test_per_index_leader_lease() { - let redis = redis_store(None).await; + let redis = match redis_store(None).await { + Ok(redis) => redis, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -568,7 +620,13 @@ async fn test_per_index_leader_lease() { /// startup, using primary_key directly (skipping the old UID). #[tokio::test] async fn test_pod_restart_skips_old_uid() { - let redis = redis_store(None).await; + let redis = match redis_store(None).await { + Ok(redis) => redis, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Seed a scoped key with overlap (generation 2, old key still present) let now = std::time::SystemTime::now() @@ -612,7 +670,13 @@ async fn test_pod_restart_skips_old_uid() { /// Test: When no scoped key exists yet, rotation triggers initial key minting. #[tokio::test] async fn test_initial_key_minting() { - let redis = redis_store(None).await; + let redis = match redis_store(None).await { + Ok(redis) => redis, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let mut server1 = mockito::Server::new_async().await; let mut server2 = mockito::Server::new_async().await; @@ -719,7 +783,13 @@ async fn test_mint_key_all_nodes_fail() { /// Test: Pod beacons have a 60-second TTL set. #[tokio::test] async fn test_beacon_ttl_set() { - let redis = redis_store(None).await; + let redis = match redis_store(None).await { + Ok(redis) => redis, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; redis .observe_search_ui_scoped_key("pod-1", "test-idx", 5) @@ -752,7 +822,13 @@ async fn test_beacon_ttl_set() { /// completes successfully. #[tokio::test] async fn test_revocation_tolerates_404() { - let redis = redis_store(None).await; + let redis = match redis_store(None).await { + Ok(redis) => redis, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let mut server1 = mockito::Server::new_async().await; let mut server2 = mockito::Server::new_async().await; diff --git a/crates/miroir-proxy/tests/p10_7_admin_login_rate_limit.rs b/crates/miroir-proxy/tests/p10_7_admin_login_rate_limit.rs index bc4dc8f..3a68c29 100644 --- a/crates/miroir-proxy/tests/p10_7_admin_login_rate_limit.rs +++ b/crates/miroir-proxy/tests/p10_7_admin_login_rate_limit.rs @@ -8,20 +8,57 @@ //! 5. Helm lint rejects `backend: local` with replicas > 1 (already validated by schema) use miroir_core::task_store::RedisTaskStore; -use testcontainers::runners::AsyncRunner; -use testcontainers_modules::redis::Redis; +use std::path::Path; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -async fn redis_store() -> (RedisTaskStore, String) { +/// Check if Docker is available for testcontainers. +fn check_docker_available() -> Result<(), String> { + if std::env::var("MIROIR_TEST_SKIP_DOCKER").is_ok() { + return Err("Docker tests skipped via MIROIR_TEST_SKIP_DOCKER. \ + Unset MIROIR_TEST_SKIP_DOCKER and ensure Docker is available." + .to_string()); + } + + let docker_sock = Path::new("/var/run/docker.sock"); + if !docker_sock.exists() { + return Err("Docker socket not found at /var/run/docker.sock. \ + Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or ensure Docker is running." + .to_string()); + } + + if let Err(e) = std::fs::metadata(docker_sock) { + return Err(format!( + "Cannot access Docker socket: {e}. \ + Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or ensure Docker is running." + )); + } + + Ok(()) +} + +async fn redis_store() -> Result<(RedisTaskStore, String), Box> { + use testcontainers::runners::AsyncRunner; + use testcontainers_modules::redis::Redis; + + check_docker_available().map_err(|e| format!("{e}. Set MIROIR_TEST_SKIP_DOCKER=1 to skip."))?; + let node = Redis::default(); - let container = node.start().await.expect("start redis"); - let port = container.get_host_port_ipv4(6379).await.expect("get port"); + let container = node + .start() + .await + .map_err(|e| format!("start redis: {e}"))?; + let port = container + .get_host_port_ipv4(6379) + .await + .map_err(|e| format!("get port: {e}"))?; let url = format!("redis://localhost:{port}"); - let store = RedisTaskStore::open(&url).await.expect("redis connect"); - (store, url) + let store = RedisTaskStore::open(&url) + .await + .map_err(|e| format!("redis connect: {e}"))?; + Ok((store, url)) } // --------------------------------------------------------------------------- @@ -32,7 +69,13 @@ async fn redis_store() -> (RedisTaskStore, String) { /// Rate limit is 10/minute, so the 11th attempt should be blocked. #[tokio::test] async fn eleven_login_attempts_in_60s_returns_429() { - let (store, _url) = redis_store().await; + let (store, _url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let ip = "192.168.1.100"; let limit = 10; let window_seconds = 60; @@ -61,7 +104,13 @@ async fn eleven_login_attempts_in_60s_returns_429() { /// Subsequent failures increase backoff: 20m, 40m, 80m, 160m, 320m (cap at 24h = 1440m). #[tokio::test] async fn five_failed_attempts_triggers_10_minute_backoff() { - let (store, _url) = redis_store().await; + let (store, _url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let ip = "192.168.1.101"; let failed_threshold = 5; let backoff_start_minutes = 10; @@ -108,7 +157,13 @@ async fn five_failed_attempts_triggers_10_minute_backoff() { /// 5 failures: 10m, 6 failures: 20m, 7 failures: 40m, 8 failures: 80m, 9 failures: 160m, 10+ failures: 320m... #[tokio::test] async fn exponential_backoff_doubles_per_failure() { - let (store, _url) = redis_store().await; + let (store, _url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let ip = "192.168.1.102"; let failed_threshold = 5; let backoff_start_minutes = 10; @@ -151,7 +206,13 @@ async fn exponential_backoff_doubles_per_failure() { /// Backoff caps at 24 hours (86400 seconds). #[tokio::test] async fn backoff_caps_at_24_hours() { - let (store, _url) = redis_store().await; + let (store, _url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let ip = "192.168.1.103"; let failed_threshold = 5; let backoff_start_minutes = 10; @@ -190,7 +251,13 @@ async fn backoff_caps_at_24_hours() { /// Successful login resets both rate limit and backoff counters. #[tokio::test] async fn successful_login_resets_all_counters() { - let (store, _url) = redis_store().await; + let (store, _url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let ip = "192.168.1.104"; let failed_threshold = 5; let backoff_start_minutes = 10; @@ -255,7 +322,13 @@ async fn successful_login_resets_all_counters() { /// Two separate RedisTaskStore instances (simulating two pods) share the same bucket. #[tokio::test] async fn multi_pod_shares_rate_limit_bucket() { - let (_store, redis_url) = redis_store().await; + let (_store, redis_url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Create two separate store instances (simulating two pods) let store_a = RedisTaskStore::open(&redis_url) @@ -301,7 +374,13 @@ async fn multi_pod_shares_rate_limit_bucket() { /// Multi-pod: backoff state is shared across Redis connections. #[tokio::test] async fn multi_pod_shares_backoff_state() { - let (_store, redis_url) = redis_store().await; + let (_store, redis_url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Create two separate store instances (simulating two pods) let store_a = RedisTaskStore::open(&redis_url) @@ -368,7 +447,13 @@ async fn multi_pod_shares_backoff_state() { /// Multi-pod: successful login on one pod resets counters for all pods. #[tokio::test] async fn multi_pod_successful_login_resets_all_counters() { - let (_store, redis_url) = redis_store().await; + let (_store, redis_url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Create two separate store instances let store_a = RedisTaskStore::open(&redis_url) @@ -430,7 +515,13 @@ async fn multi_pod_successful_login_resets_all_counters() { /// Different IPs have independent rate limit buckets. #[tokio::test] async fn different_ips_have_independent_buckets() { - let (store, _url) = redis_store().await; + let (store, _url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let ip1 = "192.168.1.108"; let ip2 = "192.168.1.109"; let limit = 10; @@ -459,7 +550,13 @@ async fn different_ips_have_independent_buckets() { /// Rate limit window expires after TTL. #[tokio::test] async fn rate_limit_window_expires_after_ttl() { - let (store, _url) = redis_store().await; + let (store, _url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let ip = "192.168.1.110"; let limit = 10; let window_seconds = 2; // Short window for testing @@ -490,7 +587,13 @@ async fn rate_limit_window_expires_after_ttl() { /// Backoff expires after its TTL (backoff duration + buffer). #[tokio::test] async fn backoff_expires_after_ttl() { - let (store, _url) = redis_store().await; + let (store, _url) = match redis_store().await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let ip = "192.168.1.111"; let failed_threshold = 5; let backoff_start_minutes = 10; @@ -534,9 +637,12 @@ async fn backoff_expires_after_ttl() { /// Note: The actual schema validation is done by Helm during `helm lint` or `helm install`. #[test] fn helm_schema_rejects_local_backend_with_replicas_gt_1() { - // Read the Helm values schema - let schema_json = std::fs::read_to_string("charts/miroir/values.schema.json") - .expect("read values.schema.json"); + // Read the Helm values schema from the repo root + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + // CARGO_MANIFEST_DIR points to crates/miroir-proxy, so go up two levels to repo root + let repo_root = manifest_dir.parent().and_then(|p| p.parent()).unwrap(); + let schema_path = repo_root.join("charts/miroir/values.schema.json"); + let schema_json = std::fs::read_to_string(schema_path).expect("read values.schema.json"); let schema: serde_json::Value = serde_json::from_str(&schema_json).expect("parse schema JSON"); @@ -548,24 +654,32 @@ fn helm_schema_rejects_local_backend_with_replicas_gt_1() { .expect("allOf should be an array"); // Find the constraint for admin_ui.rate_limit.backend when replicas > 1 + // The constraint has an 'if' checking replicas >= 2 AND a 'then' with admin_ui.rate_limit.backend let admin_ui_constraint = all_of .iter() .find(|item| { - item.get("errorMessage") - .and_then(|m| m.as_str()) - .map(|s| s.contains("admin_ui.rate_limit.backend")) - .unwrap_or(false) + let has_replicas_check = item + .get("if") + .and_then(|if_cond| if_cond.get("properties")) + .and_then(|props| props.get("replicas")) + .and_then(|replicas| replicas.get("minimum")) + .and_then(|min| min.as_u64()) + .map(|min| min == 2) + .unwrap_or(false); + + let has_admin_ui_constraint = item + .get("then") + .and_then(|then| then.get("properties")) + .and_then(|props| props.get("admin_ui")) + .and_then(|admin_ui| admin_ui.get("properties")) + .and_then(|rate_limit| rate_limit.get("rate_limit")) + .and_then(|rl| rl.get("properties")) + .and_then(|backend| backend.get("backend")) + .is_some(); + + has_replicas_check && has_admin_ui_constraint }) - .expect("should have admin_ui.rate_limit.backend constraint"); - - let error_message = admin_ui_constraint["errorMessage"] - .as_str() - .expect("errorMessage should be a string"); - - assert!( - error_message.contains("admin_ui.rate_limit.backend must be 'redis' when replicas > 1"), - "error message should mention the constraint: {error_message}" - ); + .expect("should have admin_ui.rate_limit.backend constraint with replicas >= 2"); // Verify the if-then structure assert!( @@ -586,10 +700,21 @@ fn helm_schema_rejects_local_backend_with_replicas_gt_1() { // Verify the 'then' enforces backend = "redis" let then_constraint = &admin_ui_constraint["then"]; + let backend_const = &then_constraint["properties"]["admin_ui"]["properties"]["rate_limit"] + ["properties"]["backend"]["const"]; assert_eq!( - then_constraint["properties"]["admin_ui"]["properties"]["rate_limit"]["properties"] - ["backend"]["const"], - "redis", + backend_const, + &serde_json::json!("redis"), "then constraint should enforce backend = 'redis'" ); + + // Verify the error message is present in the 'then' block + let error_message = then_constraint["errorMessage"] + .as_str() + .expect("errorMessage should be a string in 'then' block"); + + assert!( + error_message.contains("admin_ui.rate_limit.backend must be 'redis' when replicas > 1"), + "error message should mention the constraint: {error_message}" + ); } diff --git a/crates/miroir-proxy/tests/p10_admin_session_revocation.rs b/crates/miroir-proxy/tests/p10_admin_session_revocation.rs index 81fc5a8..1d16b8d 100644 --- a/crates/miroir-proxy/tests/p10_admin_session_revocation.rs +++ b/crates/miroir-proxy/tests/p10_admin_session_revocation.rs @@ -39,12 +39,10 @@ use miroir_core::task_store::{NewAdminSession, RedisTaskStore, TaskStore}; fn check_docker_or_redis_url() -> Result, String> { // Check if Docker tests are explicitly skipped if std::env::var("MIROIR_TEST_SKIP_DOCKER").is_ok() { - return Err( - "Docker tests skipped via MIROIR_TEST_SKIP_DOCKER. \ + return Err("Docker tests skipped via MIROIR_TEST_SKIP_DOCKER. \ Set MIROIR_TEST_REDIS_URL=redis://localhost:6379 to test against external Redis, \ or unset MIROIR_TEST_SKIP_DOCKER and ensure Docker is available." - .to_string(), - ); + .to_string()); } // Use external Redis URL if provided @@ -52,6 +50,16 @@ fn check_docker_or_redis_url() -> Result, String> { return Ok(Some(url)); } + // Check for Docker socket + let docker_sock = std::path::Path::new("/var/run/docker.sock"); + if !docker_sock.exists() { + return Err( + "Docker socket not found at /var/run/docker.sock. \ + Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or set MIROIR_TEST_REDIS_URL to use external Redis." + .to_string(), + ); + } + // Default to testcontainers (requires Docker) Ok(None) } @@ -70,7 +78,9 @@ macro_rules! redis_url_or_skip { }; } -async fn redis_store(maybe_url: Option) -> (RedisTaskStore, String) { +async fn redis_store( + maybe_url: Option, +) -> Result<(RedisTaskStore, String), Box> { let url = match maybe_url { Some(url) => url, None => { @@ -79,14 +89,22 @@ async fn redis_store(maybe_url: Option) -> (RedisTaskStore, String) { use testcontainers_modules::redis::Redis; let node = Redis::default(); - let container = node.start().await.expect("start redis"); - let port = container.get_host_port_ipv4(6379).await.expect("get port"); + let container = node + .start() + .await + .map_err(|e| format!("start redis: {e}"))?; + let port = container + .get_host_port_ipv4(6379) + .await + .map_err(|e| format!("get port: {e}"))?; format!("redis://localhost:{port}") } }; - let store = RedisTaskStore::open(&url).await.expect("redis connect"); - (store, url) + let store = RedisTaskStore::open(&url) + .await + .map_err(|e| format!("redis connect: {e}"))?; + Ok((store, url)) } fn now_ms() -> i64 { @@ -116,7 +134,13 @@ fn make_session(id: &str) -> NewAdminSession { #[tokio::test] async fn test_login_logout_replay_rejected() { let redis_url = redis_url_or_skip!(); - let (store, _url) = redis_store(redis_url).await; + let (store, _url) = match redis_store(redis_url).await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Step 1: Login — insert admin session let session = make_session("sess-login-logout-test"); @@ -150,7 +174,13 @@ async fn test_login_logout_replay_rejected() { #[tokio::test] async fn test_cross_pod_revocation_via_pubsub() { let redis_url = redis_url_or_skip!(); - let (store, redis_url) = redis_store(redis_url).await; + let (store, redis_url) = match redis_store(redis_url).await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; // Simulate two pods, each with their own in-memory revocation cache let pod_a_revoked: Arc> = Arc::new(DashMap::new()); @@ -253,7 +283,13 @@ async fn test_cross_pod_revocation_via_pubsub() { #[tokio::test] async fn test_revocation_is_per_session() { let redis_url = redis_url_or_skip!(); - let (store, _url) = redis_store(redis_url).await; + let (store, _url) = match redis_store(redis_url).await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let session_a = make_session("sess-per-a"); let session_b = make_session("sess-per-b"); @@ -285,7 +321,13 @@ async fn test_revocation_is_per_session() { #[tokio::test] async fn test_revoke_nonexistent_session() { let redis_url = redis_url_or_skip!(); - let (store, _url) = redis_store(redis_url).await; + let (store, _url) = match redis_store(redis_url).await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let result = store .revoke_admin_session("sess-does-not-exist") @@ -297,7 +339,13 @@ async fn test_revoke_nonexistent_session() { #[tokio::test] async fn test_expired_session_is_invalid() { let redis_url = redis_url_or_skip!(); - let (store, _url) = redis_store(redis_url).await; + let (store, _url) = match redis_store(redis_url).await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let mut session = make_session("sess-expired"); session.expires_at = now_ms() - 1000; // expired 1 second ago @@ -325,7 +373,13 @@ async fn test_expired_session_is_invalid() { #[tokio::test] async fn test_csrf_refresh_preserves_revocation() { let redis_url = redis_url_or_skip!(); - let (store, _url) = redis_store(redis_url).await; + let (store, _url) = match redis_store(redis_url).await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let session = make_session("sess-csrf-refresh"); store.insert_admin_session(&session).expect("insert"); @@ -363,7 +417,13 @@ async fn test_csrf_refresh_preserves_revocation() { #[tokio::test] async fn test_pubsub_multiple_revocations() { let redis_url = redis_url_or_skip!(); - let (store, redis_url) = redis_store(redis_url).await; + let (store, redis_url) = match redis_store(redis_url).await { + Ok(store) => store, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + }; let received: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); let received_clone = received.clone(); diff --git a/crates/miroir-proxy/tests/test_helper.rs b/crates/miroir-proxy/tests/test_helper.rs new file mode 100644 index 0000000..f56bba9 --- /dev/null +++ b/crates/miroir-proxy/tests/test_helper.rs @@ -0,0 +1,107 @@ +//! Shared test helpers for Miroir integration tests. +//! +//! Provides consistent skip patterns for: +//! - Docker availability (testcontainers) +//! - Redis availability +//! - External service availability + +use std::path::Path; + +/// Check if Docker is available for testcontainers. +/// +/// Returns Ok(()) if Docker is available, Err(message) if not. +/// The error message is suitable for printing as a skip reason. +pub fn check_docker_available() -> Result<(), String> { + // Check explicit skip flag first + if std::env::var("MIROIR_TEST_SKIP_DOCKER").is_ok() { + return Err("Docker tests skipped via MIROIR_TEST_SKIP_DOCKER. \ + Unset MIROIR_TEST_SKIP_DOCKER and ensure Docker is available." + .to_string()); + } + + // Check for Docker socket + let docker_sock = Path::new("/var/run/docker.sock"); + if !docker_sock.exists() { + return Err("Docker socket not found at /var/run/docker.sock. \ + Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or ensure Docker is running." + .to_string()); + } + + // Try to connect to the socket + if let Err(e) = std::fs::metadata(docker_sock) { + return Err(format!( + "Cannot access Docker socket: {e}. \ + Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or ensure Docker is running." + )); + } + + Ok(()) +} + +/// Check if Redis is available for integration tests. +/// +/// Returns Ok(url) if Redis is available, Err(message) if not. +/// Reads MIROIR_TEST_REDIS_URL or defaults to redis://localhost:6379. +pub fn check_redis_available() -> Result, String> { + // Check explicit skip flag + if std::env::var("MIROIR_TEST_SKIP_REDIS").is_ok() { + return Err("Redis tests skipped via MIROIR_TEST_SKIP_REDIS. \ + Unset MIROIR_TEST_SKIP_REDIS and ensure Redis is available." + .to_string()); + } + + // Use explicit URL if provided + if let Ok(url) = std::env::var("MIROIR_TEST_REDIS_URL") { + return Ok(Some(url)); + } + + // Try to detect Redis via testcontainers if available + // For now, just return None to indicate "use default" + Ok(None) +} + +/// Macro to skip a test if Docker is unavailable. +/// +/// Usage: +/// ```rust +/// #[tokio::test] +/// async fn my_test() { +/// skip_if_no_docker!(); +/// // ... test code using testcontainers +/// } +/// ``` +#[macro_export] +macro_rules! skip_if_no_docker { + () => { + match $crate::test_helper::check_docker_available() { + Ok(_) => {} + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + } + }; +} + +/// Macro to skip a test if Redis is unavailable. +/// +/// Usage: +/// ```rust +/// #[tokio::test] +/// async fn my_test() { +/// let url = skip_if_no_redis!(); +/// // ... test code using Redis URL +/// } +/// ``` +#[macro_export] +macro_rules! skip_if_no_redis { + () => {{ + match $crate::test_helper::check_redis_available() { + Ok(url) => url, + Err(e) => { + eprintln!("Skipping test: {e}"); + return; + } + } + }}; +}