use std::sync::Arc;
use axum::{
body::Body,
http::{Method, Request, StatusCode, header},
};
use http_body_util::BodyExt as _;
use tower::ServiceExt as _;
use crate::{
manta_backend_dispatcher::StaticBackendDispatcher,
server::{ServerState, routes::build_router},
};
fn router() -> axum::Router {
let backend =
StaticBackendDispatcher::new("csm", "http://stub.invalid", b"").unwrap();
let state = Arc::new(ServerState {
backend,
site_name: "test".to_string(),
shasta_base_url: "http://stub.invalid".to_string(),
shasta_root_cert: vec![],
vault_base_url: None,
gitea_base_url: "http://stub.invalid".to_string(),
k8s_api_url: None,
console_inactivity_timeout: std::time::Duration::from_secs(1800),
});
build_router(state)
}
fn router_with_vault() -> axum::Router {
let backend =
StaticBackendDispatcher::new("csm", "http://stub.invalid", b"").unwrap();
let state = Arc::new(ServerState {
backend,
site_name: "test".to_string(),
shasta_base_url: "http://stub.invalid".to_string(),
shasta_root_cert: vec![],
vault_base_url: Some("http://vault.stub.invalid".to_string()),
gitea_base_url: "http://stub.invalid".to_string(),
k8s_api_url: Some("http://k8s.stub.invalid".to_string()),
console_inactivity_timeout: std::time::Duration::from_secs(1800),
});
build_router(state)
}
async fn body_string(body: Body) -> String {
let bytes = body.collect().await.unwrap().to_bytes();
String::from_utf8_lossy(&bytes).into_owned()
}
fn get(uri: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri(uri)
.body(Body::empty())
.unwrap()
}
fn get_auth(uri: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri(uri)
.header(header::AUTHORIZATION, "Bearer test-token")
.body(Body::empty())
.unwrap()
}
fn delete_auth(uri: &str) -> Request<Body> {
Request::builder()
.method(Method::DELETE)
.uri(uri)
.header(header::AUTHORIZATION, "Bearer test-token")
.body(Body::empty())
.unwrap()
}
fn post_json(uri: &str, body: &str) -> Request<Body> {
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "Bearer test-token")
.body(Body::from(body.to_string()))
.unwrap()
}
#[tokio::test]
async fn health_returns_200_without_auth() {
let resp = router().oneshot(get("/api/v1/health")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn health_body_contains_status_ok() {
let resp = router().oneshot(get("/api/v1/health")).await.unwrap();
let body = body_string(resp.into_body()).await;
assert!(body.contains("\"ok\""), "body was: {}", body);
}
#[tokio::test]
async fn unknown_route_returns_404() {
let resp = router()
.oneshot(get("/api/v1/does-not-exist"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn wrong_api_version_returns_404() {
let resp = router()
.oneshot(get("/api/v2/sessions"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_on_post_only_route_returns_405() {
let resp = router()
.oneshot(get("/api/v1/power"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn post_on_get_only_route_returns_405() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/clusters")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn delete_health_returns_405() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::DELETE)
.uri("/api/v1/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
}
async fn assert_401_without_auth(uri: &str) {
let resp = router().oneshot(get(uri)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"expected 401 for GET {}",
uri
);
}
#[tokio::test]
async fn get_sessions_requires_auth() {
assert_401_without_auth("/api/v1/sessions").await;
}
#[tokio::test]
async fn get_configurations_requires_auth() {
assert_401_without_auth("/api/v1/configurations").await;
}
#[tokio::test]
async fn get_groups_requires_auth() {
assert_401_without_auth("/api/v1/groups").await;
}
#[tokio::test]
async fn get_images_requires_auth() {
assert_401_without_auth("/api/v1/images").await;
}
#[tokio::test]
async fn get_templates_requires_auth() {
assert_401_without_auth("/api/v1/templates").await;
}
#[tokio::test]
async fn get_boot_parameters_requires_auth() {
assert_401_without_auth("/api/v1/boot-parameters").await;
}
#[tokio::test]
async fn get_kernel_parameters_requires_auth() {
assert_401_without_auth("/api/v1/kernel-parameters").await;
}
#[tokio::test]
async fn get_redfish_endpoints_requires_auth() {
assert_401_without_auth("/api/v1/redfish-endpoints").await;
}
#[tokio::test]
async fn get_clusters_requires_auth() {
assert_401_without_auth("/api/v1/clusters").await;
}
#[tokio::test]
async fn get_hardware_clusters_requires_auth() {
assert_401_without_auth("/api/v1/hardware-clusters").await;
}
#[tokio::test]
async fn delete_node_requires_auth() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::DELETE)
.uri("/api/v1/nodes/x3000c0s1b0n0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn delete_group_requires_auth() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::DELETE)
.uri("/api/v1/groups/my-group")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn delete_session_requires_auth() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::DELETE)
.uri("/api/v1/sessions/my-session")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn delete_redfish_endpoint_requires_auth() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::DELETE)
.uri("/api/v1/redfish-endpoints/x3000c0s1b0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn post_nodes_missing_required_fields_returns_422() {
let resp = router()
.oneshot(post_json("/api/v1/nodes", "{}"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn post_ephemeral_env_missing_image_id_returns_422() {
let resp = router()
.oneshot(post_json("/api/v1/ephemeral-env", "{}"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn post_power_unknown_action_returns_422() {
let resp = router()
.oneshot(post_json(
"/api/v1/power",
r#"{"action":"fly","targets":["x3000c0s1b0n0"],"target_type":"nodes"}"#,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn post_power_missing_action_returns_422() {
let resp = router()
.oneshot(post_json(
"/api/v1/power",
r#"{"targets":["x3000c0s1b0n0"],"target_type":"nodes"}"#,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn post_template_session_missing_required_fields_returns_422() {
let resp = router()
.oneshot(post_json(
"/api/v1/templates/my-template/sessions",
r#"{"dry_run":false}"#,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn post_sat_file_missing_content_returns_422() {
let resp = router()
.oneshot(post_json("/api/v1/sat-file", "{}"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn get_nodes_without_xname_returns_400() {
let resp = router()
.oneshot(get_auth("/api/v1/nodes"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn get_hardware_nodes_without_xnames_returns_400() {
let resp = router()
.oneshot(get_auth("/api/v1/hardware-nodes"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn get_session_logs_without_k8s_config_returns_501() {
let resp = router()
.oneshot(get_auth("/api/v1/sessions/my-session/logs"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn post_sat_file_without_vault_config_returns_501() {
let resp = router()
.oneshot(post_json(
"/api/v1/sat-file",
r#"{"sat_file_content":"schema: 1.0\n"}"#,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn post_sat_file_without_k8s_config_returns_501() {
let backend =
StaticBackendDispatcher::new("csm", "http://stub.invalid", b"").unwrap();
let state = Arc::new(ServerState {
backend,
site_name: "test".to_string(),
shasta_base_url: "http://stub.invalid".to_string(),
shasta_root_cert: vec![],
vault_base_url: Some("http://vault.stub.invalid".to_string()),
gitea_base_url: "http://stub.invalid".to_string(),
k8s_api_url: None, console_inactivity_timeout: std::time::Duration::from_secs(1800),
});
let resp = build_router(state)
.oneshot(post_json(
"/api/v1/sat-file",
r#"{"sat_file_content":"schema: 1.0\n"}"#,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
}
async fn assert_route_exists(method: Method, uri: &str) {
let req = Request::builder()
.method(method)
.uri(uri)
.header(header::AUTHORIZATION, "Bearer test-token")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::empty())
.unwrap();
let resp = router().oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::NOT_FOUND,
"route not found: {}",
uri
);
assert_ne!(
resp.status(),
StatusCode::METHOD_NOT_ALLOWED,
"method not allowed: {}",
uri
);
}
#[tokio::test]
async fn all_get_routes_are_registered() {
for uri in &[
"/api/v1/sessions",
"/api/v1/configurations",
"/api/v1/groups",
"/api/v1/images",
"/api/v1/templates",
"/api/v1/boot-parameters",
"/api/v1/kernel-parameters",
"/api/v1/redfish-endpoints",
"/api/v1/clusters",
"/api/v1/hardware-clusters",
"/api/v1/health",
"/api/v1/nodes/x3000c0s1b0n0/console",
"/api/v1/sessions/my-session/console",
] {
assert_route_exists(Method::GET, uri).await;
}
}
#[tokio::test]
async fn all_post_routes_are_registered() {
for uri in &[
"/api/v1/sessions",
"/api/v1/sessions/apply",
"/api/v1/nodes",
"/api/v1/groups",
"/api/v1/groups/test/members",
"/api/v1/boot-parameters",
"/api/v1/redfish-endpoints",
"/api/v1/boot-config",
"/api/v1/kernel-parameters/apply",
"/api/v1/kernel-parameters/add",
"/api/v1/migrate/nodes",
"/api/v1/migrate/backup",
"/api/v1/migrate/restore",
"/api/v1/ephemeral-env",
"/api/v1/power",
"/api/v1/templates/my-template/sessions",
"/api/v1/sat-file",
"/api/v1/hardware-clusters/my-cluster/members",
"/api/v1/hardware-clusters/my-cluster/configuration",
] {
assert_route_exists(Method::POST, uri).await;
}
}
#[tokio::test]
async fn all_delete_routes_are_registered() {
for uri in &[
"/api/v1/nodes/x3000c0s1b0n0",
"/api/v1/groups/my-group",
"/api/v1/groups/my-group/members",
"/api/v1/boot-parameters",
"/api/v1/kernel-parameters",
"/api/v1/redfish-endpoints/x3000c0s1b0",
"/api/v1/sessions/my-session",
"/api/v1/images",
"/api/v1/configurations",
"/api/v1/hardware-clusters/my-cluster/members",
] {
assert_route_exists(Method::DELETE, uri).await;
}
}
#[tokio::test]
async fn add_kernel_parameters_requires_auth() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/kernel-parameters/add")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"params":"quiet"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn delete_kernel_parameters_requires_auth() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::DELETE)
.uri("/api/v1/kernel-parameters")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"params":"quiet"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn add_hw_component_requires_auth() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/hardware-clusters/my-cluster/members")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"parent_cluster":"p","pattern":"a100:2"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn delete_hw_component_requires_auth() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::DELETE)
.uri("/api/v1/hardware-clusters/my-cluster/members")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"parent_cluster":"p","pattern":"a100:2"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn apply_hw_configuration_requires_auth() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/hardware-clusters/my-cluster/configuration")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"parent_cluster":"p","pattern":"a100:2"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn apply_session_requires_auth() {
let resp = router()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/v1/sessions/apply")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"repo_names":["cray/foo"],"repo_last_commit_ids":["abc"]}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn add_kernel_parameters_missing_params_returns_422() {
let resp = router()
.oneshot(post_json("/api/v1/kernel-parameters/add", "{}"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn delete_kernel_parameters_missing_params_returns_422() {
let resp = router()
.oneshot(post_json("/api/v1/kernel-parameters", "{}"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn add_hw_component_missing_body_returns_422() {
let resp = router()
.oneshot(post_json("/api/v1/hardware-clusters/my-cluster/members", "{}"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn apply_hw_configuration_missing_body_returns_422() {
let resp = router()
.oneshot(post_json(
"/api/v1/hardware-clusters/my-cluster/configuration",
"{}",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn apply_session_missing_repo_fields_returns_422() {
let resp = router()
.oneshot(post_json("/api/v1/sessions/apply", "{}"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn apply_session_without_vault_returns_501() {
let resp = router()
.oneshot(post_json(
"/api/v1/sessions/apply",
r#"{"repo_names":["cray/foo"],"repo_last_commit_ids":["abc123"]}"#,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
}
fn ws_upgrade(uri: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri(uri)
.header(header::CONNECTION, "Upgrade")
.header(header::UPGRADE, "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn console_node_without_auth_returns_401() {
let resp = router()
.oneshot(ws_upgrade("/api/v1/nodes/x3000c0s1b0n0/console"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn console_session_without_auth_returns_401() {
let resp = router()
.oneshot(ws_upgrade("/api/v1/sessions/my-session/console"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn to_handler_error_not_found_variants() {
use crate::server::handlers::to_handler_error;
use axum::http::StatusCode;
use manta_backend_dispatcher::error::Error;
assert_eq!(to_handler_error(Error::NotFound("session foo".into())).0, StatusCode::NOT_FOUND);
assert_eq!(to_handler_error(Error::SessionNotFound).0, StatusCode::NOT_FOUND);
assert_eq!(to_handler_error(Error::ConfigurationNotFound).0, StatusCode::NOT_FOUND);
}
#[test]
fn to_handler_error_conflict_variants() {
use crate::server::handlers::to_handler_error;
use axum::http::StatusCode;
use manta_backend_dispatcher::error::Error;
assert_eq!(to_handler_error(Error::Conflict("group foo".into())).0, StatusCode::CONFLICT);
assert_eq!(to_handler_error(Error::ConfigurationAlreadyExistsError("cfg".into())).0, StatusCode::CONFLICT);
}
#[test]
fn to_handler_error_bad_request_and_internal() {
use crate::server::handlers::to_handler_error;
use axum::http::StatusCode;
use manta_backend_dispatcher::error::Error;
assert_eq!(to_handler_error(Error::BadRequest("bad input".into())).0, StatusCode::BAD_REQUEST);
assert_eq!(to_handler_error(Error::Message("something broke".into())).0, StatusCode::INTERNAL_SERVER_ERROR);
}