//! Integration tests for API endpoints. //! Requires TEST_DATABASE_URL to be set. use axum::{ body::Body, http::{Request, StatusCode}, }; use tower::ServiceExt; async fn get_pool() -> Option { let url = match std::env::var("TEST_DATABASE_URL") { Ok(url) => url, Err(_) => { println!("Skipping API test: TEST_DATABASE_URL not set"); return None; }, }; let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(5) .connect(&url) .await .ok()?; sqlx::migrate!("../common/migrations") .run(&pool) .await .ok()?; Some(pool) } fn build_app(pool: sqlx::PgPool) -> axum::Router { let config = fc_common::config::Config::default(); let server_config = config.server.clone(); let state = fc_server::state::AppState { pool, config, sessions: std::sync::Arc::new(dashmap::DashMap::new()), }; fc_server::routes::router(state, &server_config) } fn build_app_with_config( pool: sqlx::PgPool, config: fc_common::config::Config, ) -> axum::Router { let server_config = config.server.clone(); let state = fc_server::state::AppState { pool, config, sessions: std::sync::Arc::new(dashmap::DashMap::new()), }; fc_server::routes::router(state, &server_config) } // ---- Existing tests ---- #[tokio::test] async fn test_health_endpoint() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let response = app .oneshot( Request::builder() .uri("/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["status"], "ok"); assert_eq!(json["database"], true); } #[tokio::test] async fn test_project_endpoints() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); // Create project let create_body = serde_json::json!({ "name": format!("api-test-{}", uuid::Uuid::new_v4()), "repository_url": "https://github.com/test/repo", "description": "Test project" }); let response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/v1/projects") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&create_body).unwrap())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let project: serde_json::Value = serde_json::from_slice(&body).unwrap(); let project_id = project["id"].as_str().unwrap(); // Get project let response = app .clone() .oneshot( Request::builder() .uri(format!("/api/v1/projects/{project_id}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // List projects let response = app .clone() .oneshot( Request::builder() .uri("/api/v1/projects") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // Get non-existent project -> 404 let fake_id = uuid::Uuid::new_v4(); let response = app .clone() .oneshot( Request::builder() .uri(format!("/api/v1/projects/{fake_id}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); // Delete project let response = app .clone() .oneshot( Request::builder() .method("DELETE") .uri(format!("/api/v1/projects/{project_id}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_builds_endpoints() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); // Stats endpoint let response = app .clone() .oneshot( Request::builder() .uri("/api/v1/builds/stats") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // Recent endpoint let response = app .clone() .oneshot( Request::builder() .uri("/api/v1/builds/recent") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } // ---- Hardening tests ---- #[tokio::test] async fn test_error_response_includes_error_code() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let fake_id = uuid::Uuid::new_v4(); let response = app .oneshot( Request::builder() .uri(format!("/api/v1/projects/{fake_id}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error_code"], "NOT_FOUND"); assert!(json["error"].as_str().is_some()); } #[tokio::test] async fn test_cache_invalid_hash_returns_404() { let pool = match get_pool().await { Some(p) => p, None => return, }; let mut config = fc_common::config::Config::default(); config.cache.enabled = true; let app = build_app_with_config(pool, config); // Too short let response = app .clone() .oneshot( Request::builder() .uri("/nix-cache/tooshort.narinfo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); // Contains uppercase let response = app .clone() .oneshot( Request::builder() .uri("/nix-cache/ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEF.narinfo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); // Contains special chars let response = app .clone() .oneshot( Request::builder() .uri("/nix-cache/abcdefghijklmnop!@#$%^&*()abcde.narinfo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); // SQL injection attempt let response = app .clone() .oneshot( Request::builder() .uri("/nix-cache/'%20OR%201=1;%20DROP%20TABLE%20builds;--.narinfo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); // Valid hash format but no matching product -> 404 (not error) let response = app .clone() .oneshot( Request::builder() .uri("/nix-cache/abcdefghijklmnopqrstuvwxyz012345.narinfo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_cache_nar_invalid_hash_returns_404() { let pool = match get_pool().await { Some(p) => p, None => return, }; let mut config = fc_common::config::Config::default(); config.cache.enabled = true; let app = build_app_with_config(pool, config); // Invalid hash in NAR endpoint let response = app .clone() .oneshot( Request::builder() .uri("/nix-cache/nar/INVALID_HASH.nar.zst") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); // Invalid hash in uncompressed NAR endpoint let response = app .clone() .oneshot( Request::builder() .uri("/nix-cache/nar/INVALID_HASH.nar") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_cache_disabled_returns_404() { let pool = match get_pool().await { Some(p) => p, None => return, }; let mut config = fc_common::config::Config::default(); config.cache.enabled = false; let app = build_app_with_config(pool, config); let response = app .clone() .oneshot( Request::builder() .uri("/nix-cache/nix-cache-info") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); let response = app .clone() .oneshot( Request::builder() .uri("/nix-cache/abcdefghijklmnopqrstuvwxyz012345.narinfo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_search_rejects_long_query() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); // Query over 256 chars should return empty results let long_query = "a".repeat(300); let response = app .clone() .oneshot( Request::builder() .uri(format!("/api/v1/search?q={long_query}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["projects"], serde_json::json!([])); assert_eq!(json["builds"], serde_json::json!([])); } #[tokio::test] async fn test_search_rejects_empty_query() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let response = app .clone() .oneshot( Request::builder() .uri("/api/v1/search?q=") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["projects"], serde_json::json!([])); assert_eq!(json["builds"], serde_json::json!([])); } #[tokio::test] async fn test_search_whitespace_only_query() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let response = app .clone() .oneshot( Request::builder() .uri("/api/v1/search?q=%20%20%20") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["projects"], serde_json::json!([])); } #[tokio::test] async fn test_builds_list_with_system_filter() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); // Filter by system - should return 200 even with no results let response = app .clone() .oneshot( Request::builder() .uri("/api/v1/builds?system=x86_64-linux") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["items"].is_array()); assert!(json["total"].is_number()); } #[tokio::test] async fn test_builds_list_with_job_name_filter() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let response = app .clone() .oneshot( Request::builder() .uri("/api/v1/builds?job_name=hello") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["items"].is_array()); } #[tokio::test] async fn test_builds_list_combined_filters() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let response = app .clone() .oneshot( Request::builder() .uri("/api/v1/builds?system=aarch64-linux&status=pending&job_name=foo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_cache_info_returns_correct_headers() { let pool = match get_pool().await { Some(p) => p, None => return, }; let mut config = fc_common::config::Config::default(); config.cache.enabled = true; let app = build_app_with_config(pool, config); let response = app .oneshot( Request::builder() .uri("/nix-cache/nix-cache-info") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers().get("content-type").unwrap(), "text/plain" ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body_str = String::from_utf8(body.to_vec()).unwrap(); assert!(body_str.contains("StoreDir: /nix/store")); assert!(body_str.contains("WantMassQuery: 1")); assert!(body_str.contains("Priority: 30")); } #[tokio::test] async fn test_metrics_endpoint() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let response = app .oneshot( Request::builder() .uri("/metrics") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert!( response .headers() .get("content-type") .unwrap() .to_str() .unwrap() .contains("text/plain") ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body_str = String::from_utf8(body.to_vec()).unwrap(); assert!(body_str.contains("fc_builds_total")); assert!(body_str.contains("fc_projects_total")); assert!(body_str.contains("fc_evaluations_total")); } #[tokio::test] async fn test_get_nonexistent_build_returns_error_code() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let fake_id = uuid::Uuid::new_v4(); let response = app .oneshot( Request::builder() .uri(format!("/api/v1/builds/{fake_id}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error_code"], "NOT_FOUND"); assert!(json["error"].as_str().unwrap().contains("not found")); } // ---- Validation tests ---- #[tokio::test] async fn test_create_project_validation_rejects_invalid_name() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); // Name starting with dash let body = serde_json::json!({ "name": "-bad-name", "repository_url": "https://github.com/test/repo" }); let response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/v1/projects") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error_code"], "VALIDATION_ERROR"); } #[tokio::test] async fn test_create_project_validation_rejects_bad_url() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let body = serde_json::json!({ "name": "valid-name", "repository_url": "ftp://bad-protocol.com/repo" }); let response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/v1/projects") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error_code"], "VALIDATION_ERROR"); } #[tokio::test] async fn test_create_project_validation_accepts_valid() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let body = serde_json::json!({ "name": format!("valid-project-{}", uuid::Uuid::new_v4()), "repository_url": "https://github.com/test/repo", "description": "A valid project" }); let response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/v1/projects") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } // ---- Error handling tests ---- #[tokio::test] async fn test_project_create_with_auth() { let pool = match get_pool().await { Some(p) => p, None => return, }; // Create an admin API key let mut hasher = sha2::Sha256::new(); use sha2::Digest; hasher.update(b"fc_test_project_auth"); let key_hash = hex::encode(hasher.finalize()); let _ = fc_common::repo::api_keys::upsert(&pool, "test-auth", &key_hash, "admin") .await; let app = build_app(pool); let body = serde_json::json!({ "name": "auth-test-project", "repository_url": "https://github.com/test/auth-test" }); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/v1/projects") .header("content-type", "application/json") .header("authorization", "Bearer fc_test_project_auth") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); assert_eq!(json["name"], "auth-test-project"); assert!(json["id"].as_str().is_some()); } #[tokio::test] async fn test_project_create_without_auth_rejected() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let body = serde_json::json!({ "name": "no-auth-project", "repository_url": "https://github.com/test/no-auth" }); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/v1/projects") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_setup_endpoint_creates_project_and_jobsets() { let pool = match get_pool().await { Some(p) => p, None => return, }; // Create an admin API key let mut hasher = sha2::Sha256::new(); use sha2::Digest; hasher.update(b"fc_test_setup_key"); let key_hash = hex::encode(hasher.finalize()); let _ = fc_common::repo::api_keys::upsert(&pool, "test-setup", &key_hash, "admin") .await; let app = build_app(pool.clone()); let body = serde_json::json!({ "repository_url": "https://github.com/test/setup-test", "name": "setup-test-project", "description": "Test project from setup endpoint", "jobsets": [ { "name": "packages", "nix_expression": "packages", "description": "Packages" }, { "name": "checks", "nix_expression": "checks", "description": "Checks" } ] }); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/v1/projects/setup") .header("content-type", "application/json") .header("authorization", "Bearer fc_test_setup_key") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); assert_eq!(json["project"]["name"], "setup-test-project"); assert_eq!(json["jobsets"].as_array().unwrap().len(), 2); assert_eq!(json["jobsets"][0]["name"], "packages"); assert_eq!(json["jobsets"][1]["name"], "checks"); } #[tokio::test] async fn test_security_headers_present() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let response = app .oneshot( Request::builder() .uri("/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( response .headers() .get("x-content-type-options") .map(|v| v.to_str().unwrap()), Some("nosniff") ); assert_eq!( response .headers() .get("x-frame-options") .map(|v| v.to_str().unwrap()), Some("DENY") ); assert_eq!( response .headers() .get("referrer-policy") .map(|v| v.to_str().unwrap()), Some("strict-origin-when-cross-origin") ); } #[tokio::test] async fn test_static_css_served() { let pool = match get_pool().await { Some(p) => p, None => return, }; let app = build_app(pool); let response = app .oneshot( Request::builder() .uri("/static/style.css") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("content-type") .map(|v| v.to_str().unwrap()), Some("text/css") ); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let css = String::from_utf8_lossy(&body_bytes); assert!(css.contains("--accent"), "CSS should contain design tokens"); assert!( css.contains("prefers-color-scheme: dark"), "CSS should have dark mode" ); }