Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I5cf55cc4cb558c3f9f764c71224e87176a6a6964
1045 lines
24 KiB
Rust
1045 lines
24 KiB
Rust
//! 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<sqlx::PgPool> {
|
|
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
|
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()),
|
|
http_client: reqwest::Client::new(),
|
|
};
|
|
fc_server::routes::router(state, &server_config)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_router_no_duplicate_routes() {
|
|
let Some(pool) = get_pool().await else {
|
|
return;
|
|
};
|
|
|
|
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()),
|
|
http_client: reqwest::Client::new(),
|
|
};
|
|
|
|
let _app = 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()),
|
|
http_client: reqwest::Client::new(),
|
|
};
|
|
fc_server::routes::router(state, &server_config)
|
|
}
|
|
|
|
// API endpoint tests
|
|
|
|
#[tokio::test]
|
|
async fn test_health_endpoint() {
|
|
let Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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);
|
|
}
|
|
|
|
// Error response structure
|
|
|
|
#[tokio::test]
|
|
async fn test_error_response_includes_error_code() {
|
|
let Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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();
|
|
|
|
// Verify metric names are present
|
|
assert!(body_str.contains("fc_builds_total"));
|
|
assert!(body_str.contains("fc_projects_total"));
|
|
assert!(body_str.contains("fc_evaluations_total"));
|
|
|
|
// Verify Prometheus format: HELP/TYPE headers and label syntax
|
|
assert!(
|
|
body_str.contains("# HELP fc_builds_total"),
|
|
"Missing HELP header for fc_builds_total"
|
|
);
|
|
assert!(
|
|
body_str.contains("# TYPE fc_builds_total gauge"),
|
|
"Missing TYPE header for fc_builds_total"
|
|
);
|
|
assert!(
|
|
body_str.contains("fc_builds_total{status=\"succeeded\"}"),
|
|
"Missing succeeded status label"
|
|
);
|
|
assert!(
|
|
body_str.contains("fc_builds_total{status=\"failed\"}"),
|
|
"Missing failed status label"
|
|
);
|
|
assert!(
|
|
body_str.contains("fc_queue_depth"),
|
|
"Missing queue depth metric"
|
|
);
|
|
assert!(
|
|
body_str.contains("fc_builds_avg_duration_seconds"),
|
|
"Missing avg duration metric"
|
|
);
|
|
|
|
// Verify each line with a metric value ends with a number (basic format
|
|
// check)
|
|
for line in body_str.lines() {
|
|
if line.starts_with('#') || line.is_empty() {
|
|
continue;
|
|
}
|
|
// Metric lines should have format: metric_name{labels} value
|
|
// or: metric_name value
|
|
let parts: Vec<&str> = line.rsplitn(2, ' ').collect();
|
|
assert!(
|
|
parts.len() == 2,
|
|
"Malformed metric line (expected 'name value'): {line}"
|
|
);
|
|
assert!(
|
|
parts[0].parse::<f64>().is_ok(),
|
|
"Metric value is not a number: '{}' in line: {line}",
|
|
parts[0]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_nonexistent_build_returns_error_code() {
|
|
let Some(pool) = get_pool().await else {
|
|
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"));
|
|
}
|
|
|
|
// Input validation
|
|
|
|
#[tokio::test]
|
|
async fn test_create_project_validation_rejects_invalid_name() {
|
|
let Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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);
|
|
}
|
|
|
|
// Auth and error handling
|
|
|
|
#[tokio::test]
|
|
async fn test_project_create_with_auth() {
|
|
use sha2::Digest;
|
|
|
|
let Some(pool) = get_pool().await else {
|
|
return;
|
|
};
|
|
|
|
// Create an admin API key
|
|
let mut hasher = sha2::Sha256::new();
|
|
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 Some(pool) = get_pool().await else {
|
|
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() {
|
|
use sha2::Digest;
|
|
|
|
let Some(pool) = get_pool().await else {
|
|
return;
|
|
};
|
|
|
|
// Create an admin API key
|
|
let mut hasher = sha2::Sha256::new();
|
|
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 Some(pool) = get_pool().await else {
|
|
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 Some(pool) = get_pool().await else {
|
|
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"
|
|
);
|
|
}
|