Skip to main content

perfgate_server/
server.rs

1//! Server configuration and bootstrap.
2//!
3//! This module provides the [`ServerConfig`] and [`run_server`] function
4//! for starting the HTTP server.
5
6use std::net::SocketAddr;
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11use axum::{
12    Router, middleware,
13    routing::{delete, get, post},
14};
15use tower::ServiceBuilder;
16use tower_http::{
17    cors::{Any, CorsLayer},
18    request_id::MakeRequestUuid,
19    trace::TraceLayer,
20};
21use tracing::info;
22
23use crate::auth::{
24    ApiKey, ApiKeyStore, AuthState, JwtConfig, Role, auth_middleware, local_mode_auth_middleware,
25};
26use crate::cleanup::spawn_cleanup_task;
27use crate::error::ConfigError;
28use crate::handlers::{
29    DefaultRetentionDays, admin_cleanup, create_key, dashboard_index, delete_baseline,
30    dependency_impact, get_baseline, get_latest_baseline, get_trend, health_check,
31    list_audit_events, list_baselines, list_fleet_alerts, list_keys, list_verdicts,
32    promote_baseline, record_dependency_event, revoke_key, static_asset, submit_verdict,
33    upload_baseline,
34};
35use crate::metrics::{metrics_handler, metrics_middleware, setup_metrics_recorder};
36use crate::oidc::{OidcConfig, OidcProvider, OidcRegistry};
37use crate::storage::fleet::{FleetStore, InMemoryFleetStore};
38use crate::storage::{
39    ArtifactStore, AuditStore, BaselineStore, InMemoryKeyStore, InMemoryStore, KeyStore,
40    ObjectArtifactStore, PostgresStore, SqliteKeyStore, SqliteStore,
41};
42use metrics_exporter_prometheus::PrometheusHandle;
43
44/// Shared application state holding all stores.
45#[derive(Clone)]
46pub struct AppState {
47    /// Baseline/verdict store
48    pub store: Arc<dyn BaselineStore>,
49    /// Audit event store
50    pub audit: Arc<dyn AuditStore>,
51}
52
53/// Storage backend type.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum StorageBackend {
56    /// In-memory storage (for testing/development)
57    #[default]
58    Memory,
59    /// SQLite persistent storage
60    Sqlite,
61    /// PostgreSQL storage (not yet implemented)
62    Postgres,
63}
64
65impl std::str::FromStr for StorageBackend {
66    type Err = String;
67
68    fn from_str(s: &str) -> Result<Self, Self::Err> {
69        match s.to_lowercase().as_str() {
70            "memory" => Ok(Self::Memory),
71            "sqlite" => Ok(Self::Sqlite),
72            "postgres" | "postgresql" => Ok(Self::Postgres),
73            _ => Err(format!("Unknown storage backend: {}", s)),
74        }
75    }
76}
77
78/// Configuration for PostgreSQL connection pool tuning.
79///
80/// These parameters control how sqlx manages the underlying connection pool.
81/// All fields have sensible defaults suitable for moderate production load.
82///
83/// # Recommended PostgreSQL server settings
84///
85/// For production workloads, tune these `postgresql.conf` knobs alongside the
86/// pool parameters:
87///
88/// | PostgreSQL setting      | Recommended value | Notes                                    |
89/// |-------------------------|-------------------|------------------------------------------|
90/// | `max_connections`       | 100 - 200         | Must exceed pool `max_connections`        |
91/// | `shared_buffers`        | 25% of RAM        | Main query cache                         |
92/// | `work_mem`              | 4 - 16 MB         | Per-sort/hash memory                     |
93/// | `idle_in_transaction_session_timeout` | 30s  | Kill idle-in-transaction sessions        |
94/// | `statement_timeout`     | 30s               | Server-side query timeout                |
95/// | `tcp_keepalives_idle`   | 60                | Seconds before TCP keepalive probes      |
96/// | `tcp_keepalives_interval` | 10              | Seconds between probes                   |
97/// | `tcp_keepalives_count`  | 6                 | Failed probes before disconnect           |
98#[derive(Debug, Clone)]
99pub struct PostgresPoolConfig {
100    /// Maximum number of connections in the pool (default: 10).
101    pub max_connections: u32,
102    /// Minimum number of idle connections to maintain (default: 2).
103    pub min_connections: u32,
104    /// Time a connection may sit idle before being closed (default: 300s).
105    pub idle_timeout: Duration,
106    /// Maximum lifetime of a connection before it is recycled (default: 1800s).
107    pub max_lifetime: Duration,
108    /// How long to wait when acquiring a connection from the pool (default: 5s).
109    pub acquire_timeout: Duration,
110    /// Statement timeout set on each new connection via `SET statement_timeout`
111    /// (default: 30s). Prevents runaway queries.
112    pub statement_timeout: Duration,
113}
114
115impl Default for PostgresPoolConfig {
116    fn default() -> Self {
117        Self {
118            max_connections: 10,
119            min_connections: 2,
120            idle_timeout: Duration::from_secs(300),
121            max_lifetime: Duration::from_secs(1800),
122            acquire_timeout: Duration::from_secs(5),
123            statement_timeout: Duration::from_secs(30),
124        }
125    }
126}
127
128/// API key configuration.
129#[derive(Debug, Clone)]
130pub struct ApiKeyConfig {
131    /// The actual API key string
132    pub key: String,
133    /// Assigned role
134    pub role: Role,
135    /// Project identifier the key is restricted to
136    pub project: String,
137    /// Optional regex to restrict access to specific benchmarks
138    pub benchmark_regex: Option<String>,
139}
140
141/// Server configuration.
142#[derive(Debug, Clone)]
143pub struct ServerConfig {
144    /// Bind address (e.g., "0.0.0.0:8080")
145    pub bind: SocketAddr,
146
147    /// Storage backend type
148    pub storage_backend: StorageBackend,
149
150    /// SQLite database path (when storage_backend is Sqlite)
151    pub sqlite_path: Option<PathBuf>,
152
153    /// PostgreSQL connection URL (when storage_backend is Postgres)
154    pub postgres_url: Option<String>,
155
156    /// PostgreSQL connection pool configuration
157    pub postgres_pool: PostgresPoolConfig,
158
159    /// Artifact storage URL (e.g., s3://bucket/prefix)
160    pub artifacts_url: Option<String>,
161
162    /// API keys for authentication
163    pub api_keys: Vec<ApiKeyConfig>,
164
165    /// Optional JWT validation settings.
166    pub jwt: Option<JwtConfig>,
167
168    /// OIDC provider configurations (GitHub, GitLab, custom).
169    pub oidc_configs: Vec<OidcConfig>,
170
171    /// Enable CORS for all origins
172    pub cors: bool,
173
174    /// Request timeout in seconds
175    pub timeout_seconds: u64,
176
177    /// Local mode: disable authentication for single-user local use.
178    pub local_mode: bool,
179
180    /// Artifact retention period in days (0 = no cleanup).
181    pub retention_days: u64,
182
183    /// Interval between background cleanup passes (in hours).
184    pub cleanup_interval_hours: u64,
185}
186
187impl Default for ServerConfig {
188    fn default() -> Self {
189        Self {
190            bind: "0.0.0.0:8080".parse().unwrap(),
191            storage_backend: StorageBackend::Memory,
192            sqlite_path: None,
193            postgres_url: None,
194            postgres_pool: PostgresPoolConfig::default(),
195            artifacts_url: None,
196            api_keys: vec![],
197            jwt: None,
198            oidc_configs: vec![],
199            cors: true,
200            timeout_seconds: 30,
201            local_mode: false,
202            retention_days: 0,
203            cleanup_interval_hours: 1,
204        }
205    }
206}
207
208impl ServerConfig {
209    /// Creates a new configuration with default values.
210    pub fn new() -> Self {
211        Self::default()
212    }
213
214    /// Sets the bind address.
215    pub fn bind(mut self, addr: impl Into<String>) -> Result<Self, ConfigError> {
216        self.bind = addr
217            .into()
218            .parse()
219            .map_err(|e| ConfigError::InvalidValue(format!("Invalid bind address: {}", e)))?;
220        Ok(self)
221    }
222
223    /// Sets the storage backend.
224    pub fn storage_backend(mut self, backend: StorageBackend) -> Self {
225        self.storage_backend = backend;
226        self
227    }
228
229    /// Sets the SQLite database path.
230    pub fn sqlite_path(mut self, path: impl Into<PathBuf>) -> Self {
231        self.sqlite_path = Some(path.into());
232        self
233    }
234
235    /// Sets the PostgreSQL connection URL.
236    pub fn postgres_url(mut self, url: impl Into<String>) -> Self {
237        self.postgres_url = Some(url.into());
238        self
239    }
240
241    /// Sets the PostgreSQL connection pool configuration.
242    pub fn postgres_pool(mut self, pool_config: PostgresPoolConfig) -> Self {
243        self.postgres_pool = pool_config;
244        self
245    }
246
247    /// Sets the artifacts storage URL.
248    pub fn artifacts_url(mut self, url: impl Into<String>) -> Self {
249        self.artifacts_url = Some(url.into());
250        self
251    }
252
253    /// Adds an API key with a specific role.
254    pub fn api_key(self, key: impl Into<String>, role: Role) -> Self {
255        self.scoped_api_key(key, role, "default", None)
256    }
257
258    /// Adds a scoped API key restricted to a project and optional benchmark regex.
259    pub fn scoped_api_key(
260        mut self,
261        key: impl Into<String>,
262        role: Role,
263        project: impl Into<String>,
264        benchmark_regex: Option<String>,
265    ) -> Self {
266        self.api_keys.push(ApiKeyConfig {
267            key: key.into(),
268            role,
269            project: project.into(),
270            benchmark_regex,
271        });
272        self
273    }
274
275    /// Enables JWT token authentication.
276    pub fn jwt(mut self, jwt: JwtConfig) -> Self {
277        self.jwt = Some(jwt);
278        self
279    }
280
281    /// Adds an OIDC provider configuration.
282    ///
283    /// Multiple providers can be registered (e.g. GitHub + GitLab).
284    pub fn oidc(mut self, config: OidcConfig) -> Self {
285        self.oidc_configs.push(config);
286        self
287    }
288
289    /// Enables or disables CORS.
290    pub fn cors(mut self, enabled: bool) -> Self {
291        self.cors = enabled;
292        self
293    }
294
295    /// Enables or disables local mode (no authentication).
296    pub fn local_mode(mut self, enabled: bool) -> Self {
297        self.local_mode = enabled;
298        self
299    }
300
301    /// Sets the artifact retention period in days. 0 means no cleanup.
302    pub fn retention_days(mut self, days: u64) -> Self {
303        self.retention_days = days;
304        self
305    }
306
307    /// Sets the interval (in hours) between background cleanup passes.
308    pub fn cleanup_interval_hours(mut self, hours: u64) -> Self {
309        self.cleanup_interval_hours = hours;
310        self
311    }
312}
313
314/// Creates the artifact storage based on configuration.
315pub(crate) async fn create_artifacts(
316    config: &ServerConfig,
317) -> Result<Option<Arc<dyn ArtifactStore>>, ConfigError> {
318    if let Some(url) = &config.artifacts_url {
319        info!(url = %url, "Using object storage for artifacts");
320        let (store, _path) = object_store::parse_url(
321            &url.parse()
322                .map_err(|e| ConfigError::InvalidValue(format!("Invalid artifacts URL: {}", e)))?,
323        )
324        .map_err(|e| ConfigError::InvalidValue(format!("Failed to parse artifacts URL: {}", e)))?;
325
326        Ok(Some(Arc::new(ObjectArtifactStore::new(Arc::from(store)))))
327    } else {
328        Ok(None)
329    }
330}
331
332/// Creates the storage backend based on configuration.
333///
334/// Returns both a `BaselineStore` and an `AuditStore` backed by the same backend.
335#[allow(dead_code)]
336pub(crate) async fn create_storage(
337    config: &ServerConfig,
338) -> Result<(Arc<dyn BaselineStore>, Arc<dyn AuditStore>), ConfigError> {
339    let artifacts = create_artifacts(config).await?;
340    create_storage_with_artifacts(config, artifacts).await
341}
342
343/// Creates the storage backend with a pre-built artifact store.
344pub(crate) async fn create_storage_with_artifacts(
345    config: &ServerConfig,
346    artifacts: Option<Arc<dyn ArtifactStore>>,
347) -> Result<(Arc<dyn BaselineStore>, Arc<dyn AuditStore>), ConfigError> {
348    match config.storage_backend {
349        StorageBackend::Memory => {
350            info!("Using in-memory storage");
351            let store = Arc::new(InMemoryStore::new());
352            Ok((store.clone(), store))
353        }
354        StorageBackend::Sqlite => {
355            let path = config
356                .sqlite_path
357                .clone()
358                .unwrap_or_else(|| PathBuf::from("perfgate.db"));
359            info!(path = %path.display(), "Using SQLite storage");
360            let store = SqliteStore::new(&path, artifacts)
361                .map_err(|e| ConfigError::InvalidValue(format!("Failed to open SQLite: {}", e)))?;
362            let store = Arc::new(store);
363            Ok((store.clone(), store))
364        }
365        StorageBackend::Postgres => {
366            let url = config
367                .postgres_url
368                .clone()
369                .unwrap_or_else(|| "postgres://localhost:5432/perfgate".to_string());
370            info!(url = %url, "Using PostgreSQL storage");
371            let store = PostgresStore::new(&url, artifacts, &config.postgres_pool)
372                .await
373                .map_err(|e| {
374                    ConfigError::InvalidValue(format!("Failed to connect to Postgres: {}", e))
375                })?;
376            let store = Arc::new(store);
377            Ok((store.clone(), store))
378        }
379    }
380}
381
382/// Creates the in-memory API key store from CLI configuration.
383pub(crate) async fn create_key_store(
384    config: &ServerConfig,
385) -> Result<Arc<ApiKeyStore>, ConfigError> {
386    let store = ApiKeyStore::new();
387
388    // Add configured API keys
389    for cfg in &config.api_keys {
390        let mut api_key = ApiKey::new(
391            uuid::Uuid::new_v4().to_string(),
392            format!("{:?} key for {}", cfg.role, cfg.project),
393            cfg.project.clone(),
394            cfg.role,
395        );
396        api_key.benchmark_regex = cfg.benchmark_regex.clone();
397
398        store.add_key(api_key, &cfg.key).await;
399        info!(role = ?cfg.role, project = %cfg.project, "Added API key");
400    }
401
402    Ok(Arc::new(store))
403}
404
405/// Creates the persistent key store based on the storage backend.
406pub(crate) fn create_persistent_key_store(
407    config: &ServerConfig,
408    sqlite_conn: Option<Arc<std::sync::Mutex<rusqlite::Connection>>>,
409) -> Result<Arc<dyn KeyStore>, ConfigError> {
410    match config.storage_backend {
411        StorageBackend::Sqlite => {
412            if let Some(conn) = sqlite_conn {
413                let store = SqliteKeyStore::new(conn).map_err(|e| {
414                    ConfigError::InvalidValue(format!("Failed to create SQLite key store: {}", e))
415                })?;
416                info!("Using SQLite persistent key store");
417                Ok(Arc::new(store))
418            } else {
419                info!("Using in-memory key store (no SQLite connection available)");
420                Ok(Arc::new(InMemoryKeyStore::new()))
421            }
422        }
423        _ => {
424            info!("Using in-memory key store");
425            Ok(Arc::new(InMemoryKeyStore::new()))
426        }
427    }
428}
429
430/// Creates the fleet store (currently always in-memory).
431pub(crate) fn create_fleet_store() -> Arc<dyn FleetStore> {
432    Arc::new(InMemoryFleetStore::new())
433}
434
435/// Creates the router with all routes configured.
436pub(crate) fn create_router(
437    state: AppState,
438    persistent_key_store: Arc<dyn KeyStore>,
439    fleet_store: Arc<dyn FleetStore>,
440    artifact_store: Option<Arc<dyn ArtifactStore>>,
441    auth_state: AuthState,
442    config: &ServerConfig,
443    prometheus_handle: Option<PrometheusHandle>,
444) -> Router {
445    let local_mode = config.local_mode;
446
447    // Health check (no auth required)
448    let health_routes = Router::new().route("/health", get(health_check));
449
450    // Info endpoint: exposes local_mode flag for the dashboard.
451    let info_routes = Router::new().route(
452        "/info",
453        get(move || async move { axum::Json(serde_json::json!({ "local_mode": local_mode })) }),
454    );
455
456    // Dashboard routes (no auth required for read-only view)
457    let dashboard_routes = Router::new()
458        .route("/", get(dashboard_index))
459        .route("/index.html", get(dashboard_index))
460        .route("/assets/{*path}", get(static_asset));
461
462    // Key management routes (admin-only, using the persistent key store as state)
463    let key_routes = Router::new()
464        .route("/keys", post(create_key))
465        .route("/keys", get(list_keys))
466        .route("/keys/{id}", delete(revoke_key))
467        .with_state(persistent_key_store)
468        .layer(middleware::from_fn_with_state(
469            auth_state.clone(),
470            auth_middleware,
471        ));
472
473    // Admin routes (require admin auth, never skipped even in local mode)
474    let admin_routes = Router::new()
475        .route("/admin/cleanup", delete(admin_cleanup))
476        .layer(axum::Extension(artifact_store.clone()))
477        .layer(axum::Extension(DefaultRetentionDays(config.retention_days)))
478        .layer(middleware::from_fn_with_state(
479            auth_state.clone(),
480            auth_middleware,
481        ));
482
483    // Fleet routes (cross-project, no per-project auth scope)
484    let fleet_routes = Router::new()
485        .route("/fleet/dependency-event", post(record_dependency_event))
486        .route("/fleet/alerts", get(list_fleet_alerts))
487        .route(
488            "/fleet/dependency/{dep_name}/impact",
489            get(dependency_impact),
490        )
491        .with_state(fleet_store);
492
493    // API routes — auth middleware is skipped in local mode.
494    let api_routes_inner = Router::new()
495        // Baseline CRUD
496        .route("/projects/{project}/baselines", post(upload_baseline))
497        .route(
498            "/projects/{project}/baselines/{benchmark}/latest",
499            get(get_latest_baseline),
500        )
501        .route(
502            "/projects/{project}/baselines/{benchmark}/versions/{version}",
503            get(get_baseline),
504        )
505        .route(
506            "/projects/{project}/baselines/{benchmark}/versions/{version}",
507            delete(delete_baseline),
508        )
509        .route("/projects/{project}/baselines", get(list_baselines))
510        .route("/projects/{project}/verdicts", post(submit_verdict))
511        .route("/projects/{project}/verdicts", get(list_verdicts))
512        .route(
513            "/projects/{project}/baselines/{benchmark}/promote",
514            post(promote_baseline),
515        )
516        .route(
517            "/projects/{project}/baselines/{benchmark}/trend",
518            get(get_trend),
519        )
520        // Audit log
521        .route("/audit", get(list_audit_events));
522
523    let api_routes = if config.local_mode {
524        api_routes_inner.layer(middleware::from_fn(local_mode_auth_middleware))
525    } else {
526        api_routes_inner.layer(middleware::from_fn_with_state(auth_state, auth_middleware))
527    };
528
529    // Combine routes under /api/v1, plus root /health and dashboard
530    let mut app = Router::new()
531        .merge(dashboard_routes)
532        .merge(health_routes.clone())
533        .nest(
534            "/api/v1",
535            health_routes
536                .merge(info_routes)
537                .merge(api_routes)
538                .merge(key_routes)
539                .merge(admin_routes)
540                .merge(fleet_routes),
541        );
542
543    // Add /metrics endpoint if Prometheus handle is available
544    if let Some(handle) = prometheus_handle {
545        let metrics_routes = Router::new()
546            .route("/metrics", get(metrics_handler))
547            .with_state(handle);
548        app = app.merge(metrics_routes);
549        // Apply metrics middleware to all routes
550        app = app.layer(middleware::from_fn(metrics_middleware));
551    }
552
553    // Add CORS if enabled
554    if config.cors {
555        app = app.layer(
556            CorsLayer::new()
557                .allow_origin(Any)
558                .allow_methods(Any)
559                .allow_headers(Any),
560        );
561    }
562
563    app.with_state(state)
564}
565
566/// Runs the HTTP server.
567///
568/// This function starts the server and blocks until shutdown.
569pub async fn run_server(config: ServerConfig) -> Result<(), Box<dyn std::error::Error>> {
570    info!(
571        bind = %config.bind,
572        backend = ?config.storage_backend,
573        retention_days = config.retention_days,
574        "Starting perfgate server"
575    );
576
577    // Create artifact store (shared between baseline store and cleanup)
578    let artifact_store = create_artifacts(&config).await?;
579
580    // Create storage
581    let (store, audit) = create_storage_with_artifacts(&config, artifact_store.clone()).await?;
582
583    // Create the persistent key store (shares SQLite connection when applicable)
584    let sqlite_conn = if config.storage_backend == StorageBackend::Sqlite {
585        let path = config
586            .sqlite_path
587            .clone()
588            .unwrap_or_else(|| PathBuf::from("perfgate.db"));
589        let conn = rusqlite::Connection::open(&path).map_err(|e| {
590            ConfigError::InvalidValue(format!("Failed to open SQLite for key store: {}", e))
591        })?;
592        Some(Arc::new(std::sync::Mutex::new(conn)))
593    } else {
594        None
595    };
596    let persistent_key_store = create_persistent_key_store(&config, sqlite_conn)?;
597
598    // Create in-memory key store (for CLI-provided keys)
599    let key_store = create_key_store(&config).await?;
600
601    let mut oidc_registry = OidcRegistry::new();
602    for oidc_cfg in &config.oidc_configs {
603        let provider = OidcProvider::new(oidc_cfg.clone())
604            .await
605            .map_err(|e| e.to_string())?;
606        oidc_registry.add(provider);
607    }
608
609    let auth_state = AuthState::new(key_store, config.jwt.clone(), oidc_registry)
610        .with_persistent_key_store(persistent_key_store.clone());
611
612    // Spawn background cleanup task if retention is configured
613    let cleanup_handle = if config.retention_days > 0 {
614        if let Some(ref art_store) = artifact_store {
615            info!(
616                retention_days = config.retention_days,
617                interval_hours = config.cleanup_interval_hours,
618                "Spawning background artifact cleanup task"
619            );
620            Some(spawn_cleanup_task(
621                art_store.clone(),
622                config.retention_days,
623                config.cleanup_interval_hours,
624            ))
625        } else {
626            info!(
627                "Retention policy configured but no artifact store available; skipping background cleanup"
628            );
629            None
630        }
631    } else {
632        None
633    };
634
635    // Install Prometheus metrics recorder
636    let prometheus_handle = setup_metrics_recorder();
637    info!("Prometheus metrics enabled at /metrics");
638
639    let app_state = AppState { store, audit };
640
641    // Create fleet store
642    let fleet_store = create_fleet_store();
643
644    // Create router
645    let app = create_router(
646        app_state,
647        persistent_key_store.clone(),
648        fleet_store,
649        artifact_store,
650        auth_state,
651        &config,
652        Some(prometheus_handle),
653    );
654
655    // Add tracing and request ID layers
656    let app = app.layer(
657        ServiceBuilder::new()
658            .layer(TraceLayer::new_for_http())
659            .layer(tower_http::request_id::SetRequestIdLayer::x_request_id(
660                MakeRequestUuid,
661            )),
662    );
663
664    // Create listener
665    let listener = tokio::net::TcpListener::bind(config.bind).await?;
666    info!(addr = %config.bind, "Server listening");
667
668    // Run server with graceful shutdown
669    axum::serve(listener, app)
670        .with_graceful_shutdown(shutdown_signal())
671        .await?;
672
673    // Abort cleanup task on shutdown
674    if let Some(handle) = cleanup_handle {
675        handle.abort();
676    }
677
678    info!("Server shutdown complete");
679    Ok(())
680}
681
682/// Creates a shutdown signal handler.
683async fn shutdown_signal() {
684    use tokio::signal;
685
686    let ctrl_c = async {
687        signal::ctrl_c()
688            .await
689            .expect("Failed to install Ctrl+C handler");
690    };
691
692    #[cfg(unix)]
693    let terminate = async {
694        signal::unix::signal(signal::unix::SignalKind::terminate())
695            .expect("Failed to install signal handler")
696            .recv()
697            .await;
698    };
699
700    #[cfg(not(unix))]
701    let terminate = std::future::pending::<()>();
702
703    tokio::select! {
704        _ = ctrl_c => {},
705        _ = terminate => {},
706    }
707
708    info!("Shutdown signal received");
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn test_server_config_default() {
717        let config = ServerConfig::new();
718        assert_eq!(config.bind.to_string(), "0.0.0.0:8080");
719        assert_eq!(config.storage_backend, StorageBackend::Memory);
720    }
721
722    #[test]
723    fn test_server_config_builder() {
724        let config = ServerConfig::new()
725            .bind("127.0.0.1:3000")
726            .unwrap()
727            .storage_backend(StorageBackend::Sqlite)
728            .sqlite_path("/tmp/test.db")
729            .api_key("test-key", Role::Admin)
730            .scoped_api_key(
731                "scoped-key",
732                Role::Contributor,
733                "my-proj",
734                Some("^bench-.*$".to_string()),
735            )
736            .jwt(JwtConfig::hs256(b"test-secret".to_vec()).issuer("perfgate"))
737            .cors(false);
738
739        assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
740        assert_eq!(config.storage_backend, StorageBackend::Sqlite);
741        assert_eq!(config.sqlite_path, Some(PathBuf::from("/tmp/test.db")));
742        assert_eq!(config.api_keys.len(), 2);
743        assert_eq!(config.api_keys[1].project, "my-proj");
744        assert_eq!(
745            config.api_keys[1].benchmark_regex,
746            Some("^bench-.*$".to_string())
747        );
748        assert!(config.jwt.is_some());
749        assert!(!config.cors);
750    }
751
752    #[test]
753    fn test_storage_backend_from_str() {
754        assert_eq!(
755            "memory".parse::<StorageBackend>().unwrap(),
756            StorageBackend::Memory
757        );
758        assert_eq!(
759            "sqlite".parse::<StorageBackend>().unwrap(),
760            StorageBackend::Sqlite
761        );
762        assert_eq!(
763            "postgres".parse::<StorageBackend>().unwrap(),
764            StorageBackend::Postgres
765        );
766        assert!("invalid".parse::<StorageBackend>().is_err());
767    }
768
769    #[tokio::test]
770    async fn test_create_storage_memory() {
771        let config = ServerConfig::new().storage_backend(StorageBackend::Memory);
772        let (storage, _audit) = create_storage(&config).await.unwrap();
773        assert_eq!(storage.backend_type(), "memory");
774    }
775
776    #[tokio::test(flavor = "multi_thread")]
777    async fn test_create_storage_sqlite() {
778        let config = ServerConfig::new()
779            .storage_backend(StorageBackend::Sqlite)
780            .sqlite_path(":memory:");
781        let (storage, _audit) = create_storage(&config).await.unwrap();
782        assert_eq!(storage.backend_type(), "sqlite");
783    }
784
785    #[tokio::test]
786    async fn test_create_storage_postgres() {
787        let config = ServerConfig::new()
788            .storage_backend(StorageBackend::Postgres)
789            .postgres_url("postgresql://localhost/test");
790        let result = create_storage(&config).await;
791        // Should fail because no Postgres is running
792        assert!(result.is_err());
793    }
794
795    #[tokio::test]
796    async fn test_create_key_store() {
797        let config = ServerConfig::new()
798            .api_key("pg_live_test123456789012345678901234567890", Role::Admin)
799            .scoped_api_key(
800                "pg_live_viewer123456789012345678901234567",
801                Role::Viewer,
802                "project-1",
803                None,
804            );
805
806        let key_store = create_key_store(&config).await.unwrap();
807        let keys = key_store.list_keys().await;
808
809        assert_eq!(keys.len(), 2);
810        let viewer_key = keys.iter().find(|k| k.role == Role::Viewer).unwrap();
811        assert_eq!(viewer_key.project_id, "project-1");
812    }
813
814    #[tokio::test]
815    async fn test_router_creation() {
816        let store = Arc::new(InMemoryStore::new());
817        let persistent_key_store: Arc<dyn KeyStore> = Arc::new(InMemoryKeyStore::new());
818        let fleet_store = create_fleet_store();
819        let auth_state = AuthState::new(Arc::new(ApiKeyStore::new()), None, Default::default());
820        let config = ServerConfig::new();
821        let app_state = AppState {
822            store: store.clone(),
823            audit: store,
824        };
825
826        let _router = create_router(
827            app_state,
828            persistent_key_store,
829            fleet_store,
830            None,
831            auth_state,
832            &config,
833            None,
834        );
835        // Router created successfully
836    }
837
838    #[tokio::test]
839    async fn test_router_local_mode_injects_auth_context_for_api_routes() {
840        let store = Arc::new(InMemoryStore::new());
841        let persistent_key_store: Arc<dyn KeyStore> = Arc::new(InMemoryKeyStore::new());
842        let fleet_store = create_fleet_store();
843        let auth_state = AuthState::new(Arc::new(ApiKeyStore::new()), None, Default::default());
844        let config = ServerConfig::new().local_mode(true);
845        let app_state = AppState {
846            store: store.clone(),
847            audit: store,
848        };
849
850        let router = create_router(
851            app_state,
852            persistent_key_store,
853            fleet_store,
854            None,
855            auth_state,
856            &config,
857            None,
858        );
859
860        let response = tower::ServiceExt::oneshot(
861            router,
862            axum::http::Request::builder()
863                .uri("/api/v1/projects/test/baselines")
864                .body(axum::body::Body::empty())
865                .unwrap(),
866        )
867        .await
868        .unwrap();
869
870        assert_eq!(response.status(), axum::http::StatusCode::OK);
871    }
872
873    #[test]
874    fn test_postgres_pool_config_defaults() {
875        let cfg = PostgresPoolConfig::default();
876        assert_eq!(cfg.max_connections, 10);
877        assert_eq!(cfg.min_connections, 2);
878        assert_eq!(cfg.idle_timeout, Duration::from_secs(300));
879        assert_eq!(cfg.max_lifetime, Duration::from_secs(1800));
880        assert_eq!(cfg.acquire_timeout, Duration::from_secs(5));
881        assert_eq!(cfg.statement_timeout, Duration::from_secs(30));
882    }
883
884    #[test]
885    fn test_server_config_with_postgres_pool() {
886        let pool_config = PostgresPoolConfig {
887            max_connections: 20,
888            min_connections: 5,
889            idle_timeout: Duration::from_secs(120),
890            max_lifetime: Duration::from_secs(3600),
891            acquire_timeout: Duration::from_secs(10),
892            statement_timeout: Duration::from_secs(60),
893        };
894
895        let config = ServerConfig::new()
896            .storage_backend(StorageBackend::Postgres)
897            .postgres_url("postgres://localhost:5432/perfgate")
898            .postgres_pool(pool_config);
899
900        assert_eq!(config.postgres_pool.max_connections, 20);
901        assert_eq!(config.postgres_pool.min_connections, 5);
902        assert_eq!(config.postgres_pool.idle_timeout, Duration::from_secs(120));
903        assert_eq!(config.postgres_pool.max_lifetime, Duration::from_secs(3600));
904        assert_eq!(
905            config.postgres_pool.acquire_timeout,
906            Duration::from_secs(10)
907        );
908        assert_eq!(
909            config.postgres_pool.statement_timeout,
910            Duration::from_secs(60)
911        );
912    }
913
914    #[tokio::test]
915    async fn test_health_endpoint_no_pool_for_memory() {
916        let store: Arc<dyn crate::storage::BaselineStore> = Arc::new(InMemoryStore::new());
917        assert!(store.pool_metrics().is_none());
918    }
919}