agent-style workers
This commit is contained in:
@@ -302,3 +302,163 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
.route("/agent/binary", get(download_agent_binary))
|
||||
.route("/agent/info", get(agent_info))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use attune_common::config::AgentConfig;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
// ── validate_arch tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_validate_arch_valid_x86_64() {
|
||||
let result = validate_arch("x86_64");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "x86_64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_arch_valid_aarch64() {
|
||||
let result = validate_arch("aarch64");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "aarch64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_arch_arm64_alias() {
|
||||
// "arm64" is an alias for "aarch64"
|
||||
let result = validate_arch("arm64");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "aarch64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_arch_invalid() {
|
||||
let result = validate_arch("mips");
|
||||
assert!(result.is_err());
|
||||
let (status, body) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body.0["error"], "Invalid architecture");
|
||||
}
|
||||
|
||||
// ── validate_token tests ────────────────────────────────────────
|
||||
|
||||
/// Helper: build a minimal Config with the given agent config.
|
||||
/// Only the `agent` field is relevant for `validate_token`.
|
||||
fn test_config(agent: Option<AgentConfig>) -> attune_common::config::Config {
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
|
||||
let config_path = format!("{}/../../config.test.yaml", manifest_dir);
|
||||
let mut config = attune_common::config::Config::load_from_file(&config_path)
|
||||
.expect("Failed to load test config");
|
||||
config.agent = agent;
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_no_config() {
|
||||
// When no agent config is set at all, no token is required.
|
||||
let config = test_config(None);
|
||||
let headers = HeaderMap::new();
|
||||
let query_token = None;
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_no_bootstrap_token_configured() {
|
||||
// Agent config exists but bootstrap_token is None → no token required.
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: None,
|
||||
}));
|
||||
let headers = HeaderMap::new();
|
||||
let query_token = None;
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_valid_from_header() {
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: Some("s3cret-bootstrap".to_string()),
|
||||
}));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-agent-token",
|
||||
HeaderValue::from_static("s3cret-bootstrap"),
|
||||
);
|
||||
let query_token = None;
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_valid_from_query() {
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: Some("s3cret-bootstrap".to_string()),
|
||||
}));
|
||||
let headers = HeaderMap::new();
|
||||
let query_token = Some("s3cret-bootstrap".to_string());
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_invalid() {
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: Some("correct-token".to_string()),
|
||||
}));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-agent-token", HeaderValue::from_static("wrong-token"));
|
||||
let query_token = None;
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_err());
|
||||
let (status, body) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(body.0["error"], "Invalid token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_missing_when_required() {
|
||||
// bootstrap_token is configured but caller provides nothing.
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: Some("required-token".to_string()),
|
||||
}));
|
||||
let headers = HeaderMap::new();
|
||||
let query_token = None;
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_err());
|
||||
let (status, body) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(body.0["error"], "Token required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_header_takes_precedence_over_query() {
|
||||
// When both header and query provide a token, the header value is
|
||||
// checked first (it appears first in the or_else chain). Provide a
|
||||
// valid token in the header and an invalid one in the query — should
|
||||
// succeed because the header matches.
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: Some("the-real-token".to_string()),
|
||||
}));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-agent-token", HeaderValue::from_static("the-real-token"));
|
||||
let query_token = Some("wrong-token".to_string());
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
138
crates/api/tests/agent_tests.rs
Normal file
138
crates/api/tests/agent_tests.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! Integration tests for agent binary distribution endpoints
|
||||
//!
|
||||
//! The agent endpoints (`/api/v1/agent/binary` and `/api/v1/agent/info`) are
|
||||
//! intentionally unauthenticated — the agent needs to download its binary
|
||||
//! before it has JWT credentials. An optional `bootstrap_token` can restrict
|
||||
//! access, but that is validated inside the handler, not via RequireAuth
|
||||
//! middleware.
|
||||
//!
|
||||
//! The test configuration (`config.test.yaml`) does NOT include an `agent`
|
||||
//! section, so both endpoints return 503 Service Unavailable. This is the
|
||||
//! correct behaviour: the endpoints are reachable (no 401/404 from middleware)
|
||||
//! but the feature is not configured.
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod helpers;
|
||||
use helpers::TestContext;
|
||||
|
||||
// ── /api/v1/agent/info ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_agent_info_not_configured() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/api/v1/agent/info", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// Agent config is not set in config.test.yaml, so the handler returns 503.
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
|
||||
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
|
||||
assert_eq!(body["error"], "Not configured");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_agent_info_no_auth_required() {
|
||||
// Verify that the endpoint is reachable WITHOUT any JWT token.
|
||||
// If RequireAuth middleware were applied, this would return 401.
|
||||
// Instead we expect 503 (not configured) — proving the endpoint
|
||||
// is publicly accessible.
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/api/v1/agent/info", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// Must NOT be 401 Unauthorized — the endpoint has no auth middleware.
|
||||
assert_ne!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"agent/info should not require authentication"
|
||||
);
|
||||
// Should be 503 because agent config is absent.
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// ── /api/v1/agent/binary ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_agent_binary_not_configured() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/api/v1/agent/binary", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// Agent config is not set in config.test.yaml, so the handler returns 503.
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
|
||||
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
|
||||
assert_eq!(body["error"], "Not configured");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_agent_binary_no_auth_required() {
|
||||
// Same reasoning as test_agent_info_no_auth_required: the binary
|
||||
// download endpoint must be publicly accessible (no RequireAuth).
|
||||
// When no bootstrap_token is configured, any caller can reach the
|
||||
// handler. We still get 503 because the agent feature itself is
|
||||
// not configured in the test environment.
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/api/v1/agent/binary", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// Must NOT be 401 Unauthorized — the endpoint has no auth middleware.
|
||||
assert_ne!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"agent/binary should not require authentication when no bootstrap_token is configured"
|
||||
);
|
||||
// Should be 503 because agent config is absent.
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_agent_binary_invalid_arch() {
|
||||
// Architecture validation (`validate_arch`) rejects unsupported values
|
||||
// with 400 Bad Request. However, in the handler the execution order is:
|
||||
// 1. validate_token (passes — no bootstrap_token configured)
|
||||
// 2. check agent config (fails with 503 — not configured)
|
||||
// 3. validate_arch (never reached)
|
||||
//
|
||||
// So even with an invalid arch like "mips", we get 503 from the config
|
||||
// check before the arch is ever validated. The arch validation is covered
|
||||
// by unit tests in routes/agent.rs instead.
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/api/v1/agent/binary?arch=mips", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// 503 from the agent-config-not-set check, NOT 400 from arch validation.
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
Reference in New Issue
Block a user