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