sentinel_proxy/
builtin_handlers.rs

1//! Built-in handlers for Sentinel proxy
2//!
3//! These handlers provide default responses for common endpoints like
4//! status pages, health checks, and metrics. They are used when routes
5//! are configured with `service-type: builtin`.
6
7use bytes::Bytes;
8use http::{Response, StatusCode};
9use http_body_util::Full;
10use serde::Serialize;
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14use tracing::{debug, info, trace};
15
16use sentinel_config::{BuiltinHandler, Config};
17
18use crate::cache::{CacheManager, HttpCacheStats};
19
20/// Application state for builtin handlers
21pub struct BuiltinHandlerState {
22    /// Application start time
23    start_time: Instant,
24    /// Application version
25    version: String,
26    /// Instance ID
27    instance_id: String,
28}
29
30impl BuiltinHandlerState {
31    /// Create new handler state
32    pub fn new(version: String, instance_id: String) -> Self {
33        Self {
34            start_time: Instant::now(),
35            version,
36            instance_id,
37        }
38    }
39
40    /// Get uptime as a Duration
41    pub fn uptime(&self) -> Duration {
42        self.start_time.elapsed()
43    }
44
45    /// Format uptime as human-readable string
46    pub fn uptime_string(&self) -> String {
47        let uptime = self.uptime();
48        let secs = uptime.as_secs();
49        let days = secs / 86400;
50        let hours = (secs % 86400) / 3600;
51        let mins = (secs % 3600) / 60;
52        let secs = secs % 60;
53
54        if days > 0 {
55            format!("{}d {}h {}m {}s", days, hours, mins, secs)
56        } else if hours > 0 {
57            format!("{}h {}m {}s", hours, mins, secs)
58        } else if mins > 0 {
59            format!("{}m {}s", mins, secs)
60        } else {
61            format!("{}s", secs)
62        }
63    }
64}
65
66/// Status response payload
67#[derive(Debug, Serialize)]
68pub struct StatusResponse {
69    /// Service status
70    pub status: &'static str,
71    /// Service version
72    pub version: String,
73    /// Service uptime
74    pub uptime: String,
75    /// Uptime in seconds
76    pub uptime_secs: u64,
77    /// Instance identifier
78    pub instance_id: String,
79    /// Timestamp
80    pub timestamp: String,
81}
82
83/// Health check response
84#[derive(Debug, Serialize)]
85pub struct HealthResponse {
86    /// Health status
87    pub status: &'static str,
88    /// Timestamp
89    pub timestamp: String,
90}
91
92/// Upstream health snapshot for the upstreams handler
93#[derive(Debug, Clone, Default)]
94pub struct UpstreamHealthSnapshot {
95    /// Health status per upstream, keyed by upstream ID
96    pub upstreams: HashMap<String, UpstreamStatus>,
97}
98
99/// Status of a single upstream
100#[derive(Debug, Clone, Serialize)]
101pub struct UpstreamStatus {
102    /// Upstream ID
103    pub id: String,
104    /// Load balancing algorithm
105    pub load_balancing: String,
106    /// Target statuses
107    pub targets: Vec<TargetStatus>,
108}
109
110/// Status of a single target within an upstream
111#[derive(Debug, Clone, Serialize)]
112pub struct TargetStatus {
113    /// Target address
114    pub address: String,
115    /// Weight
116    pub weight: u32,
117    /// Health status
118    pub status: TargetHealthStatus,
119    /// Failure rate (0.0 - 1.0)
120    pub failure_rate: Option<f64>,
121    /// Last error message if unhealthy
122    pub last_error: Option<String>,
123}
124
125/// Health status of a target
126#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
127#[serde(rename_all = "lowercase")]
128pub enum TargetHealthStatus {
129    /// Target is healthy
130    Healthy,
131    /// Target is unhealthy
132    Unhealthy,
133    /// Health status unknown (no checks yet)
134    Unknown,
135}
136
137/// Cache purge request details
138#[derive(Debug, Clone)]
139pub struct CachePurgeRequest {
140    /// Pattern to purge (URL path or wildcard pattern)
141    pub pattern: String,
142    /// Whether this is a wildcard purge (purge all matching pattern)
143    pub wildcard: bool,
144}
145
146/// Execute a builtin handler
147pub fn execute_handler(
148    handler: BuiltinHandler,
149    state: &BuiltinHandlerState,
150    request_id: &str,
151    config: Option<Arc<Config>>,
152    upstreams: Option<UpstreamHealthSnapshot>,
153    cache_stats: Option<Arc<HttpCacheStats>>,
154    cache_purge: Option<CachePurgeRequest>,
155    cache_manager: Option<&Arc<CacheManager>>,
156) -> Response<Full<Bytes>> {
157    trace!(
158        handler = ?handler,
159        request_id = %request_id,
160        "Executing builtin handler"
161    );
162
163    let response = match handler {
164        BuiltinHandler::Status => status_handler(state, request_id),
165        BuiltinHandler::Health => health_handler(request_id),
166        BuiltinHandler::Metrics => metrics_handler(request_id, cache_stats.as_ref()),
167        BuiltinHandler::NotFound => not_found_handler(request_id),
168        BuiltinHandler::Config => config_handler(config, request_id),
169        BuiltinHandler::Upstreams => upstreams_handler(upstreams, request_id),
170        BuiltinHandler::CachePurge => cache_purge_handler(cache_purge, cache_manager, request_id),
171        BuiltinHandler::CacheStats => cache_stats_handler(cache_stats, request_id),
172    };
173
174    debug!(
175        handler = ?handler,
176        request_id = %request_id,
177        status = response.status().as_u16(),
178        "Builtin handler completed"
179    );
180
181    response
182}
183
184/// JSON status page handler
185fn status_handler(state: &BuiltinHandlerState, request_id: &str) -> Response<Full<Bytes>> {
186    trace!(
187        request_id = %request_id,
188        uptime_secs = state.uptime().as_secs(),
189        "Generating status response"
190    );
191
192    let response = StatusResponse {
193        status: "ok",
194        version: state.version.clone(),
195        uptime: state.uptime_string(),
196        uptime_secs: state.uptime().as_secs(),
197        instance_id: state.instance_id.clone(),
198        timestamp: chrono::Utc::now().to_rfc3339(),
199    };
200
201    let body =
202        serde_json::to_vec_pretty(&response).unwrap_or_else(|_| b"{\"status\":\"ok\"}".to_vec());
203
204    Response::builder()
205        .status(StatusCode::OK)
206        .header("Content-Type", "application/json; charset=utf-8")
207        .header("X-Request-Id", request_id)
208        .header("Cache-Control", "no-cache, no-store, must-revalidate")
209        .body(Full::new(Bytes::from(body)))
210        .expect("static response builder with valid headers cannot fail")
211}
212
213/// Health check handler
214fn health_handler(request_id: &str) -> Response<Full<Bytes>> {
215    let response = HealthResponse {
216        status: "healthy",
217        timestamp: chrono::Utc::now().to_rfc3339(),
218    };
219
220    let body =
221        serde_json::to_vec(&response).unwrap_or_else(|_| b"{\"status\":\"healthy\"}".to_vec());
222
223    Response::builder()
224        .status(StatusCode::OK)
225        .header("Content-Type", "application/json; charset=utf-8")
226        .header("X-Request-Id", request_id)
227        .header("Cache-Control", "no-cache, no-store, must-revalidate")
228        .body(Full::new(Bytes::from(body)))
229        .expect("static response builder with valid headers cannot fail")
230}
231
232/// Prometheus metrics handler
233fn metrics_handler(
234    request_id: &str,
235    cache_stats: Option<&Arc<HttpCacheStats>>,
236) -> Response<Full<Bytes>> {
237    use prometheus::{Encoder, TextEncoder};
238
239    // Create encoder for Prometheus text format
240    let encoder = TextEncoder::new();
241
242    // Gather all metrics from the default registry
243    let metric_families = prometheus::gather();
244
245    // Encode metrics to text format
246    let mut buffer = Vec::new();
247    match encoder.encode(&metric_families, &mut buffer) {
248        Ok(()) => {
249            // Add sentinel_up and build_info metrics
250            let extra_metrics = format!(
251                "# HELP sentinel_up Sentinel proxy is up and running\n\
252                 # TYPE sentinel_up gauge\n\
253                 sentinel_up 1\n\
254                 # HELP sentinel_build_info Build information\n\
255                 # TYPE sentinel_build_info gauge\n\
256                 sentinel_build_info{{version=\"{}\"}} 1\n",
257                env!("CARGO_PKG_VERSION")
258            );
259            buffer.extend_from_slice(extra_metrics.as_bytes());
260
261            // Add HTTP cache metrics if available
262            if let Some(stats) = cache_stats {
263                let cache_metrics = format!(
264                    "# HELP sentinel_cache_hits_total Total number of cache hits\n\
265                     # TYPE sentinel_cache_hits_total counter\n\
266                     sentinel_cache_hits_total {}\n\
267                     # HELP sentinel_cache_misses_total Total number of cache misses\n\
268                     # TYPE sentinel_cache_misses_total counter\n\
269                     sentinel_cache_misses_total {}\n\
270                     # HELP sentinel_cache_stores_total Total number of cache stores\n\
271                     # TYPE sentinel_cache_stores_total counter\n\
272                     sentinel_cache_stores_total {}\n\
273                     # HELP sentinel_cache_hit_ratio Cache hit ratio (0.0 to 1.0)\n\
274                     # TYPE sentinel_cache_hit_ratio gauge\n\
275                     sentinel_cache_hit_ratio {:.4}\n",
276                    stats.hits(),
277                    stats.misses(),
278                    stats.stores(),
279                    stats.hit_ratio()
280                );
281                buffer.extend_from_slice(cache_metrics.as_bytes());
282            }
283
284            Response::builder()
285                .status(StatusCode::OK)
286                .header("Content-Type", encoder.format_type())
287                .header("X-Request-Id", request_id)
288                .body(Full::new(Bytes::from(buffer)))
289                .expect("static response builder with valid headers cannot fail")
290        }
291        Err(e) => {
292            tracing::error!(error = %e, "Failed to encode Prometheus metrics");
293            let error_body = format!("# ERROR: Failed to encode metrics: {}\n", e);
294            Response::builder()
295                .status(StatusCode::INTERNAL_SERVER_ERROR)
296                .header("Content-Type", "text/plain; charset=utf-8")
297                .header("X-Request-Id", request_id)
298                .body(Full::new(Bytes::from(error_body)))
299                .expect("static response builder with valid headers cannot fail")
300        }
301    }
302}
303
304/// 404 Not Found handler
305fn not_found_handler(request_id: &str) -> Response<Full<Bytes>> {
306    let body = serde_json::json!({
307        "error": "Not Found",
308        "status": 404,
309        "message": "The requested resource could not be found.",
310        "request_id": request_id,
311        "timestamp": chrono::Utc::now().to_rfc3339(),
312    });
313
314    let body_bytes = serde_json::to_vec_pretty(&body)
315        .unwrap_or_else(|_| b"{\"error\":\"Not Found\",\"status\":404}".to_vec());
316
317    Response::builder()
318        .status(StatusCode::NOT_FOUND)
319        .header("Content-Type", "application/json; charset=utf-8")
320        .header("X-Request-Id", request_id)
321        .body(Full::new(Bytes::from(body_bytes)))
322        .expect("static response builder with valid headers cannot fail")
323}
324
325/// Configuration dump handler
326///
327/// Returns the current running configuration as JSON. Sensitive fields like
328/// TLS private keys are redacted for security.
329fn config_handler(config: Option<Arc<Config>>, request_id: &str) -> Response<Full<Bytes>> {
330    let body = match &config {
331        Some(cfg) => {
332            // Build a response with configuration details
333            // The Config struct derives Serialize, so we can serialize directly
334            // Note: sensitive fields should be redacted in production
335            let response = serde_json::json!({
336                "timestamp": chrono::Utc::now().to_rfc3339(),
337                "request_id": request_id,
338                "config": {
339                    "server": &cfg.server,
340                    "listeners": cfg.listeners.iter().map(|l| {
341                        serde_json::json!({
342                            "id": l.id,
343                            "address": l.address,
344                            "protocol": l.protocol,
345                            "default_route": l.default_route,
346                            "request_timeout_secs": l.request_timeout_secs,
347                            "keepalive_timeout_secs": l.keepalive_timeout_secs,
348                            // TLS config is redacted - only show if enabled
349                            "tls_enabled": l.tls.is_some(),
350                        })
351                    }).collect::<Vec<_>>(),
352                    "routes": cfg.routes.iter().map(|r| {
353                        serde_json::json!({
354                            "id": r.id,
355                            "priority": r.priority,
356                            "matches": r.matches,
357                            "upstream": r.upstream,
358                            "service_type": r.service_type,
359                            "builtin_handler": r.builtin_handler,
360                            "filters": r.filters,
361                            "waf_enabled": r.waf_enabled,
362                        })
363                    }).collect::<Vec<_>>(),
364                    "upstreams": cfg.upstreams.iter().map(|(id, u)| {
365                        serde_json::json!({
366                            "id": id,
367                            "targets": u.targets.iter().map(|t| {
368                                serde_json::json!({
369                                    "address": t.address,
370                                    "weight": t.weight,
371                                })
372                            }).collect::<Vec<_>>(),
373                            "load_balancing": u.load_balancing,
374                            "health_check": u.health_check.as_ref().map(|h| {
375                                serde_json::json!({
376                                    "interval_secs": h.interval_secs,
377                                    "timeout_secs": h.timeout_secs,
378                                    "healthy_threshold": h.healthy_threshold,
379                                    "unhealthy_threshold": h.unhealthy_threshold,
380                                })
381                            }),
382                            // TLS config redacted
383                            "tls_enabled": u.tls.is_some(),
384                        })
385                    }).collect::<Vec<_>>(),
386                    "agents": cfg.agents.iter().map(|a| {
387                        serde_json::json!({
388                            "id": a.id,
389                            "agent_type": a.agent_type,
390                            "timeout_ms": a.timeout_ms,
391                        })
392                    }).collect::<Vec<_>>(),
393                    "filters": cfg.filters.keys().collect::<Vec<_>>(),
394                    "waf": cfg.waf.as_ref().map(|w| {
395                        serde_json::json!({
396                            "mode": w.mode,
397                            "engine": w.engine,
398                            "audit_log": w.audit_log,
399                        })
400                    }),
401                    "limits": &cfg.limits,
402                }
403            });
404
405            serde_json::to_vec_pretty(&response).unwrap_or_else(|e| {
406                serde_json::to_vec(&serde_json::json!({
407                    "error": "Failed to serialize config",
408                    "message": e.to_string(),
409                }))
410                .unwrap_or_default()
411            })
412        }
413        None => serde_json::to_vec_pretty(&serde_json::json!({
414            "error": "Configuration unavailable",
415            "status": 503,
416            "message": "Config manager not available",
417            "request_id": request_id,
418            "timestamp": chrono::Utc::now().to_rfc3339(),
419        }))
420        .unwrap_or_default(),
421    };
422
423    let status = if config.is_some() {
424        StatusCode::OK
425    } else {
426        StatusCode::SERVICE_UNAVAILABLE
427    };
428
429    Response::builder()
430        .status(status)
431        .header("Content-Type", "application/json; charset=utf-8")
432        .header("X-Request-Id", request_id)
433        .header("Cache-Control", "no-cache, no-store, must-revalidate")
434        .body(Full::new(Bytes::from(body)))
435        .expect("static response builder with valid headers cannot fail")
436}
437
438/// Upstream health status handler
439///
440/// Returns the health status of all configured upstreams and their targets.
441fn upstreams_handler(
442    snapshot: Option<UpstreamHealthSnapshot>,
443    request_id: &str,
444) -> Response<Full<Bytes>> {
445    let body = match snapshot {
446        Some(data) => {
447            // Count healthy/unhealthy/unknown targets
448            let mut total_healthy = 0;
449            let mut total_unhealthy = 0;
450            let mut total_unknown = 0;
451
452            for upstream in data.upstreams.values() {
453                for target in &upstream.targets {
454                    match target.status {
455                        TargetHealthStatus::Healthy => total_healthy += 1,
456                        TargetHealthStatus::Unhealthy => total_unhealthy += 1,
457                        TargetHealthStatus::Unknown => total_unknown += 1,
458                    }
459                }
460            }
461
462            let response = serde_json::json!({
463                "timestamp": chrono::Utc::now().to_rfc3339(),
464                "request_id": request_id,
465                "summary": {
466                    "total_upstreams": data.upstreams.len(),
467                    "total_targets": total_healthy + total_unhealthy + total_unknown,
468                    "healthy": total_healthy,
469                    "unhealthy": total_unhealthy,
470                    "unknown": total_unknown,
471                },
472                "upstreams": data.upstreams.values().collect::<Vec<_>>(),
473            });
474
475            serde_json::to_vec_pretty(&response).unwrap_or_else(|e| {
476                serde_json::to_vec(&serde_json::json!({
477                    "error": "Failed to serialize upstreams",
478                    "message": e.to_string(),
479                }))
480                .unwrap_or_default()
481            })
482        }
483        None => {
484            // No upstreams configured or data unavailable
485            serde_json::to_vec_pretty(&serde_json::json!({
486                "timestamp": chrono::Utc::now().to_rfc3339(),
487                "request_id": request_id,
488                "summary": {
489                    "total_upstreams": 0,
490                    "total_targets": 0,
491                    "healthy": 0,
492                    "unhealthy": 0,
493                    "unknown": 0,
494                },
495                "upstreams": [],
496                "message": "No upstreams configured",
497            }))
498            .unwrap_or_default()
499        }
500    };
501
502    Response::builder()
503        .status(StatusCode::OK)
504        .header("Content-Type", "application/json; charset=utf-8")
505        .header("X-Request-Id", request_id)
506        .header("Cache-Control", "no-cache, no-store, must-revalidate")
507        .body(Full::new(Bytes::from(body)))
508        .expect("static response builder with valid headers cannot fail")
509}
510
511/// Cache purge handler
512///
513/// Handles PURGE requests to invalidate cache entries. Accepts a pattern
514/// and optionally purges all matching entries if wildcard is enabled.
515fn cache_purge_handler(
516    purge_request: Option<CachePurgeRequest>,
517    cache_manager: Option<&Arc<CacheManager>>,
518    request_id: &str,
519) -> Response<Full<Bytes>> {
520    let body = match (&purge_request, cache_manager) {
521        (Some(request), Some(manager)) => {
522            info!(
523                pattern = %request.pattern,
524                wildcard = request.wildcard,
525                request_id = %request_id,
526                "Processing cache purge request"
527            );
528
529            // Execute the actual purge via CacheManager
530            let purged_count = if request.wildcard {
531                // Wildcard purge - register pattern for matching
532                manager.purge_wildcard(&request.pattern)
533            } else {
534                // Single entry purge
535                manager.purge(&request.pattern)
536            };
537
538            info!(
539                pattern = %request.pattern,
540                wildcard = request.wildcard,
541                purged_count = purged_count,
542                request_id = %request_id,
543                "Cache purge completed"
544            );
545
546            serde_json::to_vec_pretty(&serde_json::json!({
547                "status": "ok",
548                "message": "Cache purge request processed",
549                "pattern": request.pattern,
550                "wildcard": request.wildcard,
551                "purged_entries": purged_count,
552                "active_purges": manager.active_purge_count(),
553                "request_id": request_id,
554                "timestamp": chrono::Utc::now().to_rfc3339(),
555            }))
556            .unwrap_or_default()
557        }
558        (Some(request), None) => {
559            // Cache manager not available - log warning and acknowledge request
560            tracing::warn!(
561                pattern = %request.pattern,
562                request_id = %request_id,
563                "Cache purge requested but cache manager not available"
564            );
565
566            serde_json::to_vec_pretty(&serde_json::json!({
567                "status": "warning",
568                "message": "Cache purge acknowledged but cache manager unavailable",
569                "pattern": request.pattern,
570                "wildcard": request.wildcard,
571                "purged_entries": 0,
572                "request_id": request_id,
573                "timestamp": chrono::Utc::now().to_rfc3339(),
574            }))
575            .unwrap_or_default()
576        }
577        (None, _) => {
578            // No purge request provided - return error
579            serde_json::to_vec_pretty(&serde_json::json!({
580                "error": "Bad Request",
581                "status": 400,
582                "message": "Cache purge requires a pattern. Use PURGE /path or X-Purge-Pattern header.",
583                "request_id": request_id,
584                "timestamp": chrono::Utc::now().to_rfc3339(),
585            })).unwrap_or_default()
586        }
587    };
588
589    let status = if purge_request.is_some() {
590        StatusCode::OK
591    } else {
592        StatusCode::BAD_REQUEST
593    };
594
595    Response::builder()
596        .status(status)
597        .header("Content-Type", "application/json; charset=utf-8")
598        .header("X-Request-Id", request_id)
599        .header("Cache-Control", "no-cache, no-store, must-revalidate")
600        .body(Full::new(Bytes::from(body)))
601        .expect("static response builder with valid headers cannot fail")
602}
603
604/// Cache statistics response
605#[derive(Debug, Serialize)]
606struct CacheStatsResponse {
607    /// Total cache hits
608    hits: u64,
609    /// Total cache misses
610    misses: u64,
611    /// Total cache stores
612    stores: u64,
613    /// Total cache evictions
614    evictions: u64,
615    /// Cache hit ratio (0.0 to 1.0)
616    hit_ratio: f64,
617    /// Request ID
618    request_id: String,
619    /// Timestamp
620    timestamp: String,
621}
622
623/// Cache statistics handler
624///
625/// Returns current cache statistics including hits, misses, and hit ratio.
626fn cache_stats_handler(
627    cache_stats: Option<Arc<HttpCacheStats>>,
628    request_id: &str,
629) -> Response<Full<Bytes>> {
630    let body = match cache_stats {
631        Some(stats) => {
632            let response = CacheStatsResponse {
633                hits: stats.hits(),
634                misses: stats.misses(),
635                stores: stats.stores(),
636                evictions: stats.evictions(),
637                hit_ratio: stats.hit_ratio(),
638                request_id: request_id.to_string(),
639                timestamp: chrono::Utc::now().to_rfc3339(),
640            };
641
642            serde_json::to_vec_pretty(&response)
643                .unwrap_or_else(|_| b"{\"error\":\"Failed to serialize stats\"}".to_vec())
644        }
645        None => serde_json::to_vec_pretty(&serde_json::json!({
646            "hits": 0,
647            "misses": 0,
648            "stores": 0,
649            "evictions": 0,
650            "hit_ratio": 0.0,
651            "message": "Cache statistics not available",
652            "request_id": request_id,
653            "timestamp": chrono::Utc::now().to_rfc3339(),
654        }))
655        .unwrap_or_default(),
656    };
657
658    Response::builder()
659        .status(StatusCode::OK)
660        .header("Content-Type", "application/json; charset=utf-8")
661        .header("X-Request-Id", request_id)
662        .header("Cache-Control", "no-cache, no-store, must-revalidate")
663        .body(Full::new(Bytes::from(body)))
664        .expect("static response builder with valid headers cannot fail")
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    #[test]
672    fn test_status_handler() {
673        let state = BuiltinHandlerState::new("0.1.0".to_string(), "test-instance".to_string());
674
675        let response = status_handler(&state, "test-request-id");
676        assert_eq!(response.status(), StatusCode::OK);
677
678        let content_type = response.headers().get("Content-Type").unwrap();
679        assert_eq!(content_type, "application/json; charset=utf-8");
680    }
681
682    #[test]
683    fn test_health_handler() {
684        let response = health_handler("test-request-id");
685        assert_eq!(response.status(), StatusCode::OK);
686    }
687
688    #[test]
689    fn test_metrics_handler() {
690        let response = metrics_handler("test-request-id", None);
691        assert_eq!(response.status(), StatusCode::OK);
692
693        let content_type = response.headers().get("Content-Type").unwrap();
694        assert!(content_type.to_str().unwrap().contains("text/plain"));
695    }
696
697    #[test]
698    fn test_metrics_handler_with_cache_stats() {
699        let stats = Arc::new(HttpCacheStats::default());
700        stats.record_hit();
701        stats.record_miss();
702        stats.record_store();
703
704        let response = metrics_handler("test-request-id", Some(&stats));
705        assert_eq!(response.status(), StatusCode::OK);
706    }
707
708    #[test]
709    fn test_cache_purge_handler_with_request() {
710        let cache_manager = Arc::new(CacheManager::new());
711        let request = CachePurgeRequest {
712            pattern: "/api/users/*".to_string(),
713            wildcard: true,
714        };
715        let response = cache_purge_handler(Some(request), Some(&cache_manager), "test-request-id");
716        assert_eq!(response.status(), StatusCode::OK);
717
718        // Verify the purge was actually registered
719        assert!(cache_manager.active_purge_count() > 0);
720    }
721
722    #[test]
723    fn test_cache_purge_handler_single_entry() {
724        let cache_manager = Arc::new(CacheManager::new());
725        let request = CachePurgeRequest {
726            pattern: "/api/users/123".to_string(),
727            wildcard: false,
728        };
729        let response = cache_purge_handler(Some(request), Some(&cache_manager), "test-request-id");
730        assert_eq!(response.status(), StatusCode::OK);
731
732        // Verify the purge was registered
733        assert!(cache_manager.should_invalidate("/api/users/123"));
734    }
735
736    #[test]
737    fn test_cache_purge_handler_without_request() {
738        let cache_manager = Arc::new(CacheManager::new());
739        let response = cache_purge_handler(None, Some(&cache_manager), "test-request-id");
740        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
741    }
742
743    #[test]
744    fn test_cache_purge_handler_without_manager() {
745        let request = CachePurgeRequest {
746            pattern: "/api/users/*".to_string(),
747            wildcard: true,
748        };
749        // Without cache manager, should still return OK but with warning
750        let response = cache_purge_handler(Some(request), None, "test-request-id");
751        assert_eq!(response.status(), StatusCode::OK);
752    }
753
754    #[test]
755    fn test_cache_stats_handler_with_stats() {
756        let stats = Arc::new(HttpCacheStats::default());
757        stats.record_hit();
758        stats.record_hit();
759        stats.record_miss();
760
761        let response = cache_stats_handler(Some(stats), "test-request-id");
762        assert_eq!(response.status(), StatusCode::OK);
763
764        let content_type = response.headers().get("Content-Type").unwrap();
765        assert_eq!(content_type, "application/json; charset=utf-8");
766    }
767
768    #[test]
769    fn test_cache_stats_handler_without_stats() {
770        let response = cache_stats_handler(None, "test-request-id");
771        assert_eq!(response.status(), StatusCode::OK);
772    }
773
774    #[test]
775    fn test_not_found_handler() {
776        let response = not_found_handler("test-request-id");
777        assert_eq!(response.status(), StatusCode::NOT_FOUND);
778    }
779
780    #[test]
781    fn test_config_handler_with_config() {
782        let config = Arc::new(Config::default_for_testing());
783        let response = config_handler(Some(config), "test-request-id");
784        assert_eq!(response.status(), StatusCode::OK);
785
786        let content_type = response.headers().get("Content-Type").unwrap();
787        assert_eq!(content_type, "application/json; charset=utf-8");
788    }
789
790    #[test]
791    fn test_config_handler_without_config() {
792        let response = config_handler(None, "test-request-id");
793        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
794    }
795
796    #[test]
797    fn test_upstreams_handler_with_data() {
798        let mut upstreams = HashMap::new();
799        upstreams.insert(
800            "backend".to_string(),
801            UpstreamStatus {
802                id: "backend".to_string(),
803                load_balancing: "round_robin".to_string(),
804                targets: vec![
805                    TargetStatus {
806                        address: "10.0.0.1:8080".to_string(),
807                        weight: 1,
808                        status: TargetHealthStatus::Healthy,
809                        failure_rate: Some(0.0),
810                        last_error: None,
811                    },
812                    TargetStatus {
813                        address: "10.0.0.2:8080".to_string(),
814                        weight: 1,
815                        status: TargetHealthStatus::Unhealthy,
816                        failure_rate: Some(0.8),
817                        last_error: Some("connection refused".to_string()),
818                    },
819                ],
820            },
821        );
822
823        let snapshot = UpstreamHealthSnapshot { upstreams };
824        let response = upstreams_handler(Some(snapshot), "test-request-id");
825        assert_eq!(response.status(), StatusCode::OK);
826
827        let content_type = response.headers().get("Content-Type").unwrap();
828        assert_eq!(content_type, "application/json; charset=utf-8");
829    }
830
831    #[test]
832    fn test_upstreams_handler_no_upstreams() {
833        let response = upstreams_handler(None, "test-request-id");
834        assert_eq!(response.status(), StatusCode::OK);
835    }
836
837    #[test]
838    fn test_uptime_formatting() {
839        let state = BuiltinHandlerState::new("0.1.0".to_string(), "test".to_string());
840
841        // Just verify it doesn't panic and returns a string
842        let uptime = state.uptime_string();
843        assert!(!uptime.is_empty());
844    }
845}