kora_lib/validator/
cache_validator.rs

1use deadpool_redis::Runtime;
2use redis::AsyncCommands;
3
4use crate::config::UsageLimitConfig;
5
6pub struct CacheValidator {}
7
8impl CacheValidator {
9    /// Test Redis connection for usage limit cache
10    async fn test_redis_connection(cache_url: &str) -> Result<(), String> {
11        let cfg = deadpool_redis::Config::from_url(cache_url);
12        let pool = cfg
13            .create_pool(Some(Runtime::Tokio1))
14            .map_err(|e| format!("Failed to create Redis pool: {e}"))?;
15
16        let mut conn = pool.get().await.map_err(|e| format!("Failed to connect to Redis: {e}"))?;
17
18        let _: Option<String> = conn
19            .get("__config_validator_test__")
20            .await
21            .map_err(|e| format!("Redis connection test failed: {e}"))?;
22
23        drop(conn);
24        drop(pool);
25
26        Ok(())
27    }
28
29    pub async fn validate(usage_config: &UsageLimitConfig) -> (Vec<String>, Vec<String>) {
30        let mut errors = Vec::new();
31        let mut warnings = Vec::new();
32
33        // Skip validation if usage limiting is disabled
34        if !usage_config.enabled {
35            return (errors, warnings);
36        }
37
38        // Check if cache_url is provided when enabled
39        match &usage_config.cache_url {
40            None => {
41                // In-memory store will be used - warn about non-persistence
42                warnings.push(
43                    "Usage limiting enabled without cache_url - using in-memory store (not persistent across restarts)"
44                        .to_string(),
45                );
46            }
47            Some(cache_url) => {
48                // Validate cache_url format
49                if !cache_url.starts_with("redis://") && !cache_url.starts_with("rediss://") {
50                    errors.push(format!(
51                        "Invalid cache_url format: '{cache_url}' - must start with redis:// or rediss://"
52                    ));
53                }
54            }
55        }
56
57        // Warn about fallback configuration
58        if !usage_config.fallback_if_unavailable {
59            warnings.push(
60                "Usage limit fallback disabled - service will fail if cache becomes unavailable"
61                    .to_string(),
62            );
63        }
64
65        // Test Redis connection
66        if let Some(cache_url) = &usage_config.cache_url {
67            if cache_url.starts_with("redis://") || cache_url.starts_with("rediss://") {
68                if let Err(e) = Self::test_redis_connection(cache_url).await {
69                    if usage_config.fallback_if_unavailable {
70                        warnings.push(format!(
71                            "Usage limit Redis connection failed (fallback enabled): {e}"
72                        ));
73                    } else {
74                        errors.push(format!(
75                            "Usage limit Redis connection failed (fallback disabled): {e}"
76                        ));
77                    }
78                }
79            }
80        }
81
82        (errors, warnings)
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::tests::config_mock::ConfigMockBuilder;
90    use serial_test::serial;
91
92    #[tokio::test]
93    #[serial]
94    async fn test_validate_usage_limit_disabled() {
95        let config = ConfigMockBuilder::new().with_usage_limit_enabled(false).build();
96
97        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
98
99        assert!(errors.is_empty());
100        assert!(warnings.is_empty());
101    }
102
103    #[tokio::test]
104    #[serial]
105    async fn test_validate_usage_limit_enabled_no_cache_url_fallback_enabled() {
106        let config = ConfigMockBuilder::new()
107            .with_usage_limit_enabled(true)
108            .with_usage_limit_cache_url(None)
109            .with_usage_limit_fallback(true)
110            .build();
111
112        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
113
114        assert!(errors.is_empty());
115        assert!(warnings.iter().any(
116            |w| w.contains("Usage limiting enabled without cache_url - using in-memory store")
117        ));
118    }
119
120    #[tokio::test]
121    #[serial]
122    async fn test_validate_usage_limit_enabled_no_cache_url_fallback_disabled() {
123        let config = ConfigMockBuilder::new()
124            .with_usage_limit_enabled(true)
125            .with_usage_limit_cache_url(None)
126            .with_usage_limit_fallback(false)
127            .build();
128
129        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
130
131        // In-memory store works - just warn about non-persistence
132        assert!(errors.is_empty());
133        assert!(warnings.iter().any(
134            |w| w.contains("Usage limiting enabled without cache_url - using in-memory store")
135        ));
136        // Fallback warning still applies
137        assert!(warnings.iter().any(|w| w.contains(
138            "Usage limit fallback disabled - service will fail if cache becomes unavailable"
139        )));
140    }
141
142    #[tokio::test]
143    #[serial]
144    async fn test_validate_usage_limit_invalid_cache_url_format() {
145        let config = ConfigMockBuilder::new()
146            .with_usage_limit_enabled(true)
147            .with_usage_limit_cache_url(Some("invalid://localhost:6379".to_string()))
148            .with_usage_limit_fallback(true)
149            .build();
150
151        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
152
153        // Should error for invalid cache_url format
154        assert!(errors.iter().any(|e| e.contains("Invalid cache_url format")
155            && e.contains("must start with redis:// or rediss://")));
156        // No fallback warning since fallback is enabled
157        assert!(!warnings.iter().any(|w| w.contains(
158            "Usage limit fallback disabled - service will fail if cache becomes unavailable"
159        )));
160    }
161
162    #[tokio::test]
163    #[serial]
164    async fn test_validate_usage_limit_fallback_disabled_warning() {
165        let config = ConfigMockBuilder::new()
166            .with_usage_limit_enabled(true)
167            // We use port 54321 to ensure connection failure, as tests assert that the validator
168            // catches connection errors. Using default 6379 might succeed if a local Redis is running.
169            .with_usage_limit_cache_url(Some("redis://localhost:54321".to_string()))
170            .with_usage_limit_fallback(false)
171            .build();
172
173        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
174
175        // Should error about Redis connection failure with fallback disabled
176        assert!(errors
177            .iter()
178            .any(|e| e.contains("Usage limit Redis connection failed (fallback disabled)")));
179        assert!(warnings.iter().any(|w| w.contains(
180            "Usage limit fallback disabled - service will fail if cache becomes unavailable"
181        )));
182    }
183
184    #[tokio::test]
185    #[serial]
186    async fn test_validate_usage_limit_valid_redis_url() {
187        let config = ConfigMockBuilder::new()
188            .with_usage_limit_enabled(true)
189            // Ensure connection failure by using a non-standard/unused port
190            .with_usage_limit_cache_url(Some("redis://localhost:54321".to_string()))
191            .with_usage_limit_fallback(true)
192            .build();
193
194        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
195
196        // Should get warnings because Redis connection fails (unit tests don't run Redis) but fallback is enabled
197        assert!(errors.is_empty());
198        assert!(warnings
199            .iter()
200            .any(|w| w.contains("Usage limit Redis connection failed (fallback enabled)")));
201    }
202}