1use 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 chrono::{DateTime, Utc};
16use tower::ServiceBuilder;
17use tower_http::{
18 cors::{Any, CorsLayer},
19 request_id::MakeRequestUuid,
20 trace::TraceLayer,
21};
22use tracing::info;
23
24use crate::auth::{
25 ApiKey, ApiKeyStore, AuthState, JwtConfig, Role, auth_middleware, local_mode_auth_middleware,
26};
27use crate::cleanup::spawn_cleanup_task;
28use crate::error::ConfigError;
29use crate::handlers::{
30 DefaultRetentionDays, admin_cleanup, create_key, dashboard_index, delete_baseline,
31 dependency_impact, get_baseline, get_latest_baseline, get_trend, health_check, latest_decision,
32 list_audit_events, list_baselines, list_decisions, list_fleet_alerts, list_keys, list_verdicts,
33 promote_baseline, prune_decisions, record_dependency_event, revoke_key, static_asset,
34 submit_verdict, upload_baseline, upload_decision,
35};
36use crate::metrics::{metrics_handler, metrics_middleware, setup_metrics_recorder};
37use crate::oidc::{OidcConfig, OidcProvider, OidcRegistry};
38use crate::storage::fleet::{FleetStore, InMemoryFleetStore};
39use crate::storage::{
40 ArtifactStore, AuditStore, BaselineStore, InMemoryKeyStore, InMemoryStore, KeyStore,
41 ObjectArtifactStore, PostgresStore, SqliteKeyStore, SqliteStore,
42 open_configured_sqlite_connection,
43};
44use metrics_exporter_prometheus::PrometheusHandle;
45
46#[derive(Clone)]
48pub struct AppState {
49 pub store: Arc<dyn BaselineStore>,
51 pub audit: Arc<dyn AuditStore>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum StorageBackend {
58 #[default]
60 Memory,
61 Sqlite,
63 Postgres,
65}
66
67impl std::str::FromStr for StorageBackend {
68 type Err = String;
69
70 fn from_str(s: &str) -> Result<Self, Self::Err> {
71 match s.to_lowercase().as_str() {
72 "memory" => Ok(Self::Memory),
73 "sqlite" => Ok(Self::Sqlite),
74 "postgres" | "postgresql" => Ok(Self::Postgres),
75 _ => Err(format!("Unknown storage backend: {}", s)),
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
101pub struct PostgresPoolConfig {
102 pub max_connections: u32,
104 pub min_connections: u32,
106 pub idle_timeout: Duration,
108 pub max_lifetime: Duration,
110 pub acquire_timeout: Duration,
112 pub statement_timeout: Duration,
115}
116
117impl Default for PostgresPoolConfig {
118 fn default() -> Self {
119 Self {
120 max_connections: 10,
121 min_connections: 2,
122 idle_timeout: Duration::from_secs(300),
123 max_lifetime: Duration::from_secs(1800),
124 acquire_timeout: Duration::from_secs(5),
125 statement_timeout: Duration::from_secs(30),
126 }
127 }
128}
129
130#[derive(Debug, Clone)]
132pub struct ApiKeyConfig {
133 pub id: Option<String>,
135 pub name: Option<String>,
137 pub key: String,
139 pub role: Role,
141 pub project: String,
143 pub benchmark_regex: Option<String>,
145 pub expires_at: Option<DateTime<Utc>>,
147}
148
149#[derive(Debug, Clone, Default)]
151pub struct ApiKeyMetadata {
152 pub id: Option<String>,
153 pub name: Option<String>,
154 pub expires_at: Option<DateTime<Utc>>,
155}
156
157#[derive(Debug, Clone)]
159pub struct ServerConfig {
160 pub bind: SocketAddr,
162
163 pub storage_backend: StorageBackend,
165
166 pub sqlite_path: Option<PathBuf>,
168
169 pub postgres_url: Option<String>,
171
172 pub postgres_pool: PostgresPoolConfig,
174
175 pub artifacts_url: Option<String>,
177
178 pub api_keys: Vec<ApiKeyConfig>,
180
181 pub jwt: Option<JwtConfig>,
183
184 pub oidc_configs: Vec<OidcConfig>,
186
187 pub cors: bool,
189
190 pub timeout_seconds: u64,
192
193 pub local_mode: bool,
195
196 pub retention_days: u64,
198
199 pub cleanup_interval_hours: u64,
201}
202
203impl Default for ServerConfig {
204 fn default() -> Self {
205 Self {
206 bind: "0.0.0.0:8080".parse().unwrap(),
207 storage_backend: StorageBackend::Memory,
208 sqlite_path: None,
209 postgres_url: None,
210 postgres_pool: PostgresPoolConfig::default(),
211 artifacts_url: None,
212 api_keys: vec![],
213 jwt: None,
214 oidc_configs: vec![],
215 cors: true,
216 timeout_seconds: 30,
217 local_mode: false,
218 retention_days: 0,
219 cleanup_interval_hours: 1,
220 }
221 }
222}
223
224impl ServerConfig {
225 pub fn new() -> Self {
227 Self::default()
228 }
229
230 pub fn bind(mut self, addr: impl Into<String>) -> Result<Self, ConfigError> {
232 self.bind = addr
233 .into()
234 .parse()
235 .map_err(|e| ConfigError::InvalidValue(format!("Invalid bind address: {}", e)))?;
236 Ok(self)
237 }
238
239 pub fn storage_backend(mut self, backend: StorageBackend) -> Self {
241 self.storage_backend = backend;
242 self
243 }
244
245 pub fn sqlite_path(mut self, path: impl Into<PathBuf>) -> Self {
247 self.sqlite_path = Some(path.into());
248 self
249 }
250
251 pub fn postgres_url(mut self, url: impl Into<String>) -> Self {
253 self.postgres_url = Some(url.into());
254 self
255 }
256
257 pub fn postgres_pool(mut self, pool_config: PostgresPoolConfig) -> Self {
259 self.postgres_pool = pool_config;
260 self
261 }
262
263 pub fn artifacts_url(mut self, url: impl Into<String>) -> Self {
265 self.artifacts_url = Some(url.into());
266 self
267 }
268
269 pub fn api_key(self, key: impl Into<String>, role: Role) -> Self {
271 self.scoped_api_key(key, role, "default", None)
272 }
273
274 pub fn scoped_api_key(
276 self,
277 key: impl Into<String>,
278 role: Role,
279 project: impl Into<String>,
280 benchmark_regex: Option<String>,
281 ) -> Self {
282 self.scoped_api_key_with_metadata(
283 key,
284 role,
285 project,
286 benchmark_regex,
287 ApiKeyMetadata::default(),
288 )
289 }
290
291 pub fn scoped_api_key_with_metadata(
293 mut self,
294 key: impl Into<String>,
295 role: Role,
296 project: impl Into<String>,
297 benchmark_regex: Option<String>,
298 metadata: ApiKeyMetadata,
299 ) -> Self {
300 self.api_keys.push(ApiKeyConfig {
301 id: metadata.id,
302 name: metadata.name,
303 key: key.into(),
304 role,
305 project: project.into(),
306 benchmark_regex,
307 expires_at: metadata.expires_at,
308 });
309 self
310 }
311
312 pub fn jwt(mut self, jwt: JwtConfig) -> Self {
314 self.jwt = Some(jwt);
315 self
316 }
317
318 pub fn oidc(mut self, config: OidcConfig) -> Self {
322 self.oidc_configs.push(config);
323 self
324 }
325
326 pub fn cors(mut self, enabled: bool) -> Self {
328 self.cors = enabled;
329 self
330 }
331
332 pub fn local_mode(mut self, enabled: bool) -> Self {
334 self.local_mode = enabled;
335 self
336 }
337
338 pub fn retention_days(mut self, days: u64) -> Self {
340 self.retention_days = days;
341 self
342 }
343
344 pub fn cleanup_interval_hours(mut self, hours: u64) -> Self {
346 self.cleanup_interval_hours = hours;
347 self
348 }
349}
350
351pub(crate) async fn create_artifacts(
353 config: &ServerConfig,
354) -> Result<Option<Arc<dyn ArtifactStore>>, ConfigError> {
355 if let Some(url) = &config.artifacts_url {
356 info!(url = %url, "Using object storage for artifacts");
357 let (store, _path) = object_store::parse_url(
358 &url.parse()
359 .map_err(|e| ConfigError::InvalidValue(format!("Invalid artifacts URL: {}", e)))?,
360 )
361 .map_err(|e| ConfigError::InvalidValue(format!("Failed to parse artifacts URL: {}", e)))?;
362
363 Ok(Some(Arc::new(ObjectArtifactStore::new(Arc::from(store)))))
364 } else {
365 Ok(None)
366 }
367}
368
369#[cfg(any(test, feature = "test-utils"))]
373pub(crate) async fn create_storage(
374 config: &ServerConfig,
375) -> Result<(Arc<dyn BaselineStore>, Arc<dyn AuditStore>), ConfigError> {
376 let artifacts = create_artifacts(config).await?;
377 create_storage_with_artifacts(config, artifacts).await
378}
379
380pub(crate) async fn create_storage_with_artifacts(
382 config: &ServerConfig,
383 artifacts: Option<Arc<dyn ArtifactStore>>,
384) -> Result<(Arc<dyn BaselineStore>, Arc<dyn AuditStore>), ConfigError> {
385 match config.storage_backend {
386 StorageBackend::Memory => {
387 info!("Using in-memory storage");
388 let store = Arc::new(InMemoryStore::new());
389 Ok((store.clone(), store))
390 }
391 StorageBackend::Sqlite => {
392 let path = config
393 .sqlite_path
394 .clone()
395 .unwrap_or_else(|| PathBuf::from("perfgate.db"));
396 info!(path = %path.display(), "Using SQLite storage");
397 let store = SqliteStore::new(&path, artifacts)
398 .map_err(|e| ConfigError::InvalidValue(format!("Failed to open SQLite: {}", e)))?;
399 let store = Arc::new(store);
400 Ok((store.clone(), store))
401 }
402 StorageBackend::Postgres => {
403 let url = config
404 .postgres_url
405 .clone()
406 .unwrap_or_else(|| "postgres://localhost:5432/perfgate".to_string());
407 info!(url = %url, "Using PostgreSQL storage");
408 let store = PostgresStore::new(&url, artifacts, &config.postgres_pool)
409 .await
410 .map_err(|e| {
411 ConfigError::InvalidValue(format!("Failed to connect to Postgres: {}", e))
412 })?;
413 let store = Arc::new(store);
414 Ok((store.clone(), store))
415 }
416 }
417}
418
419pub(crate) async fn create_key_store(
421 config: &ServerConfig,
422) -> Result<Arc<ApiKeyStore>, ConfigError> {
423 let store = ApiKeyStore::new();
424
425 for cfg in &config.api_keys {
427 let key_id = cfg
428 .id
429 .clone()
430 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
431 let mut api_key = ApiKey::new(
432 key_id.clone(),
433 cfg.name
434 .clone()
435 .or_else(|| cfg.id.clone())
436 .unwrap_or_else(|| format!("{:?} key for {}", cfg.role, cfg.project)),
437 cfg.project.clone(),
438 cfg.role,
439 );
440 api_key.benchmark_regex = cfg.benchmark_regex.clone();
441 api_key.expires_at = cfg.expires_at;
442
443 store.add_key(api_key, &cfg.key).await;
444 info!(key_id = %key_id, role = ?cfg.role, project = %cfg.project, "Added API key");
445 }
446
447 Ok(Arc::new(store))
448}
449
450pub(crate) fn create_persistent_key_store(
452 config: &ServerConfig,
453 sqlite_conn: Option<Arc<std::sync::Mutex<rusqlite::Connection>>>,
454) -> Result<Arc<dyn KeyStore>, ConfigError> {
455 match config.storage_backend {
456 StorageBackend::Sqlite => {
457 if let Some(conn) = sqlite_conn {
458 let store = SqliteKeyStore::new(conn).map_err(|e| {
459 ConfigError::InvalidValue(format!("Failed to create SQLite key store: {}", e))
460 })?;
461 info!("Using SQLite persistent key store");
462 Ok(Arc::new(store))
463 } else {
464 info!("Using in-memory key store (no SQLite connection available)");
465 Ok(Arc::new(InMemoryKeyStore::new()))
466 }
467 }
468 _ => {
469 info!("Using in-memory key store");
470 Ok(Arc::new(InMemoryKeyStore::new()))
471 }
472 }
473}
474
475pub(crate) fn create_fleet_store() -> Arc<dyn FleetStore> {
477 Arc::new(InMemoryFleetStore::new())
478}
479
480pub(crate) fn create_router(
482 state: AppState,
483 persistent_key_store: Arc<dyn KeyStore>,
484 fleet_store: Arc<dyn FleetStore>,
485 artifact_store: Option<Arc<dyn ArtifactStore>>,
486 auth_state: AuthState,
487 config: &ServerConfig,
488 prometheus_handle: Option<PrometheusHandle>,
489) -> Router {
490 let local_mode = config.local_mode;
491
492 let health_routes = Router::new().route("/health", get(health_check));
494
495 let info_routes = Router::new().route(
497 "/info",
498 get(move || async move { axum::Json(serde_json::json!({ "local_mode": local_mode })) }),
499 );
500
501 let dashboard_routes = Router::new()
503 .route("/", get(dashboard_index))
504 .route("/index.html", get(dashboard_index))
505 .route("/assets/{*path}", get(static_asset));
506
507 let key_routes = Router::new()
509 .route("/keys", post(create_key))
510 .route("/keys", get(list_keys))
511 .route("/keys/{id}", delete(revoke_key))
512 .layer(axum::Extension(persistent_key_store))
513 .layer(middleware::from_fn_with_state(
514 auth_state.clone(),
515 auth_middleware,
516 ))
517 .with_state(state.clone());
518
519 let admin_routes = Router::new()
521 .route("/admin/cleanup", delete(admin_cleanup))
522 .layer(axum::Extension(artifact_store.clone()))
523 .layer(axum::Extension(DefaultRetentionDays(config.retention_days)))
524 .layer(middleware::from_fn_with_state(
525 auth_state.clone(),
526 auth_middleware,
527 ));
528
529 let fleet_routes = Router::new()
531 .route("/fleet/dependency-event", post(record_dependency_event))
532 .route("/fleet/alerts", get(list_fleet_alerts))
533 .route(
534 "/fleet/dependency/{dep_name}/impact",
535 get(dependency_impact),
536 )
537 .with_state(fleet_store);
538
539 let api_routes_inner = Router::new()
541 .route("/projects/{project}/baselines", post(upload_baseline))
543 .route(
544 "/projects/{project}/baselines/{benchmark}/latest",
545 get(get_latest_baseline),
546 )
547 .route(
548 "/projects/{project}/baselines/{benchmark}/versions/{version}",
549 get(get_baseline),
550 )
551 .route(
552 "/projects/{project}/baselines/{benchmark}/versions/{version}",
553 delete(delete_baseline),
554 )
555 .route("/projects/{project}/baselines", get(list_baselines))
556 .route("/projects/{project}/verdicts", post(submit_verdict))
557 .route("/projects/{project}/verdicts", get(list_verdicts))
558 .route("/projects/{project}/decisions", post(upload_decision))
559 .route("/projects/{project}/decisions", get(list_decisions))
560 .route("/projects/{project}/decisions/latest", get(latest_decision))
561 .route("/projects/{project}/decisions/prune", post(prune_decisions))
562 .route(
563 "/projects/{project}/baselines/{benchmark}/promote",
564 post(promote_baseline),
565 )
566 .route(
567 "/projects/{project}/baselines/{benchmark}/trend",
568 get(get_trend),
569 )
570 .route("/audit", get(list_audit_events));
572
573 let api_routes = if config.local_mode {
574 api_routes_inner.layer(middleware::from_fn(local_mode_auth_middleware))
575 } else {
576 api_routes_inner.layer(middleware::from_fn_with_state(auth_state, auth_middleware))
577 };
578
579 let mut app = Router::new()
581 .merge(dashboard_routes)
582 .merge(health_routes.clone())
583 .nest(
584 "/api/v1",
585 health_routes
586 .merge(info_routes)
587 .merge(api_routes)
588 .merge(key_routes)
589 .merge(admin_routes)
590 .merge(fleet_routes),
591 );
592
593 if let Some(handle) = prometheus_handle {
595 let metrics_routes = Router::new()
596 .route("/metrics", get(metrics_handler))
597 .with_state(handle);
598 app = app.merge(metrics_routes);
599 app = app.layer(middleware::from_fn(metrics_middleware));
601 }
602
603 if config.cors {
605 app = app.layer(
606 CorsLayer::new()
607 .allow_origin(Any)
608 .allow_methods(Any)
609 .allow_headers(Any),
610 );
611 }
612
613 app.with_state(state)
614}
615
616pub async fn run_server(config: ServerConfig) -> Result<(), Box<dyn std::error::Error>> {
620 info!(
621 bind = %config.bind,
622 backend = ?config.storage_backend,
623 retention_days = config.retention_days,
624 "Starting perfgate server"
625 );
626
627 let artifact_store = create_artifacts(&config).await?;
629
630 let (store, audit) = create_storage_with_artifacts(&config, artifact_store.clone()).await?;
632
633 let sqlite_conn = if config.storage_backend == StorageBackend::Sqlite {
635 let path = config
636 .sqlite_path
637 .clone()
638 .unwrap_or_else(|| PathBuf::from("perfgate.db"));
639 let conn = open_configured_sqlite_connection(&path).map_err(|e| {
640 ConfigError::InvalidValue(format!("Failed to open SQLite for key store: {}", e))
641 })?;
642 Some(Arc::new(std::sync::Mutex::new(conn)))
643 } else {
644 None
645 };
646 let persistent_key_store = create_persistent_key_store(&config, sqlite_conn)?;
647
648 let key_store = create_key_store(&config).await?;
650
651 let mut oidc_registry = OidcRegistry::new();
652 for oidc_cfg in &config.oidc_configs {
653 let provider = OidcProvider::new(oidc_cfg.clone())
654 .await
655 .map_err(|e| e.to_string())?;
656 oidc_registry.add(provider);
657 }
658
659 let auth_state = AuthState::new(key_store, config.jwt.clone(), oidc_registry)
660 .with_persistent_key_store(persistent_key_store.clone());
661
662 let cleanup_handle = if config.retention_days > 0 {
664 if let Some(ref art_store) = artifact_store {
665 info!(
666 retention_days = config.retention_days,
667 interval_hours = config.cleanup_interval_hours,
668 "Spawning background artifact cleanup task"
669 );
670 Some(spawn_cleanup_task(
671 art_store.clone(),
672 config.retention_days,
673 config.cleanup_interval_hours,
674 ))
675 } else {
676 info!(
677 "Retention policy configured but no artifact store available; skipping background cleanup"
678 );
679 None
680 }
681 } else {
682 None
683 };
684
685 let prometheus_handle = setup_metrics_recorder();
687 info!("Prometheus metrics enabled at /metrics");
688
689 let app_state = AppState { store, audit };
690
691 let fleet_store = create_fleet_store();
693
694 let app = create_router(
696 app_state,
697 persistent_key_store.clone(),
698 fleet_store,
699 artifact_store,
700 auth_state,
701 &config,
702 Some(prometheus_handle),
703 );
704
705 let app = app.layer(
707 ServiceBuilder::new()
708 .layer(TraceLayer::new_for_http())
709 .layer(tower_http::request_id::SetRequestIdLayer::x_request_id(
710 MakeRequestUuid,
711 )),
712 );
713
714 let listener = tokio::net::TcpListener::bind(config.bind).await?;
716 info!(addr = %config.bind, "Server listening");
717
718 axum::serve(listener, app)
720 .with_graceful_shutdown(shutdown_signal())
721 .await?;
722
723 if let Some(handle) = cleanup_handle {
725 handle.abort();
726 }
727
728 info!("Server shutdown complete");
729 Ok(())
730}
731
732async fn shutdown_signal() {
734 use tokio::signal;
735
736 let ctrl_c = async {
737 signal::ctrl_c()
738 .await
739 .expect("Failed to install Ctrl+C handler");
740 };
741
742 #[cfg(unix)]
743 let terminate = async {
744 signal::unix::signal(signal::unix::SignalKind::terminate())
745 .expect("Failed to install signal handler")
746 .recv()
747 .await;
748 };
749
750 #[cfg(not(unix))]
751 let terminate = std::future::pending::<()>();
752
753 tokio::select! {
754 _ = ctrl_c => {},
755 _ = terminate => {},
756 }
757
758 info!("Shutdown signal received");
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn test_server_config_default() {
767 let config = ServerConfig::new();
768 assert_eq!(config.bind.to_string(), "0.0.0.0:8080");
769 assert_eq!(config.storage_backend, StorageBackend::Memory);
770 }
771
772 #[test]
773 fn test_server_config_builder() {
774 let config = ServerConfig::new()
775 .bind("127.0.0.1:3000")
776 .unwrap()
777 .storage_backend(StorageBackend::Sqlite)
778 .sqlite_path("/tmp/test.db")
779 .api_key("test-key", Role::Admin)
780 .scoped_api_key(
781 "scoped-key",
782 Role::Contributor,
783 "my-proj",
784 Some("^bench-.*$".to_string()),
785 )
786 .jwt(JwtConfig::hs256(b"test-secret".to_vec()).issuer("perfgate"))
787 .cors(false);
788
789 assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
790 assert_eq!(config.storage_backend, StorageBackend::Sqlite);
791 assert_eq!(config.sqlite_path, Some(PathBuf::from("/tmp/test.db")));
792 assert_eq!(config.api_keys.len(), 2);
793 assert_eq!(config.api_keys[1].project, "my-proj");
794 assert_eq!(
795 config.api_keys[1].benchmark_regex,
796 Some("^bench-.*$".to_string())
797 );
798 assert!(config.jwt.is_some());
799 assert!(!config.cors);
800 }
801
802 #[test]
803 fn test_storage_backend_from_str() {
804 assert_eq!(
805 "memory".parse::<StorageBackend>().unwrap(),
806 StorageBackend::Memory
807 );
808 assert_eq!(
809 "sqlite".parse::<StorageBackend>().unwrap(),
810 StorageBackend::Sqlite
811 );
812 assert_eq!(
813 "postgres".parse::<StorageBackend>().unwrap(),
814 StorageBackend::Postgres
815 );
816 assert!("invalid".parse::<StorageBackend>().is_err());
817 }
818
819 #[tokio::test]
820 async fn test_create_storage_memory() {
821 let config = ServerConfig::new().storage_backend(StorageBackend::Memory);
822 let (storage, _audit) = create_storage(&config).await.unwrap();
823 assert_eq!(storage.backend_type(), "memory");
824 }
825
826 #[tokio::test(flavor = "multi_thread")]
827 async fn test_create_storage_sqlite() {
828 let config = ServerConfig::new()
829 .storage_backend(StorageBackend::Sqlite)
830 .sqlite_path(":memory:");
831 let (storage, _audit) = create_storage(&config).await.unwrap();
832 assert_eq!(storage.backend_type(), "sqlite");
833 }
834
835 #[tokio::test]
836 async fn test_create_storage_postgres() {
837 let config = ServerConfig::new()
838 .storage_backend(StorageBackend::Postgres)
839 .postgres_url("postgresql://localhost/test");
840 let result = create_storage(&config).await;
841 assert!(result.is_err());
843 }
844
845 #[tokio::test]
846 async fn test_create_key_store() {
847 let config = ServerConfig::new()
848 .api_key("pg_live_test123456789012345678901234567890", Role::Admin)
849 .scoped_api_key(
850 "pg_live_viewer123456789012345678901234567",
851 Role::Viewer,
852 "project-1",
853 None,
854 );
855
856 let key_store = create_key_store(&config).await.unwrap();
857 let keys = key_store.list_keys().await;
858
859 assert_eq!(keys.len(), 2);
860 let viewer_key = keys.iter().find(|k| k.role == Role::Viewer).unwrap();
861 assert_eq!(viewer_key.project_id, "project-1");
862 }
863
864 #[tokio::test]
865 async fn test_create_key_store_preserves_external_metadata() {
866 let expires_at = Utc::now() + chrono::Duration::hours(1);
867 let config = ServerConfig::new().scoped_api_key_with_metadata(
868 "pg_live_external123456789012345678901234",
869 Role::Contributor,
870 "project-2",
871 Some("^bench-.*$".to_string()),
872 ApiKeyMetadata {
873 id: Some("external-key-1".to_string()),
874 name: Some("external-key-1".to_string()),
875 expires_at: Some(expires_at),
876 },
877 );
878
879 let key_store = create_key_store(&config).await.unwrap();
880 let keys = key_store.list_keys().await;
881
882 assert_eq!(keys.len(), 1);
883 assert_eq!(keys[0].id, "external-key-1");
884 assert_eq!(keys[0].name, "external-key-1");
885 assert_eq!(keys[0].expires_at, Some(expires_at));
886 assert_eq!(keys[0].benchmark_regex.as_deref(), Some("^bench-.*$"));
887 }
888
889 #[tokio::test]
890 async fn test_router_creation() {
891 let store = Arc::new(InMemoryStore::new());
892 let persistent_key_store: Arc<dyn KeyStore> = Arc::new(InMemoryKeyStore::new());
893 let fleet_store = create_fleet_store();
894 let auth_state = AuthState::new(Arc::new(ApiKeyStore::new()), None, Default::default());
895 let config = ServerConfig::new();
896 let app_state = AppState {
897 store: store.clone(),
898 audit: store,
899 };
900
901 let _router = create_router(
902 app_state,
903 persistent_key_store,
904 fleet_store,
905 None,
906 auth_state,
907 &config,
908 None,
909 );
910 }
912
913 #[tokio::test]
914 async fn test_router_local_mode_injects_auth_context_for_api_routes() {
915 let store = Arc::new(InMemoryStore::new());
916 let persistent_key_store: Arc<dyn KeyStore> = Arc::new(InMemoryKeyStore::new());
917 let fleet_store = create_fleet_store();
918 let auth_state = AuthState::new(Arc::new(ApiKeyStore::new()), None, Default::default());
919 let config = ServerConfig::new().local_mode(true);
920 let app_state = AppState {
921 store: store.clone(),
922 audit: store,
923 };
924
925 let router = create_router(
926 app_state,
927 persistent_key_store,
928 fleet_store,
929 None,
930 auth_state,
931 &config,
932 None,
933 );
934
935 let response = tower::ServiceExt::oneshot(
936 router,
937 axum::http::Request::builder()
938 .uri("/api/v1/projects/test/baselines")
939 .body(axum::body::Body::empty())
940 .unwrap(),
941 )
942 .await
943 .unwrap();
944
945 assert_eq!(response.status(), axum::http::StatusCode::OK);
946 }
947
948 #[test]
949 fn test_postgres_pool_config_defaults() {
950 let cfg = PostgresPoolConfig::default();
951 assert_eq!(cfg.max_connections, 10);
952 assert_eq!(cfg.min_connections, 2);
953 assert_eq!(cfg.idle_timeout, Duration::from_secs(300));
954 assert_eq!(cfg.max_lifetime, Duration::from_secs(1800));
955 assert_eq!(cfg.acquire_timeout, Duration::from_secs(5));
956 assert_eq!(cfg.statement_timeout, Duration::from_secs(30));
957 }
958
959 #[test]
960 fn test_server_config_with_postgres_pool() {
961 let pool_config = PostgresPoolConfig {
962 max_connections: 20,
963 min_connections: 5,
964 idle_timeout: Duration::from_secs(120),
965 max_lifetime: Duration::from_secs(3600),
966 acquire_timeout: Duration::from_secs(10),
967 statement_timeout: Duration::from_secs(60),
968 };
969
970 let config = ServerConfig::new()
971 .storage_backend(StorageBackend::Postgres)
972 .postgres_url("postgres://localhost:5432/perfgate")
973 .postgres_pool(pool_config);
974
975 assert_eq!(config.postgres_pool.max_connections, 20);
976 assert_eq!(config.postgres_pool.min_connections, 5);
977 assert_eq!(config.postgres_pool.idle_timeout, Duration::from_secs(120));
978 assert_eq!(config.postgres_pool.max_lifetime, Duration::from_secs(3600));
979 assert_eq!(
980 config.postgres_pool.acquire_timeout,
981 Duration::from_secs(10)
982 );
983 assert_eq!(
984 config.postgres_pool.statement_timeout,
985 Duration::from_secs(60)
986 );
987 }
988
989 #[tokio::test]
990 async fn test_health_endpoint_no_pool_for_memory() {
991 let store: Arc<dyn crate::storage::BaselineStore> = Arc::new(InMemoryStore::new());
992 assert!(store.pool_metrics().is_none());
993 }
994}