Skip to main content

auth_framework/testing/
test_infrastructure.rs

1// test_infrastructure.rs - Bulletproof test isolation and container infrastructure
2
3use std::collections::HashMap;
4use std::env;
5use std::ffi::OsString;
6use std::sync::{Mutex, MutexGuard, OnceLock};
7
8/// Environment variable isolation for tests.
9///
10/// # Example
11/// ```rust,ignore
12/// let mut env = TestEnvironment::new();
13/// env.set_var("MY_VAR", "value");
14/// // Variable is restored on drop.
15/// ```
16pub struct TestEnvironment {
17    original_vars: HashMap<String, Option<OsString>>,
18    test_vars: HashMap<String, String>,
19}
20
21impl Default for TestEnvironment {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl TestEnvironment {
28    /// Create a new isolated test environment.
29    ///
30    /// # Example
31    /// ```rust,ignore
32    /// let env = TestEnvironment::new();
33    /// ```
34    pub fn new() -> Self {
35        Self {
36            original_vars: HashMap::new(),
37            test_vars: HashMap::new(),
38        }
39    }
40
41    /// Set an environment variable for this test only.
42    ///
43    /// # Example
44    /// ```rust,ignore
45    /// let mut env = TestEnvironment::new();
46    /// env.set_var("DATABASE_URL", "postgres://localhost/test");
47    /// ```
48    pub fn set_var(&mut self, key: &str, value: &str) {
49        // Store original value for restoration
50        if !self.original_vars.contains_key(key) {
51            self.original_vars.insert(key.to_string(), env::var_os(key));
52        }
53
54        // Set the test value
55        self.test_vars.insert(key.to_string(), value.to_string());
56        // SAFETY: Callers must hold the ENV_LOCK (via TestEnvironmentGuard) to
57        // serialize all env-var mutations, preventing data races with other threads.
58        unsafe {
59            env::set_var(key, value);
60        }
61    }
62
63    /// Set the standard JWT_SECRET for tests.
64    ///
65    /// # Example
66    /// ```rust,ignore
67    /// let env = TestEnvironment::new().with_jwt_secret("test-secret");
68    /// ```
69    pub fn with_jwt_secret(mut self, secret: &str) -> Self {
70        self.set_var("JWT_SECRET", secret);
71        self
72    }
73
74    /// Set database URL for integration tests.
75    ///
76    /// # Example
77    /// ```rust,ignore
78    /// let env = TestEnvironment::new().with_database_url("postgres://localhost/test");
79    /// ```
80    pub fn with_database_url(mut self, url: &str) -> Self {
81        self.set_var("DATABASE_URL", url);
82        self
83    }
84
85    /// Set Redis URL for session tests.
86    ///
87    /// # Example
88    /// ```rust,ignore
89    /// let env = TestEnvironment::new().with_redis_url("redis://localhost");
90    /// ```
91    pub fn with_redis_url(mut self, url: &str) -> Self {
92        self.set_var("REDIS_URL", url);
93        self
94    }
95}
96
97impl Drop for TestEnvironment {
98    /// Restore all environment variables to their original state
99    fn drop(&mut self) {
100        for (key, original_value) in &self.original_vars {
101            // SAFETY: Callers hold the ENV_LOCK (via TestEnvironmentGuard) which
102            // serializes all env-var mutations, preventing data races.
103            unsafe {
104                match original_value {
105                    Some(value) => env::set_var(key, value),
106                    None => env::remove_var(key),
107                }
108            }
109        }
110    }
111}
112
113/// Serializes all tests that mutate process-global environment variables.
114/// A `'static`-lifetime mutex is used so the `MutexGuard` can live in the struct.
115static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
116
117/// RAII guard for test environment isolation.
118///
119/// Acquires a process-wide mutex before touching any environment variable,
120/// so parallel test threads cannot race each other when setting/restoring
121/// `JWT_SECRET`, `RUST_TEST`, etc.
122///
123/// # Example
124/// ```rust,ignore
125/// let _env = TestEnvironmentGuard::new().with_jwt_secret("test-secret");
126/// // Environment is restored when `_env` is dropped.
127/// ```
128pub struct TestEnvironmentGuard {
129    /// Env-var state saved for restoration on drop. Drops FIRST (declaration order).
130    _env: TestEnvironment,
131    /// Serialization lock. Drops SECOND, releasing the mutex AFTER env vars are restored.
132    _lock: MutexGuard<'static, ()>,
133}
134
135impl Default for TestEnvironmentGuard {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl TestEnvironmentGuard {
142    pub fn new() -> Self {
143        // Acquire the serialization lock BEFORE touching any env var.
144        // This prevents parallel tests from interleaving set/restore operations
145        // on shared process-global environment variables (e.g. JWT_SECRET).
146        // The guard is held for the entire lifetime of `TestEnvironmentGuard`.
147        let _lock = ENV_LOCK
148            .get_or_init(|| Mutex::new(()))
149            .lock()
150            // If a previous test panicked while holding the lock the mutex is "poisoned".
151            // Recover the inner value so tests can still run cleanly.
152            .unwrap_or_else(|poisoned| poisoned.into_inner());
153
154        let mut env = TestEnvironment::new();
155        // Signal to the library that we are running in a test environment.
156        // This allows `is_test_environment()` in config validation to relax
157        // checks that would otherwise reject test-only secrets (e.g. short secrets).
158        env.set_var("RUST_TEST", "1");
159        Self { _env: env, _lock }
160    }
161
162    pub fn with_jwt_secret(mut self, secret: &str) -> Self {
163        self._env.set_var("JWT_SECRET", secret);
164        self
165    }
166
167    pub fn with_database_url(mut self, url: &str) -> Self {
168        self._env.set_var("DATABASE_URL", url);
169        self
170    }
171
172    pub fn with_redis_url(mut self, url: &str) -> Self {
173        self._env.set_var("REDIS_URL", url);
174        self
175    }
176
177    pub fn with_custom_var(mut self, key: &str, value: &str) -> Self {
178        self._env.set_var(key, value);
179        self
180    }
181}
182
183/// Container-based test infrastructure for complex integration tests
184#[cfg(feature = "docker-tests")]
185pub mod containers {
186    // use std::collections::HashMap; // Currently unused
187    use testcontainers::{ContainerAsync, GenericImage, ImageExt, runners::AsyncRunner};
188
189    pub struct TestDatabase {
190        _container: ContainerAsync<GenericImage>,
191        connection_string: String,
192    }
193
194    impl TestDatabase {
195        pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
196            let postgres_image = GenericImage::new("postgres", "14")
197                .with_env_var("POSTGRES_DB", "auth_test")
198                .with_env_var("POSTGRES_USER", "test_user")
199                .with_env_var("POSTGRES_PASSWORD", "test_password");
200
201            let container = postgres_image.start().await?;
202            let port = container.get_host_port_ipv4(5432).await?;
203
204            let connection_string = format!(
205                "postgresql://test_user:test_password@localhost:{}/auth_test",
206                port
207            );
208
209            Ok(Self {
210                _container: container,
211                connection_string,
212            })
213        }
214
215        pub fn connection_string(&self) -> &str {
216            &self.connection_string
217        }
218    }
219
220    pub struct TestRedis {
221        _container: ContainerAsync<GenericImage>,
222        connection_string: String,
223    }
224
225    impl TestRedis {
226        pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
227            let redis_image = GenericImage::new("redis", "7-alpine");
228
229            let container = redis_image.start().await?;
230            let port = container.get_host_port_ipv4(6379).await?;
231
232            let connection_string = format!("redis://localhost:{}", port);
233
234            Ok(Self {
235                _container: container,
236                connection_string,
237            })
238        }
239
240        pub fn connection_string(&self) -> &str {
241            &self.connection_string
242        }
243    }
244
245    /// Complete isolated test environment with containers
246    pub struct ContainerTestEnvironment {
247        pub database: TestDatabase,
248        pub redis: TestRedis,
249        pub env_guard: super::TestEnvironmentGuard,
250    }
251
252    impl ContainerTestEnvironment {
253        pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
254            let database = TestDatabase::new().await?;
255            let redis = TestRedis::new().await?;
256
257            let env_guard = super::TestEnvironmentGuard::new()
258                .with_jwt_secret("test-jwt-secret-for-container-tests")
259                .with_database_url(database.connection_string())
260                .with_redis_url(redis.connection_string());
261
262            Ok(Self {
263                database,
264                redis,
265                env_guard,
266            })
267        }
268    }
269}
270
271/// Test utilities for creating secure test data
272pub mod test_data {
273    use crate::storage::SessionData;
274    use crate::tokens::AuthToken;
275    use chrono::Utc;
276    use ring::rand::{SecureRandom, SystemRandom};
277
278    /// Generate cryptographically secure test data.
279    ///
280    /// # Example
281    /// ```rust,ignore
282    /// let token = test_data::secure_test_token("user-1");
283    /// assert_eq!(token.user_id, "user-1");
284    /// ```
285    pub fn secure_test_token(user_id: &str) -> AuthToken {
286        let rng = SystemRandom::new();
287        let mut token_bytes = [0u8; 32];
288        rng.fill(&mut token_bytes)
289            .expect("Failed to generate secure random token");
290
291        let token_id = hex::encode(token_bytes);
292        let access_token = hex::encode(&token_bytes[..16]);
293
294        AuthToken {
295            token_id,
296            user_id: user_id.to_string(),
297            access_token,
298            token_type: Some("bearer".to_string()),
299            subject: Some(user_id.to_string()),
300            issuer: Some("auth-framework-test".to_string()),
301            refresh_token: None,
302            issued_at: Utc::now(),
303            expires_at: Utc::now() + chrono::Duration::seconds(3600),
304            scopes: vec!["read".to_string(), "write".to_string()].into(),
305            auth_method: "test".to_string(),
306            client_id: Some("test-client".to_string()),
307            user_profile: None,
308            permissions: vec!["read:all".to_string(), "write:all".to_string()].into(),
309            roles: vec!["test_user".to_string()].into(),
310            metadata: crate::tokens::TokenMetadata::default(),
311        }
312    }
313
314    /// Generate secure test session.
315    ///
316    /// # Example
317    /// ```rust,ignore
318    /// let session = test_data::secure_test_session("user-1");
319    /// assert_eq!(session.user_id, "user-1");
320    /// ```
321    pub fn secure_test_session(user_id: &str) -> SessionData {
322        let rng = SystemRandom::new();
323        let mut session_bytes = [0u8; 32];
324        rng.fill(&mut session_bytes)
325            .expect("Failed to generate secure random session");
326
327        let session_id = hex::encode(session_bytes);
328
329        SessionData {
330            session_id,
331            user_id: user_id.to_string(),
332            created_at: Utc::now(),
333            last_activity: Utc::now(),
334            expires_at: Utc::now() + chrono::Duration::seconds(7200),
335            ip_address: Some("127.0.0.1".to_string()),
336            user_agent: Some("Test Agent".to_string()),
337            data: std::collections::HashMap::new(),
338        }
339    }
340
341    /// Generate secure random string for testing.
342    ///
343    /// # Example
344    /// ```rust,ignore
345    /// let s = test_data::secure_random_string(16);
346    /// assert_eq!(s.len(), 32); // hex-encoded, so 2x input length
347    /// ```
348    pub fn secure_random_string(length: usize) -> String {
349        let rng = SystemRandom::new();
350        let mut bytes = vec![0u8; length];
351        rng.fill(&mut bytes)
352            .expect("Failed to generate secure random bytes");
353        hex::encode(bytes)
354    }
355}
356
357/// Macros for simplified test environment setup
358#[macro_export]
359macro_rules! test_with_env {
360    ($test_name:ident, $jwt_secret:expr, $body:block) => {
361        #[tokio::test]
362        async fn $test_name() {
363            let _env = $crate::test_infrastructure::TestEnvironmentGuard::new()
364                .with_jwt_secret($jwt_secret);
365            $body
366        }
367    };
368}
369
370#[macro_export]
371macro_rules! test_with_containers {
372    ($test_name:ident, $body:block) => {
373        #[cfg(feature = "docker-tests")]
374        #[tokio::test]
375        async fn $test_name() {
376            let _test_env =
377                $crate::test_infrastructure::containers::ContainerTestEnvironment::new()
378                    .expect("Failed to setup container test environment");
379            $body
380        }
381    };
382}
383
384/// Ensure only one test that modifies global state runs at a time.
385///
386/// Reuses the same `ENV_LOCK` mutex that `TestEnvironmentGuard` uses,
387/// so explicit calls to this helper compose correctly with the guard.
388pub fn with_global_lock<F, R>(f: F) -> R
389where
390    F: FnOnce() -> R,
391{
392    let _guard = ENV_LOCK
393        .get_or_init(|| Mutex::new(()))
394        .lock()
395        .unwrap_or_else(|poisoned| poisoned.into_inner());
396    f()
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn test_environment_isolation() {
405        // Ensure environment variables are properly isolated
406        let original_value = env::var("TEST_VAR").ok();
407
408        {
409            let mut test_env = TestEnvironment::new();
410            test_env.set_var("TEST_VAR", "test_value");
411            assert_eq!(env::var("TEST_VAR").unwrap(), "test_value");
412        }
413
414        // Variable should be restored
415        assert_eq!(env::var("TEST_VAR").ok(), original_value);
416    }
417
418    #[test]
419    fn test_environment_guard() {
420        // Use a variable name that is unique to this test so parallel tests do not interfere.
421        // JWT_SECRET is used by many tests concurrently, making it an unsuitable choice.
422        const TEST_KEY: &str = "AUTH_FW_GUARD_ISOLATION_TEST_ONLY";
423
424        // Ensure the var is absent before we start
425        // SAFETY: This test runs in isolation; no concurrent env-var access.
426        unsafe { env::remove_var(TEST_KEY) };
427        assert!(
428            env::var(TEST_KEY).is_err(),
429            "TEST_KEY should not exist before test"
430        );
431
432        {
433            let _guard = TestEnvironmentGuard::new().with_custom_var(TEST_KEY, "isolated-value");
434            assert_eq!(env::var(TEST_KEY).unwrap(), "isolated-value");
435        }
436
437        // Variable should be removed (restored to None) after guard is dropped
438        assert!(
439            env::var(TEST_KEY).is_err(),
440            "TEST_KEY should be absent after guard is dropped"
441        );
442    }
443
444    #[test]
445    fn test_secure_test_data_generation() {
446        let token1 = test_data::secure_test_token("user1");
447        let token2 = test_data::secure_test_token("user1");
448
449        // Tokens should be different even for same user
450        assert_ne!(token1.token_id, token2.token_id);
451
452        // But should have same user_id
453        assert_eq!(token1.user_id, token2.user_id);
454    }
455
456    #[test]
457    fn test_secure_random_generation() {
458        let str1 = test_data::secure_random_string(32);
459        let str2 = test_data::secure_random_string(32);
460
461        assert_ne!(str1, str2);
462        assert_eq!(str1.len(), 64); // hex encoding doubles the length
463        assert_eq!(str2.len(), 64);
464    }
465}