Skip to main content

kora_lib/validator/
cache_validator.rs

1use deadpool_redis::Runtime;
2use redis::AsyncCommands;
3
4use crate::config::{CacheConfig, 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(
51                        "Invalid cache_url format: must start with redis:// or rediss://"
52                            .to_string(),
53                    );
54                }
55            }
56        }
57
58        // Warn about fallback configuration
59        if !usage_config.fallback_if_unavailable {
60            warnings.push(
61                "Usage limit fallback disabled - service will fail if cache becomes unavailable"
62                    .to_string(),
63            );
64        }
65
66        // Test Redis connection
67        if let Some(cache_url) = &usage_config.cache_url {
68            if cache_url.starts_with("redis://") || cache_url.starts_with("rediss://") {
69                if let Err(e) = Self::test_redis_connection(cache_url).await {
70                    if usage_config.fallback_if_unavailable {
71                        warnings.push(format!(
72                            "Usage limit Redis connection failed (fallback enabled): {e}"
73                        ));
74                    } else {
75                        errors.push(format!(
76                            "Usage limit Redis connection failed (fallback disabled): {e}"
77                        ));
78                    }
79                }
80            }
81        }
82
83        (errors, warnings)
84    }
85
86    /// Validates the RPC cache configuration at startup.
87    /// Checks if Redis URL is present and reachable when cache is enabled.
88    /// Returns (errors, warnings) — errors block startup, warnings are informational.
89    pub async fn validate_rpc_cache(cache_config: &CacheConfig) -> (Vec<String>, Vec<String>) {
90        let mut errors = Vec::new();
91        // warnings reserved for future soft-fail scenarios (e.g. fallback mode)
92        let warnings: Vec<String> = Vec::new();
93
94        if !cache_config.enabled {
95            return (errors, warnings);
96        }
97
98        match &cache_config.url {
99            None => {
100                errors.push("RPC cache is enabled but no Redis URL is configured".to_string());
101            }
102            Some(cache_url) => {
103                if !cache_url.starts_with("redis://") && !cache_url.starts_with("rediss://") {
104                    errors.push(
105                        "Invalid cache_url format: must start with redis:// or rediss://"
106                            .to_string(),
107                    );
108                } else if let Err(_e) = Self::test_redis_connection(cache_url).await {
109                    errors.push("RPC cache Redis connection failed".to_string());
110                }
111            }
112        }
113
114        (errors, warnings)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::tests::config_mock::ConfigMockBuilder;
122    use serial_test::serial;
123
124    #[tokio::test]
125    #[serial]
126    async fn test_validate_usage_limit_disabled() {
127        let config = ConfigMockBuilder::new().with_usage_limit_enabled(false).build();
128
129        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
130
131        assert!(errors.is_empty());
132        assert!(warnings.is_empty());
133    }
134
135    #[tokio::test]
136    #[serial]
137    async fn test_validate_usage_limit_enabled_no_cache_url_fallback_enabled() {
138        let config = ConfigMockBuilder::new()
139            .with_usage_limit_enabled(true)
140            .with_usage_limit_cache_url(None)
141            .with_usage_limit_fallback(true)
142            .build();
143
144        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
145
146        assert!(errors.is_empty());
147        assert!(warnings.iter().any(
148            |w| w.contains("Usage limiting enabled without cache_url - using in-memory store")
149        ));
150    }
151
152    #[tokio::test]
153    #[serial]
154    async fn test_validate_usage_limit_enabled_no_cache_url_fallback_disabled() {
155        let config = ConfigMockBuilder::new()
156            .with_usage_limit_enabled(true)
157            .with_usage_limit_cache_url(None)
158            .with_usage_limit_fallback(false)
159            .build();
160
161        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
162
163        // In-memory store works - just warn about non-persistence
164        assert!(errors.is_empty());
165        assert!(warnings.iter().any(
166            |w| w.contains("Usage limiting enabled without cache_url - using in-memory store")
167        ));
168        // Fallback warning still applies
169        assert!(warnings.iter().any(|w| w.contains(
170            "Usage limit fallback disabled - service will fail if cache becomes unavailable"
171        )));
172    }
173
174    #[tokio::test]
175    #[serial]
176    async fn test_validate_usage_limit_invalid_cache_url_format() {
177        let config = ConfigMockBuilder::new()
178            .with_usage_limit_enabled(true)
179            .with_usage_limit_cache_url(Some("invalid://localhost:6379".to_string()))
180            .with_usage_limit_fallback(true)
181            .build();
182
183        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
184
185        // Should error for invalid cache_url format
186        assert!(errors.iter().any(|e| e.contains("Invalid cache_url format")
187            && e.contains("must start with redis:// or rediss://")));
188        // No fallback warning since fallback is enabled
189        assert!(!warnings.iter().any(|w| w.contains(
190            "Usage limit fallback disabled - service will fail if cache becomes unavailable"
191        )));
192    }
193
194    #[tokio::test]
195    #[serial]
196    async fn test_validate_usage_limit_fallback_disabled_warning() {
197        let config = ConfigMockBuilder::new()
198            .with_usage_limit_enabled(true)
199            // We use port 54321 to ensure connection failure, as tests assert that the validator
200            // catches connection errors. Using default 6379 might succeed if a local Redis is running.
201            .with_usage_limit_cache_url(Some("redis://localhost:54321".to_string()))
202            .with_usage_limit_fallback(false)
203            .build();
204
205        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
206
207        // Should error about Redis connection failure with fallback disabled
208        assert!(errors
209            .iter()
210            .any(|e| e.contains("Usage limit Redis connection failed (fallback disabled)")));
211        assert!(warnings.iter().any(|w| w.contains(
212            "Usage limit fallback disabled - service will fail if cache becomes unavailable"
213        )));
214    }
215
216    #[tokio::test]
217    #[serial]
218    async fn test_validate_usage_limit_valid_redis_url() {
219        let config = ConfigMockBuilder::new()
220            .with_usage_limit_enabled(true)
221            // Ensure connection failure by using a non-standard/unused port
222            .with_usage_limit_cache_url(Some("redis://localhost:54321".to_string()))
223            .with_usage_limit_fallback(true)
224            .build();
225
226        let (errors, warnings) = CacheValidator::validate(&config.kora.usage_limit).await;
227
228        // Should get warnings because Redis connection fails (unit tests don't run Redis) but fallback is enabled
229        assert!(errors.is_empty());
230        assert!(warnings
231            .iter()
232            .any(|w| w.contains("Usage limit Redis connection failed (fallback enabled)")));
233    }
234
235    // Cache disabled — should skip all validation and return no errors
236    #[tokio::test]
237    #[serial]
238    async fn test_validate_rpc_cache_disabled() {
239        let config = ConfigMockBuilder::new().build();
240        let cache_config = CacheConfig {
241            enabled: false,
242            url: Some("redis://localhost:6379".to_string()),
243            ..config.kora.cache
244        };
245
246        let (errors, warnings) = CacheValidator::validate_rpc_cache(&cache_config).await;
247
248        assert!(errors.is_empty());
249        assert!(warnings.is_empty());
250    }
251
252    // Cache enabled but Redis URL missing — should return a config error
253    #[tokio::test]
254    #[serial]
255    async fn test_validate_rpc_cache_enabled_no_url() {
256        let config = ConfigMockBuilder::new().build();
257        let cache_config = CacheConfig { enabled: true, url: None, ..config.kora.cache };
258
259        let (errors, _) = CacheValidator::validate_rpc_cache(&cache_config).await;
260
261        assert!(!errors.is_empty());
262        assert!(errors[0].contains("no Redis URL"));
263    }
264
265    // Cache enabled but URL is not redis:// or rediss:// — should return format error
266    #[tokio::test]
267    #[serial]
268    async fn test_validate_rpc_cache_invalid_url_format() {
269        let config = ConfigMockBuilder::new().build();
270        let cache_config = CacheConfig {
271            enabled: true,
272            url: Some("http://localhost:6379".to_string()),
273            ..config.kora.cache
274        };
275
276        let (errors, _) = CacheValidator::validate_rpc_cache(&cache_config).await;
277
278        assert!(!errors.is_empty());
279        assert!(errors[0].contains("must start with redis://"));
280    }
281
282    // Cache enabled, valid URL format but Redis unreachable — should return connection error
283    #[tokio::test]
284    #[serial]
285    async fn test_validate_rpc_cache_connection_failed() {
286        let config = ConfigMockBuilder::new().build();
287        let cache_config = CacheConfig {
288            enabled: true,
289            url: Some("redis://localhost:54321".to_string()),
290            ..config.kora.cache
291        };
292
293        let (errors, _) = CacheValidator::validate_rpc_cache(&cache_config).await;
294
295        assert!(!errors.is_empty());
296        assert!(errors[0].contains("RPC cache Redis connection failed"));
297    }
298}