crates/server: REST API routes; RBAC auth middleware; cookie sessions; dashboard
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I5298a925bd9c11780e49d8b1c98eebd86a6a6964
This commit is contained in:
parent
44d1ee1d6b
commit
235d3d38a6
38 changed files with 6275 additions and 7 deletions
777
crates/server/tests/api_tests.rs
Normal file
777
crates/server/tests/api_tests.rs
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
//! Integration tests for API endpoints.
|
||||
//! Requires TEST_DATABASE_URL to be set.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
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);
|
||||
}
|
||||
334
crates/server/tests/e2e_test.rs
Normal file
334
crates/server/tests/e2e_test.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
//! End-to-end integration test.
|
||||
//! Requires TEST_DATABASE_URL to be set.
|
||||
//! Tests the full flow: create project -> jobset -> evaluation -> builds.
|
||||
//!
|
||||
//! Nix-dependent steps are skipped if nix is not available.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use fc_common::models::*;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping E2E 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)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_e2e_project_eval_build_flow() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 1. Create a project
|
||||
let project_name = format!("e2e-test-{}", uuid::Uuid::new_v4());
|
||||
let project = fc_common::repo::projects::create(
|
||||
&pool,
|
||||
CreateProject {
|
||||
name: project_name.clone(),
|
||||
description: Some("E2E test project".to_string()),
|
||||
repository_url: "https://github.com/test/e2e".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create project");
|
||||
|
||||
assert_eq!(project.name, project_name);
|
||||
|
||||
// 2. Create a jobset
|
||||
let jobset = fc_common::repo::jobsets::create(
|
||||
&pool,
|
||||
CreateJobset {
|
||||
project_id: project.id,
|
||||
name: "default".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
enabled: Some(true),
|
||||
flake_mode: Some(true),
|
||||
check_interval: Some(300),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create jobset");
|
||||
|
||||
assert_eq!(jobset.project_id, project.id);
|
||||
assert!(jobset.enabled);
|
||||
|
||||
// 3. Verify active jobsets include our new one
|
||||
let active = fc_common::repo::jobsets::list_active(&pool)
|
||||
.await
|
||||
.expect("list active");
|
||||
assert!(
|
||||
active.iter().any(|j| j.id == jobset.id),
|
||||
"new jobset should be in active list"
|
||||
);
|
||||
|
||||
// 4. Create an evaluation
|
||||
let eval = fc_common::repo::evaluations::create(
|
||||
&pool,
|
||||
CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: "e2e0000000000000000000000000000000000000".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create evaluation");
|
||||
|
||||
assert_eq!(eval.jobset_id, jobset.id);
|
||||
assert_eq!(eval.status, EvaluationStatus::Pending);
|
||||
|
||||
// 5. Mark evaluation as running
|
||||
fc_common::repo::evaluations::update_status(&pool, eval.id, EvaluationStatus::Running, None)
|
||||
.await
|
||||
.expect("update eval status");
|
||||
|
||||
// 6. Create builds as if nix evaluation found jobs
|
||||
let build1 = fc_common::repo::builds::create(
|
||||
&pool,
|
||||
CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: "hello".to_string(),
|
||||
drv_path: "/nix/store/e2e000-hello.drv".to_string(),
|
||||
system: Some("x86_64-linux".to_string()),
|
||||
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-hello"})),
|
||||
is_aggregate: Some(false),
|
||||
constituents: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create build 1");
|
||||
|
||||
let build2 = fc_common::repo::builds::create(
|
||||
&pool,
|
||||
CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: "world".to_string(),
|
||||
drv_path: "/nix/store/e2e000-world.drv".to_string(),
|
||||
system: Some("x86_64-linux".to_string()),
|
||||
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-world"})),
|
||||
is_aggregate: Some(false),
|
||||
constituents: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create build 2");
|
||||
|
||||
assert_eq!(build1.status, BuildStatus::Pending);
|
||||
assert_eq!(build2.status, BuildStatus::Pending);
|
||||
|
||||
// 7. Create build dependency (hello depends on world)
|
||||
fc_common::repo::build_dependencies::create(&pool, build1.id, build2.id)
|
||||
.await
|
||||
.expect("create dependency");
|
||||
|
||||
// 8. Verify dependency check: build1 deps NOT complete (world is still pending)
|
||||
let deps_complete = fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
|
||||
.await
|
||||
.expect("check deps");
|
||||
assert!(!deps_complete, "deps should NOT be complete yet");
|
||||
|
||||
// 9. Complete build2 (world)
|
||||
fc_common::repo::builds::start(&pool, build2.id)
|
||||
.await
|
||||
.expect("start build2");
|
||||
fc_common::repo::builds::complete(
|
||||
&pool,
|
||||
build2.id,
|
||||
BuildStatus::Completed,
|
||||
None,
|
||||
Some("/nix/store/e2e000-world"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("complete build2");
|
||||
|
||||
// 10. Now build1 deps should be complete
|
||||
let deps_complete = fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
|
||||
.await
|
||||
.expect("check deps again");
|
||||
assert!(deps_complete, "deps should be complete after build2 done");
|
||||
|
||||
// 11. Complete build1 (hello)
|
||||
fc_common::repo::builds::start(&pool, build1.id)
|
||||
.await
|
||||
.expect("start build1");
|
||||
|
||||
let step = fc_common::repo::build_steps::create(
|
||||
&pool,
|
||||
CreateBuildStep {
|
||||
build_id: build1.id,
|
||||
step_number: 1,
|
||||
command: "nix build /nix/store/e2e000-hello.drv".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create step");
|
||||
|
||||
fc_common::repo::build_steps::complete(&pool, step.id, 0, Some("built!"), None)
|
||||
.await
|
||||
.expect("complete step");
|
||||
|
||||
fc_common::repo::build_products::create(
|
||||
&pool,
|
||||
CreateBuildProduct {
|
||||
build_id: build1.id,
|
||||
name: "out".to_string(),
|
||||
path: "/nix/store/e2e000-hello".to_string(),
|
||||
sha256_hash: Some("abcdef1234567890".to_string()),
|
||||
file_size: Some(12345),
|
||||
content_type: None,
|
||||
is_directory: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create product");
|
||||
|
||||
fc_common::repo::builds::complete(
|
||||
&pool,
|
||||
build1.id,
|
||||
BuildStatus::Completed,
|
||||
None,
|
||||
Some("/nix/store/e2e000-hello"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("complete build1");
|
||||
|
||||
// 12. Mark evaluation as completed
|
||||
fc_common::repo::evaluations::update_status(&pool, eval.id, EvaluationStatus::Completed, None)
|
||||
.await
|
||||
.expect("complete eval");
|
||||
|
||||
// 13. Verify everything is in the expected state
|
||||
let final_eval = fc_common::repo::evaluations::get(&pool, eval.id)
|
||||
.await
|
||||
.expect("get eval");
|
||||
assert_eq!(final_eval.status, EvaluationStatus::Completed);
|
||||
|
||||
let final_build1 = fc_common::repo::builds::get(&pool, build1.id)
|
||||
.await
|
||||
.expect("get build1");
|
||||
assert_eq!(final_build1.status, BuildStatus::Completed);
|
||||
assert_eq!(
|
||||
final_build1.build_output_path.as_deref(),
|
||||
Some("/nix/store/e2e000-hello")
|
||||
);
|
||||
|
||||
let products = fc_common::repo::build_products::list_for_build(&pool, build1.id)
|
||||
.await
|
||||
.expect("list products");
|
||||
assert_eq!(products.len(), 1);
|
||||
assert_eq!(products[0].name, "out");
|
||||
|
||||
let steps = fc_common::repo::build_steps::list_for_build(&pool, build1.id)
|
||||
.await
|
||||
.expect("list steps");
|
||||
assert_eq!(steps.len(), 1);
|
||||
assert_eq!(steps[0].exit_code, Some(0));
|
||||
|
||||
// 14. Verify build stats reflect our changes
|
||||
let stats = fc_common::repo::builds::get_stats(&pool)
|
||||
.await
|
||||
.expect("get stats");
|
||||
assert!(stats.completed_builds.unwrap_or(0) >= 2);
|
||||
|
||||
// 15. Create a channel and verify it works
|
||||
let channel = fc_common::repo::channels::create(
|
||||
&pool,
|
||||
CreateChannel {
|
||||
project_id: project.id,
|
||||
name: "stable".to_string(),
|
||||
jobset_id: jobset.id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create channel");
|
||||
|
||||
let channels = fc_common::repo::channels::list_all(&pool)
|
||||
.await
|
||||
.expect("list channels");
|
||||
assert!(channels.iter().any(|c| c.id == channel.id));
|
||||
|
||||
// 16. Test the HTTP API layer
|
||||
let config = fc_common::config::Config::default();
|
||||
let server_config = config.server.clone();
|
||||
let state = fc_server::state::AppState {
|
||||
pool: pool.clone(),
|
||||
config,
|
||||
sessions: std::sync::Arc::new(dashmap::DashMap::new()),
|
||||
};
|
||||
let app = fc_server::routes::router(state, &server_config);
|
||||
|
||||
// GET /health
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// GET /api/v1/projects/{id}
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/projects/{}", project.id))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// GET /api/v1/builds/{id}
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/builds/{}", build1.id))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// GET / (dashboard)
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body_str.contains("Dashboard"));
|
||||
|
||||
// Clean up
|
||||
let _ = fc_common::repo::projects::delete(&pool, project.id).await;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue