Skip to main content

fraiseql_server/routes/api/
admin.rs

1//! Admin API endpoints.
2//!
3//! Provides endpoints for:
4//! - Hot-reloading schema without restart
5//! - Invalidating cache by scope (all, entity type, or pattern)
6//! - Inspecting runtime configuration (sanitized)
7
8use std::{collections::HashMap, fs};
9
10use axum::{Json, extract::State};
11use fraiseql_core::{db::traits::DatabaseAdapter, schema::CompiledSchema};
12use serde::{Deserialize, Serialize};
13use tracing::{error, info};
14
15use crate::routes::{
16    api::types::{ApiError, ApiResponse},
17    graphql::AppState,
18};
19
20/// Current status of the query result cache as understood by the server.
21///
22/// Used in the admin config endpoint and startup logs to give operators
23/// an accurate picture of what `cache_enabled` actually activates.
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum CacheStatus {
27    /// `cache_enabled = false` — no cache guard or caching active.
28    Disabled,
29    /// `cache_enabled = true` — RLS safety guard is active, but full
30    /// query result caching (`CachedDatabaseAdapter`) is not yet wired.
31    #[deprecated(
32        since = "2.2.0",
33        note = "CachedDatabaseAdapter is now always wired when cache_enabled = true. \
34                Use `Active` or `Disabled` instead."
35    )]
36    RlsGuardOnly,
37    /// Full query result caching is active.
38    ///
39    /// `CachedDatabaseAdapter` is wired into the server when `cache_enabled = true`.
40    Active,
41}
42
43impl CacheStatus {
44    /// Derive cache status from the `cache_enabled` flag.
45    ///
46    /// # Deprecated
47    ///
48    /// Use `AppState::adapter_cache_enabled` to determine the true cache state.
49    #[must_use]
50    #[deprecated(
51        since = "2.2.0",
52        note = "Use `AppState::adapter_cache_enabled` to determine the true cache state. \
53                This function returns `RlsGuardOnly` which is no longer accurate."
54    )]
55    pub const fn from_cache_enabled(cache_enabled: bool) -> Self {
56        #[allow(deprecated)] // Reason: function itself is deprecated; returns deprecated variant
57        if cache_enabled {
58            Self::RlsGuardOnly
59        } else {
60            Self::Disabled
61        }
62    }
63}
64
65/// Request to reload schema from file.
66#[derive(Debug, Deserialize, Serialize)]
67pub struct ReloadSchemaRequest {
68    /// Path to compiled schema file
69    pub schema_path:   String,
70    /// If true, only validate the schema without applying changes
71    pub validate_only: bool,
72}
73
74/// Response after schema reload attempt.
75#[derive(Debug, Serialize)]
76pub struct ReloadSchemaResponse {
77    /// Whether the operation succeeded
78    pub success: bool,
79    /// Human-readable message about the result
80    pub message: String,
81}
82
83/// Request to clear cache entries.
84#[derive(Debug, Deserialize, Serialize)]
85pub struct CacheClearRequest {
86    /// Scope for clearing: "all", "entity", or "pattern"
87    pub scope:       String,
88    /// Entity type (required if scope is "entity")
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub entity_type: Option<String>,
91    /// Pattern (required if scope is "pattern")
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub pattern:     Option<String>,
94}
95
96/// Response after cache clear operation.
97#[derive(Debug, Serialize)]
98pub struct CacheClearResponse {
99    /// Whether the operation succeeded
100    pub success:         bool,
101    /// Number of entries cleared
102    pub entries_cleared: usize,
103    /// Human-readable message about the result
104    pub message:         String,
105}
106
107/// Response containing runtime configuration (sanitized).
108#[derive(Debug, Serialize)]
109pub struct AdminConfigResponse {
110    /// Server version
111    pub version: String,
112    /// Runtime configuration (secrets redacted)
113    pub config:  HashMap<String, String>,
114}
115
116/// Reload schema from file.
117///
118/// Supports validation-only mode via `validate_only` flag.
119/// When applied, the schema is atomically swapped without stopping execution.
120///
121/// # Errors
122///
123/// Returns `ApiError` with a validation error if `schema_path` is empty.
124/// Returns `ApiError` with a parse error if the schema file cannot be read or parsed.
125///
126/// Requires admin token authentication.
127pub async fn reload_schema_handler<A: DatabaseAdapter>(
128    State(state): State<AppState<A>>,
129    Json(req): Json<ReloadSchemaRequest>,
130) -> Result<Json<ApiResponse<ReloadSchemaResponse>>, ApiError> {
131    let _ = &state; // used conditionally by #[cfg(feature = "arrow")]
132    if req.schema_path.is_empty() {
133        return Err(ApiError::validation_error("schema_path cannot be empty"));
134    }
135
136    // Step 1: Load schema from file
137    let schema_json = fs::read_to_string(&req.schema_path)
138        .map_err(|e| ApiError::parse_error(format!("Failed to read schema file: {}", e)))?;
139
140    // Step 2: Validate schema structure
141    let _validated_schema = CompiledSchema::from_json(&schema_json)
142        .map_err(|e| ApiError::parse_error(format!("Invalid schema JSON: {}", e)))?;
143
144    if req.validate_only {
145        info!(
146            operation = "admin.reload_schema",
147            schema_path = %req.schema_path,
148            validate_only = true,
149            success = true,
150            "Admin: schema validation requested"
151        );
152        let response = ReloadSchemaResponse {
153            success: true,
154            message: "Schema validated successfully (not applied)".to_string(),
155        };
156        Ok(Json(ApiResponse {
157            status: "success".to_string(),
158            data:   response,
159        }))
160    } else {
161        // Step 3: Atomically swap the executor with the new schema
162        let start = std::time::Instant::now();
163        let schema_path = std::path::Path::new(&req.schema_path);
164
165        match state.reload_schema(schema_path).await {
166            Ok(()) => {
167                let duration_ms = start.elapsed().as_millis();
168                state
169                    .metrics
170                    .schema_reloads_total
171                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
172                info!(
173                    operation = "admin.reload_schema",
174                    schema_path = %req.schema_path,
175                    duration_ms,
176                    "Schema reloaded successfully"
177                );
178
179                let response = ReloadSchemaResponse {
180                    success: true,
181                    message: format!("Schema reloaded from {} in {duration_ms}ms", req.schema_path),
182                };
183                Ok(Json(ApiResponse {
184                    status: "success".to_string(),
185                    data:   response,
186                }))
187            },
188            Err(e) => {
189                state
190                    .metrics
191                    .schema_reload_errors_total
192                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
193                error!(
194                    operation = "admin.reload_schema",
195                    schema_path = %req.schema_path,
196                    error = %e,
197                    "Schema reload failed"
198                );
199                Err(ApiError::internal_error(format!("Schema reload failed: {e}")))
200            },
201        }
202    }
203}
204
205/// Cache statistics response.
206#[derive(Debug, Serialize)]
207pub struct CacheStatsResponse {
208    /// Number of entries currently in cache
209    pub entries_count: usize,
210    /// Whether cache is enabled
211    pub cache_enabled: bool,
212    /// Cache TTL in seconds
213    pub ttl_secs:      u64,
214    /// Human-readable message
215    pub message:       String,
216}
217
218/// Clear cache entries by scope.
219///
220/// Supports three clearing scopes:
221/// - **all**: Clear all cache entries
222/// - **entity**: Clear entries for a specific entity type
223/// - **pattern**: Clear entries matching a glob pattern
224///
225/// # Errors
226///
227/// Returns `ApiError` with an internal error if the cache feature is not enabled.
228/// Returns `ApiError` with a validation error if required parameters are missing or scope is
229/// invalid.
230///
231/// Requires admin token authentication.
232pub async fn cache_clear_handler<A: DatabaseAdapter>(
233    State(state): State<AppState<A>>,
234    Json(req): Json<CacheClearRequest>,
235) -> Result<Json<ApiResponse<CacheClearResponse>>, ApiError> {
236    // Cache operations require the `arrow` feature.
237    #[cfg(not(feature = "arrow"))]
238    {
239        let _ = (state, req);
240        Err(ApiError::internal_error("Cache not configured"))
241    }
242
243    #[cfg(feature = "arrow")]
244    // Validate scope and required parameters
245    match req.scope.as_str() {
246        "all" => {
247            if let Some(cache) = state.cache() {
248                let entries_before = cache.len();
249                cache.clear();
250                info!(
251                    operation = "admin.cache_clear",
252                    scope = "all",
253                    entries_cleared = entries_before,
254                    success = true,
255                    "Admin: cache cleared (all entries)"
256                );
257                let response = CacheClearResponse {
258                    success:         true,
259                    entries_cleared: entries_before,
260                    message:         format!("Cleared {} cache entries", entries_before),
261                };
262                Ok(Json(ApiResponse {
263                    status: "success".to_string(),
264                    data:   response,
265                }))
266            } else {
267                Err(ApiError::internal_error("Cache not configured"))
268            }
269        },
270        "entity" => {
271            if req.entity_type.is_none() {
272                return Err(ApiError::validation_error(
273                    "entity_type is required when scope is 'entity'",
274                ));
275            }
276
277            if let Some(cache) = state.cache() {
278                let entity_type = req.entity_type.as_ref().ok_or_else(|| {
279                    ApiError::internal_error(
280                        "entity_type was None after validation — this is a bug",
281                    )
282                })?;
283                // Convert entity type to view name pattern (e.g., User → v_user)
284                let view_name = format!("v_{}", entity_type.to_lowercase());
285                let entries_cleared = cache.invalidate_views(&[&view_name]);
286                info!(
287                    operation = "admin.cache_clear",
288                    scope = "entity",
289                    entity_type = %entity_type,
290                    entries_cleared,
291                    success = true,
292                    "Admin: cache cleared for entity"
293                );
294                let response = CacheClearResponse {
295                    success: true,
296                    entries_cleared,
297                    message: format!(
298                        "Cleared {} cache entries for entity type '{}'",
299                        entries_cleared, entity_type
300                    ),
301                };
302                Ok(Json(ApiResponse {
303                    status: "success".to_string(),
304                    data:   response,
305                }))
306            } else {
307                Err(ApiError::internal_error("Cache not configured"))
308            }
309        },
310        "pattern" => {
311            if req.pattern.is_none() {
312                return Err(ApiError::validation_error(
313                    "pattern is required when scope is 'pattern'",
314                ));
315            }
316
317            if let Some(cache) = state.cache() {
318                let pattern = req.pattern.as_ref().ok_or_else(|| {
319                    ApiError::internal_error("pattern was None after validation — this is a bug")
320                })?;
321                let entries_cleared = cache.invalidate_pattern(pattern);
322                info!(
323                    operation = "admin.cache_clear",
324                    scope = "pattern",
325                    %pattern,
326                    entries_cleared,
327                    success = true,
328                    "Admin: cache cleared by pattern"
329                );
330                let response = CacheClearResponse {
331                    success: true,
332                    entries_cleared,
333                    message: format!(
334                        "Cleared {} cache entries matching pattern '{}'",
335                        entries_cleared, pattern
336                    ),
337                };
338                Ok(Json(ApiResponse {
339                    status: "success".to_string(),
340                    data:   response,
341                }))
342            } else {
343                Err(ApiError::internal_error("Cache not configured"))
344            }
345        },
346        _ => Err(ApiError::validation_error("scope must be 'all', 'entity', or 'pattern'")),
347    }
348}
349
350/// Get cache statistics.
351///
352/// Returns current cache metrics including entry count, enabled status, and TTL.
353///
354/// # Errors
355///
356/// This handler currently always succeeds; it is infallible.
357///
358/// Requires admin token authentication.
359pub async fn cache_stats_handler<A: DatabaseAdapter>(
360    State(state): State<AppState<A>>,
361) -> Result<Json<ApiResponse<CacheStatsResponse>>, ApiError> {
362    #[cfg(feature = "arrow")]
363    if let Some(cache) = state.cache() {
364        let response = CacheStatsResponse {
365            entries_count: cache.len(),
366            cache_enabled: true,
367            ttl_secs:      60, // Default TTL from QueryCache::new(60)
368            message:       format!("Cache contains {} entries with 60-second TTL", cache.len()),
369        };
370        return Ok(Json(ApiResponse {
371            status: "success".to_string(),
372            data:   response,
373        }));
374    }
375    {
376        let _ = state;
377        let response = CacheStatsResponse {
378            entries_count: 0,
379            cache_enabled: false,
380            ttl_secs:      0,
381            message:       "Cache is not configured".to_string(),
382        };
383        Ok(Json(ApiResponse {
384            status: "success".to_string(),
385            data:   response,
386        }))
387    }
388}
389
390/// Get sanitized runtime configuration.
391///
392/// Returns server version and runtime configuration with secrets redacted.
393/// Configuration includes database settings, cache settings, etc.
394/// but excludes API keys, passwords, and other sensitive data.
395///
396/// # Errors
397///
398/// This handler currently always succeeds; it is infallible.
399///
400/// Requires admin token authentication.
401// Reason: `cache_enabled = "false"` appears in both the else-branch and the
402// `#[cfg(not(feature = "arrow"))]` inner path. Clippy sees them as shared code, but
403// extracting it would break the `#[cfg]` conditional logic that sets a different value
404// when `arrow` is enabled.
405#[allow(clippy::branches_sharing_code)] // Reason: branches are logically distinct; extracting shared code would obscure intent
406pub async fn config_handler<A: DatabaseAdapter>(
407    State(state): State<AppState<A>>,
408) -> Result<Json<ApiResponse<AdminConfigResponse>>, ApiError> {
409    let mut config = HashMap::new();
410
411    // Get actual server configuration
412    if let Some(server_config) = state.server_config() {
413        // Safe configuration values - no secrets
414        config.insert("port".to_string(), server_config.port.to_string());
415        config.insert("host".to_string(), server_config.host.clone());
416
417        if let Some(workers) = server_config.workers {
418            config.insert("workers".to_string(), workers.to_string());
419        }
420
421        // TLS status (boolean only, paths are redacted)
422        config.insert("tls_enabled".to_string(), server_config.tls.is_some().to_string());
423
424        // Request limits
425        if let Some(limits) = &server_config.limits {
426            config.insert("max_request_size".to_string(), limits.max_request_size.clone());
427            config.insert("request_timeout".to_string(), limits.request_timeout.clone());
428            config.insert(
429                "max_concurrent_requests".to_string(),
430                limits.max_concurrent_requests.to_string(),
431            );
432            config.insert("max_queue_depth".to_string(), limits.max_queue_depth.to_string());
433        }
434
435        // Cache status: read from adapter_cache_enabled (set at startup by ServerBuilder).
436        // This reflects the CachedDatabaseAdapter state, independent of the Arrow cache.
437        let cache_active = state.adapter_cache_enabled;
438
439        config.insert("cache_enabled".to_string(), cache_active.to_string());
440        let cache_status = if cache_active {
441            CacheStatus::Active
442        } else {
443            CacheStatus::Disabled
444        };
445        config.insert(
446            "cache_status".to_string(),
447            serde_json::to_string(&cache_status)
448                .unwrap_or_else(|_| "\"disabled\"".to_string())
449                .trim_matches('"')
450                .to_string(),
451        );
452        let _ = server_config; // consumed above for other fields
453    } else {
454        // Minimal configuration if not available
455        config.insert("cache_enabled".to_string(), "false".to_string());
456        config.insert("cache_status".to_string(), "disabled".to_string());
457    }
458
459    let response = AdminConfigResponse {
460        version: env!("CARGO_PKG_VERSION").to_string(),
461        config,
462    };
463
464    Ok(Json(ApiResponse {
465        status: "success".to_string(),
466        data:   response,
467    }))
468}
469
470/// Request body for `POST /api/v1/admin/explain`.
471#[derive(Debug, Deserialize, Serialize)]
472pub struct ExplainRequest {
473    /// Name of the regular query to explain (e.g., `"users"`).
474    pub query: String,
475
476    /// GraphQL-style variable filters passed as a JSON object.
477    ///
478    /// Each key-value pair becomes an equality condition in the WHERE clause.
479    /// Example: `{"status": "active"}` → `WHERE data->>'status' = 'active'`.
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub variables: Option<serde_json::Value>,
482
483    /// Optional row limit to pass to the query.
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub limit: Option<u32>,
486
487    /// Optional row offset to pass to the query.
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub offset: Option<u32>,
490}
491
492/// Return the pre-built Grafana dashboard JSON for FraiseQL metrics.
493///
494/// The dashboard JSON is embedded at compile time from
495/// `deploy/grafana/fraiseql-dashboard.json`.  Operators can import it into
496/// Grafana with a single `curl` command (see `deploy/grafana/README.md`).
497///
498/// # Errors
499///
500/// This handler is infallible — the embedded JSON is validated at compile time
501/// by the `test_grafana_dashboard_is_valid_json` unit test.
502///
503/// Requires admin token authentication.
504pub async fn grafana_dashboard_handler<A: DatabaseAdapter>(
505    State(_state): State<AppState<A>>,
506) -> impl axum::response::IntoResponse {
507    const DASHBOARD_JSON: &str = include_str!("../../../resources/fraiseql-dashboard.json");
508
509    (
510        axum::http::StatusCode::OK,
511        [(axum::http::header::CONTENT_TYPE, "application/json")],
512        DASHBOARD_JSON,
513    )
514}
515
516/// Run `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` for a named query.
517///
518/// Accepts a query name and optional variable filters, then executes
519/// `EXPLAIN ANALYZE` against the backing PostgreSQL view using the exact
520/// same parameterized SQL that a live query would use.
521///
522/// # Errors
523///
524/// * `400 Bad Request` — empty query name, unknown query, or mutation given
525/// * `500 Internal Server Error` — database execution failure
526///
527/// Requires admin token authentication.
528pub async fn explain_handler<A: DatabaseAdapter + 'static>(
529    State(state): State<AppState<A>>,
530    Json(req): Json<ExplainRequest>,
531) -> Result<Json<ApiResponse<fraiseql_core::runtime::ExplainResult>>, ApiError> {
532    if req.query.is_empty() {
533        return Err(ApiError::validation_error("query cannot be empty"));
534    }
535
536    state
537        .executor()
538        .explain(&req.query, req.variables.as_ref(), req.limit, req.offset)
539        .await
540        .map(ApiResponse::success)
541        .map_err(|e| match e {
542            fraiseql_core::error::FraiseQLError::Validation { message, .. } => {
543                ApiError::validation_error(message)
544            },
545            fraiseql_core::error::FraiseQLError::Unsupported { message } => {
546                ApiError::validation_error(format!("Unsupported: {message}"))
547            },
548            other => ApiError::internal_error(other.to_string()),
549        })
550}
551
552#[cfg(test)]
553#[allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
554mod tests {
555    use super::*;
556
557    // ── CacheStatus (Phase 02, Issue #183) ─────────────────────────────────
558
559    #[test]
560    #[allow(deprecated)] // Reason: testing deprecated variant
561    fn cache_status_serializes_to_snake_case() {
562        let json = serde_json::to_string(&CacheStatus::RlsGuardOnly).unwrap();
563        assert_eq!(json, "\"rls_guard_only\"");
564
565        let json = serde_json::to_string(&CacheStatus::Disabled).unwrap();
566        assert_eq!(json, "\"disabled\"");
567
568        let json = serde_json::to_string(&CacheStatus::Active).unwrap();
569        assert_eq!(json, "\"active\"");
570    }
571
572    #[test]
573    #[allow(deprecated)] // Reason: testing deprecated function
574    fn cache_status_from_config_enabled() {
575        assert_eq!(CacheStatus::from_cache_enabled(true), CacheStatus::RlsGuardOnly);
576    }
577
578    #[test]
579    #[allow(deprecated)] // Reason: testing deprecated function
580    fn cache_status_from_config_disabled() {
581        assert_eq!(CacheStatus::from_cache_enabled(false), CacheStatus::Disabled);
582    }
583
584    #[test]
585    #[allow(deprecated)] // Reason: testing deprecated variant
586    fn cache_status_deserializes_from_snake_case() {
587        let status: CacheStatus = serde_json::from_str("\"rls_guard_only\"").unwrap();
588        assert_eq!(status, CacheStatus::RlsGuardOnly);
589
590        let status: CacheStatus = serde_json::from_str("\"active\"").unwrap();
591        assert_eq!(status, CacheStatus::Active);
592    }
593
594    // ── Grafana & other tests ───────────────────────────────────────────────
595
596    #[test]
597    fn test_grafana_dashboard_is_valid_json() {
598        let parsed: serde_json::Value =
599            serde_json::from_str(include_str!("../../../resources/fraiseql-dashboard.json"))
600                .expect("fraiseql-dashboard.json must be valid JSON");
601
602        assert_eq!(parsed["title"], "FraiseQL Performance");
603        assert_eq!(parsed["uid"], "fraiseql-perf-v1");
604        assert!(
605            parsed["panels"].as_array().map_or(0, |p| p.len()) >= 10,
606            "dashboard should have at least 10 panels"
607        );
608    }
609
610    #[test]
611    fn test_reload_schema_request_empty_path() {
612        let request = ReloadSchemaRequest {
613            schema_path:   String::new(),
614            validate_only: false,
615        };
616
617        assert!(request.schema_path.is_empty());
618    }
619
620    #[test]
621    fn test_reload_schema_request_with_path() {
622        let request = ReloadSchemaRequest {
623            schema_path:   "/path/to/schema.json".to_string(),
624            validate_only: false,
625        };
626
627        assert!(!request.schema_path.is_empty());
628    }
629
630    #[test]
631    fn test_cache_clear_scope_validation() {
632        let valid_scopes = vec!["all", "entity", "pattern"];
633
634        for scope in valid_scopes {
635            let request = CacheClearRequest {
636                scope:       scope.to_string(),
637                entity_type: None,
638                pattern:     None,
639            };
640            assert_eq!(request.scope, scope);
641        }
642    }
643
644    #[test]
645    fn test_admin_config_response_has_version() {
646        let response = AdminConfigResponse {
647            version: "2.0.0-a1".to_string(),
648            config:  HashMap::new(),
649        };
650
651        assert!(!response.version.is_empty());
652    }
653
654    #[test]
655    fn test_reload_schema_response_success() {
656        let response = ReloadSchemaResponse {
657            success: true,
658            message: "Reloaded".to_string(),
659        };
660
661        assert!(response.success);
662    }
663
664    #[test]
665    fn test_reload_schema_response_failure() {
666        let response = ReloadSchemaResponse {
667            success: false,
668            message: "Failed to load".to_string(),
669        };
670
671        assert!(!response.success);
672    }
673
674    #[test]
675    fn test_cache_clear_response_counts_entries() {
676        let response = CacheClearResponse {
677            success:         true,
678            entries_cleared: 42,
679            message:         "Cleared".to_string(),
680        };
681
682        assert_eq!(response.entries_cleared, 42);
683    }
684
685    #[test]
686    fn test_cache_clear_request_entity_required_for_entity_scope() {
687        let request = CacheClearRequest {
688            scope:       "entity".to_string(),
689            entity_type: Some("User".to_string()),
690            pattern:     None,
691        };
692
693        assert_eq!(request.scope, "entity");
694        assert_eq!(request.entity_type.as_deref(), Some("User"));
695    }
696
697    #[test]
698    fn test_cache_clear_request_pattern_required_for_pattern_scope() {
699        let request = CacheClearRequest {
700            scope:       "pattern".to_string(),
701            entity_type: None,
702            pattern:     Some("*_user".to_string()),
703        };
704
705        assert_eq!(request.scope, "pattern");
706        assert_eq!(request.pattern.as_deref(), Some("*_user"));
707    }
708
709    #[test]
710    fn test_admin_config_response_sanitization_excludes_paths() {
711        let response = AdminConfigResponse {
712            version: "2.0.0".to_string(),
713            config:  {
714                let mut m = HashMap::new();
715                m.insert("port".to_string(), "8000".to_string());
716                m.insert("host".to_string(), "0.0.0.0".to_string());
717                m.insert("tls_enabled".to_string(), "true".to_string());
718                m
719            },
720        };
721
722        assert_eq!(response.config.get("port"), Some(&"8000".to_string()));
723        assert_eq!(response.config.get("host"), Some(&"0.0.0.0".to_string()));
724        assert_eq!(response.config.get("tls_enabled"), Some(&"true".to_string()));
725        // Verify no cert_file or key_file keys (paths redacted)
726        assert!(!response.config.contains_key("cert_file"));
727        assert!(!response.config.contains_key("key_file"));
728    }
729
730    #[test]
731    fn test_admin_config_response_includes_limits() {
732        let response = AdminConfigResponse {
733            version: "2.0.0".to_string(),
734            config:  {
735                let mut m = HashMap::new();
736                m.insert("max_request_size".to_string(), "10MB".to_string());
737                m.insert("request_timeout".to_string(), "30s".to_string());
738                m.insert("max_concurrent_requests".to_string(), "1000".to_string());
739                m
740            },
741        };
742
743        assert!(response.config.contains_key("max_request_size"));
744        assert!(response.config.contains_key("request_timeout"));
745        assert!(response.config.contains_key("max_concurrent_requests"));
746    }
747
748    #[test]
749    fn test_cache_stats_response_structure() {
750        let response = CacheStatsResponse {
751            entries_count: 100,
752            cache_enabled: true,
753            ttl_secs:      60,
754            message:       "Cache statistics".to_string(),
755        };
756
757        assert_eq!(response.entries_count, 100);
758        assert!(response.cache_enabled);
759        assert_eq!(response.ttl_secs, 60);
760        assert!(!response.message.is_empty());
761    }
762
763    #[test]
764    fn test_reload_schema_request_validates_path() {
765        let request = ReloadSchemaRequest {
766            schema_path:   "/path/to/schema.json".to_string(),
767            validate_only: false,
768        };
769
770        assert!(!request.schema_path.is_empty());
771    }
772
773    #[test]
774    fn test_reload_schema_request_validate_only_flag() {
775        let request = ReloadSchemaRequest {
776            schema_path:   "/path/to/schema.json".to_string(),
777            validate_only: true,
778        };
779
780        assert!(request.validate_only);
781    }
782
783    #[test]
784    fn test_reload_schema_response_indicates_success() {
785        let response = ReloadSchemaResponse {
786            success: true,
787            message: "Schema reloaded".to_string(),
788        };
789
790        assert!(response.success);
791        assert!(!response.message.is_empty());
792    }
793
794    // ── Admin audit log tests (15-5) ────────────────────────────────────────
795
796    #[test]
797    fn test_reload_schema_request_carries_audit_fields() {
798        // Verifies that the request type exposes the fields needed to emit a
799        // complete audit log entry (schema_path + validate_only).
800        let req = ReloadSchemaRequest {
801            schema_path:   "/var/run/fraiseql/schema.compiled.json".to_string(),
802            validate_only: false,
803        };
804        assert!(!req.schema_path.is_empty(), "schema_path must be present for audit log");
805        // validate_only is always set (bool field) — no assertion needed.
806        let _ = req.validate_only;
807    }
808
809    #[test]
810    fn test_cache_clear_request_carries_audit_fields() {
811        // Verifies that CacheClearRequest exposes the fields needed for audit logging
812        // (scope, optional entity_type, optional pattern).
813        let all_req = CacheClearRequest {
814            scope:       "all".to_string(),
815            entity_type: None,
816            pattern:     None,
817        };
818        assert_eq!(all_req.scope, "all");
819
820        let entity_req = CacheClearRequest {
821            scope:       "entity".to_string(),
822            entity_type: Some("Order".to_string()),
823            pattern:     None,
824        };
825        assert!(
826            entity_req.entity_type.is_some(),
827            "entity scope must carry entity_type for audit"
828        );
829
830        let pattern_req = CacheClearRequest {
831            scope:       "pattern".to_string(),
832            entity_type: None,
833            pattern:     Some("v_order*".to_string()),
834        };
835        assert!(pattern_req.pattern.is_some(), "pattern scope must carry pattern for audit");
836    }
837}