//! Real HTTP integration tests for the OpenFang API. //! //! These tests boot a real kernel, start a real axum HTTP server on a random //! port, and hit actual endpoints with reqwest. No mocking. //! //! Tests that require an LLM API call are gated behind GROQ_API_KEY. //! //! Run: cargo test -p openfang-api --test api_integration_test -- --nocapture use axum::Router; use openfang_api::middleware; use openfang_api::routes::{self, AppState}; use openfang_api::ws; use openfang_kernel::OpenFangKernel; use openfang_types::config::{DefaultModelConfig, KernelConfig}; use std::sync::Arc; use std::time::Instant; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; // --------------------------------------------------------------------------- // Test infrastructure // --------------------------------------------------------------------------- struct TestServer { base_url: String, state: Arc, _tmp: tempfile::TempDir, } impl Drop for TestServer { fn drop(&mut self) { self.state.kernel.shutdown(); } } /// Start a test server using ollama as default provider (no API key needed). /// This lets the kernel boot without any real LLM credentials. /// Tests that need actual LLM calls should use `start_test_server_with_llm()`. async fn start_test_server() -> TestServer { start_test_server_with_provider("ollama", "test-model", "OLLAMA_API_KEY").await } /// Start a test server with Groq as the LLM provider (requires GROQ_API_KEY). async fn start_test_server_with_llm() -> TestServer { start_test_server_with_provider("groq", "llama-3.3-70b-versatile", "GROQ_API_KEY").await } async fn start_test_server_with_provider( provider: &str, model: &str, api_key_env: &str, ) -> TestServer { let tmp = tempfile::tempdir().expect("Failed to create temp dir"); let config = KernelConfig { home_dir: tmp.path().to_path_buf(), data_dir: tmp.path().join("data"), default_model: DefaultModelConfig { provider: provider.to_string(), model: model.to_string(), api_key_env: api_key_env.to_string(), base_url: None, }, ..KernelConfig::default() }; let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot"); let kernel = Arc::new(kernel); kernel.set_self_handle(); let state = Arc::new(AppState { kernel, started_at: Instant::now(), peer_registry: None, bridge_manager: tokio::sync::Mutex::new(None), channels_config: tokio::sync::RwLock::new(Default::default()), shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); let app = Router::new() .route("/api/health", axum::routing::get(routes::health)) .route("/api/status", axum::routing::get(routes::status)) .route( "/api/agents", axum::routing::get(routes::list_agents).post(routes::spawn_agent), ) .route( "/api/agents/{id}/message", axum::routing::post(routes::send_message), ) .route( "/api/agents/{id}/session", axum::routing::get(routes::get_agent_session), ) .route("/api/agents/{id}/ws", axum::routing::get(ws::agent_ws)) .route( "/api/agents/{id}", axum::routing::delete(routes::kill_agent), ) .route( "/api/triggers", axum::routing::get(routes::list_triggers).post(routes::create_trigger), ) .route( "/api/triggers/{id}", axum::routing::delete(routes::delete_trigger), ) .route( "/api/workflows", axum::routing::get(routes::list_workflows).post(routes::create_workflow), ) .route( "/api/workflows/{id}/run", axum::routing::post(routes::run_workflow), ) .route( "/api/workflows/{id}/runs", axum::routing::get(routes::list_workflow_runs), ) .route("/api/shutdown", axum::routing::post(routes::shutdown)) .layer(axum::middleware::from_fn(middleware::request_logging)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) .with_state(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("Failed to bind test server"); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); TestServer { base_url: format!("http://{}", addr), state, _tmp: tmp, } } /// Manifest that uses ollama (no API key required, won't make real LLM calls). const TEST_MANIFEST: &str = r#" name = "test-agent" version = "0.1.0" description = "Integration test agent" author = "test" module = "builtin:chat" [model] provider = "ollama" model = "test-model" system_prompt = "You are a test agent. Reply concisely." [capabilities] tools = ["file_read"] memory_read = ["*"] memory_write = ["self.*"] "#; /// Manifest that uses Groq for real LLM tests. const LLM_MANIFEST: &str = r#" name = "test-agent" version = "0.1.0" description = "Integration test agent" author = "test" module = "builtin:chat" [model] provider = "groq" model = "llama-3.3-70b-versatile" system_prompt = "You are a test agent. Reply concisely." [capabilities] tools = ["file_read"] memory_read = ["*"] memory_write = ["self.*"] "#; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[tokio::test] async fn test_health_endpoint() { let server = start_test_server().await; let client = reqwest::Client::new(); let resp = client .get(format!("{}/api/health", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); // Middleware injects x-request-id assert!(resp.headers().contains_key("x-request-id")); let body: serde_json::Value = resp.json().await.unwrap(); // Public health endpoint returns minimal info (redacted for security) assert_eq!(body["status"], "ok"); assert!(body["version"].is_string()); // Detailed fields should NOT appear in public health endpoint assert!(body["database"].is_null()); assert!(body["agent_count"].is_null()); } #[tokio::test] async fn test_status_endpoint() { let server = start_test_server().await; let client = reqwest::Client::new(); let resp = client .get(format!("{}/api/status", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["status"], "running"); assert_eq!(body["agent_count"], 0); assert!(body["uptime_seconds"].is_number()); assert_eq!(body["default_provider"], "ollama"); assert_eq!(body["agents"].as_array().unwrap().len(), 0); } #[tokio::test] async fn test_spawn_list_kill_agent() { let server = start_test_server().await; let client = reqwest::Client::new(); // --- Spawn --- let resp = client .post(format!("{}/api/agents", server.base_url)) .json(&serde_json::json!({"manifest_toml": TEST_MANIFEST})) .send() .await .unwrap(); assert_eq!(resp.status(), 201); let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["name"], "test-agent"); let agent_id = body["agent_id"].as_str().unwrap().to_string(); assert!(!agent_id.is_empty()); // --- List (1 agent) --- let resp = client .get(format!("{}/api/agents", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let agents: Vec = resp.json().await.unwrap(); assert_eq!(agents.len(), 1); assert_eq!(agents[0]["name"], "test-agent"); assert_eq!(agents[0]["id"], agent_id); assert_eq!(agents[0]["model_provider"], "ollama"); // --- Kill --- let resp = client .delete(format!("{}/api/agents/{}", server.base_url, agent_id)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["status"], "killed"); // --- List (empty) --- let resp = client .get(format!("{}/api/agents", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let agents: Vec = resp.json().await.unwrap(); assert_eq!(agents.len(), 0); } #[tokio::test] async fn test_agent_session_empty() { let server = start_test_server().await; let client = reqwest::Client::new(); // Spawn agent let resp = client .post(format!("{}/api/agents", server.base_url)) .json(&serde_json::json!({"manifest_toml": TEST_MANIFEST})) .send() .await .unwrap(); let body: serde_json::Value = resp.json().await.unwrap(); let agent_id = body["agent_id"].as_str().unwrap(); // Session should be empty — no messages sent yet let resp = client .get(format!( "{}/api/agents/{}/session", server.base_url, agent_id )) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["message_count"], 0); assert_eq!(body["messages"].as_array().unwrap().len(), 0); } #[tokio::test] async fn test_send_message_with_llm() { if std::env::var("GROQ_API_KEY").is_err() { eprintln!("GROQ_API_KEY not set, skipping LLM integration test"); return; } let server = start_test_server_with_llm().await; let client = reqwest::Client::new(); // Spawn let resp = client .post(format!("{}/api/agents", server.base_url)) .json(&serde_json::json!({"manifest_toml": LLM_MANIFEST})) .send() .await .unwrap(); let body: serde_json::Value = resp.json().await.unwrap(); let agent_id = body["agent_id"].as_str().unwrap().to_string(); // Send message through the real HTTP endpoint → kernel → Groq LLM let resp = client .post(format!( "{}/api/agents/{}/message", server.base_url, agent_id )) .json(&serde_json::json!({"message": "Say hello in exactly 3 words."})) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); let response_text = body["response"].as_str().unwrap(); assert!( !response_text.is_empty(), "LLM response should not be empty" ); assert!(body["input_tokens"].as_u64().unwrap() > 0); assert!(body["output_tokens"].as_u64().unwrap() > 0); // Session should now have messages let resp = client .get(format!( "{}/api/agents/{}/session", server.base_url, agent_id )) .send() .await .unwrap(); let session: serde_json::Value = resp.json().await.unwrap(); assert!(session["message_count"].as_u64().unwrap() > 0); } #[tokio::test] async fn test_workflow_crud() { let server = start_test_server().await; let client = reqwest::Client::new(); // Spawn agent for workflow let resp = client .post(format!("{}/api/agents", server.base_url)) .json(&serde_json::json!({"manifest_toml": TEST_MANIFEST})) .send() .await .unwrap(); let body: serde_json::Value = resp.json().await.unwrap(); let agent_name = body["name"].as_str().unwrap().to_string(); // Create workflow let resp = client .post(format!("{}/api/workflows", server.base_url)) .json(&serde_json::json!({ "name": "test-workflow", "description": "Integration test workflow", "steps": [ { "name": "step1", "agent_name": agent_name, "prompt": "Echo: {{input}}", "mode": "sequential", "timeout_secs": 30 } ] })) .send() .await .unwrap(); assert_eq!(resp.status(), 201); let body: serde_json::Value = resp.json().await.unwrap(); let workflow_id = body["workflow_id"].as_str().unwrap().to_string(); assert!(!workflow_id.is_empty()); // List workflows let resp = client .get(format!("{}/api/workflows", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let workflows: Vec = resp.json().await.unwrap(); assert_eq!(workflows.len(), 1); assert_eq!(workflows[0]["name"], "test-workflow"); assert_eq!(workflows[0]["steps"], 1); } #[tokio::test] async fn test_trigger_crud() { let server = start_test_server().await; let client = reqwest::Client::new(); // Spawn agent for trigger let resp = client .post(format!("{}/api/agents", server.base_url)) .json(&serde_json::json!({"manifest_toml": TEST_MANIFEST})) .send() .await .unwrap(); let body: serde_json::Value = resp.json().await.unwrap(); let agent_id = body["agent_id"].as_str().unwrap().to_string(); // Create trigger (Lifecycle pattern — simplest variant) let resp = client .post(format!("{}/api/triggers", server.base_url)) .json(&serde_json::json!({ "agent_id": agent_id, "pattern": "lifecycle", "prompt_template": "Handle: {{event}}", "max_fires": 5 })) .send() .await .unwrap(); assert_eq!(resp.status(), 201); let body: serde_json::Value = resp.json().await.unwrap(); let trigger_id = body["trigger_id"].as_str().unwrap().to_string(); assert_eq!(body["agent_id"], agent_id); // List triggers (unfiltered) let resp = client .get(format!("{}/api/triggers", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let triggers: Vec = resp.json().await.unwrap(); assert_eq!(triggers.len(), 1); assert_eq!(triggers[0]["agent_id"], agent_id); assert_eq!(triggers[0]["enabled"], true); assert_eq!(triggers[0]["max_fires"], 5); // List triggers (filtered by agent_id) let resp = client .get(format!( "{}/api/triggers?agent_id={}", server.base_url, agent_id )) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let triggers: Vec = resp.json().await.unwrap(); assert_eq!(triggers.len(), 1); // Delete trigger let resp = client .delete(format!("{}/api/triggers/{}", server.base_url, trigger_id)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); // List triggers (should be empty) let resp = client .get(format!("{}/api/triggers", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let triggers: Vec = resp.json().await.unwrap(); assert_eq!(triggers.len(), 0); } #[tokio::test] async fn test_invalid_agent_id_returns_400() { let server = start_test_server().await; let client = reqwest::Client::new(); // Send message to invalid ID let resp = client .post(format!("{}/api/agents/not-a-uuid/message", server.base_url)) .json(&serde_json::json!({"message": "hello"})) .send() .await .unwrap(); assert_eq!(resp.status(), 400); let body: serde_json::Value = resp.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("Invalid")); // Kill invalid ID let resp = client .delete(format!("{}/api/agents/not-a-uuid", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 400); // Session for invalid ID let resp = client .get(format!("{}/api/agents/not-a-uuid/session", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 400); } #[tokio::test] async fn test_kill_nonexistent_agent_returns_404() { let server = start_test_server().await; let client = reqwest::Client::new(); let fake_id = uuid::Uuid::new_v4(); let resp = client .delete(format!("{}/api/agents/{}", server.base_url, fake_id)) .send() .await .unwrap(); assert_eq!(resp.status(), 404); } #[tokio::test] async fn test_spawn_invalid_manifest_returns_400() { let server = start_test_server().await; let client = reqwest::Client::new(); let resp = client .post(format!("{}/api/agents", server.base_url)) .json(&serde_json::json!({"manifest_toml": "this is {{ not valid toml"})) .send() .await .unwrap(); assert_eq!(resp.status(), 400); let body: serde_json::Value = resp.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("Invalid manifest")); } #[tokio::test] async fn test_request_id_header_is_uuid() { let server = start_test_server().await; let client = reqwest::Client::new(); let resp = client .get(format!("{}/api/health", server.base_url)) .send() .await .unwrap(); let request_id = resp .headers() .get("x-request-id") .expect("x-request-id header should be present"); let id_str = request_id.to_str().unwrap(); assert!( uuid::Uuid::parse_str(id_str).is_ok(), "x-request-id should be a valid UUID, got: {}", id_str ); } #[tokio::test] async fn test_multiple_agents_lifecycle() { let server = start_test_server().await; let client = reqwest::Client::new(); // Spawn 3 agents let mut ids = Vec::new(); for i in 0..3 { let manifest = format!( r#" name = "agent-{i}" version = "0.1.0" description = "Multi-agent test {i}" author = "test" module = "builtin:chat" [model] provider = "ollama" model = "test-model" system_prompt = "Agent {i}." [capabilities] memory_read = ["*"] memory_write = ["self.*"] "# ); let resp = client .post(format!("{}/api/agents", server.base_url)) .json(&serde_json::json!({"manifest_toml": manifest})) .send() .await .unwrap(); assert_eq!(resp.status(), 201); let body: serde_json::Value = resp.json().await.unwrap(); ids.push(body["agent_id"].as_str().unwrap().to_string()); } // List should show 3 let resp = client .get(format!("{}/api/agents", server.base_url)) .send() .await .unwrap(); let agents: Vec = resp.json().await.unwrap(); assert_eq!(agents.len(), 3); // Status should agree let resp = client .get(format!("{}/api/status", server.base_url)) .send() .await .unwrap(); let status: serde_json::Value = resp.json().await.unwrap(); assert_eq!(status["agent_count"], 3); // Kill one let resp = client .delete(format!("{}/api/agents/{}", server.base_url, ids[1])) .send() .await .unwrap(); assert_eq!(resp.status(), 200); // List should show 2 let resp = client .get(format!("{}/api/agents", server.base_url)) .send() .await .unwrap(); let agents: Vec = resp.json().await.unwrap(); assert_eq!(agents.len(), 2); // Kill the rest for id in [&ids[0], &ids[2]] { client .delete(format!("{}/api/agents/{}", server.base_url, id)) .send() .await .unwrap(); } // List should be empty let resp = client .get(format!("{}/api/agents", server.base_url)) .send() .await .unwrap(); let agents: Vec = resp.json().await.unwrap(); assert_eq!(agents.len(), 0); } // --------------------------------------------------------------------------- // Auth integration tests // --------------------------------------------------------------------------- /// Start a test server with Bearer-token authentication enabled. async fn start_test_server_with_auth(api_key: &str) -> TestServer { let tmp = tempfile::tempdir().expect("Failed to create temp dir"); let config = KernelConfig { home_dir: tmp.path().to_path_buf(), data_dir: tmp.path().join("data"), api_key: api_key.to_string(), default_model: DefaultModelConfig { provider: "ollama".to_string(), model: "test-model".to_string(), api_key_env: "OLLAMA_API_KEY".to_string(), base_url: None, }, ..KernelConfig::default() }; let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot"); let kernel = Arc::new(kernel); kernel.set_self_handle(); let state = Arc::new(AppState { kernel, started_at: Instant::now(), peer_registry: None, bridge_manager: tokio::sync::Mutex::new(None), channels_config: tokio::sync::RwLock::new(Default::default()), shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); let api_key_state = state.kernel.config.api_key.clone(); let app = Router::new() .route("/api/health", axum::routing::get(routes::health)) .route("/api/status", axum::routing::get(routes::status)) .route( "/api/agents", axum::routing::get(routes::list_agents).post(routes::spawn_agent), ) .route( "/api/agents/{id}/message", axum::routing::post(routes::send_message), ) .route( "/api/agents/{id}/session", axum::routing::get(routes::get_agent_session), ) .route("/api/agents/{id}/ws", axum::routing::get(ws::agent_ws)) .route( "/api/agents/{id}", axum::routing::delete(routes::kill_agent), ) .route( "/api/triggers", axum::routing::get(routes::list_triggers).post(routes::create_trigger), ) .route( "/api/triggers/{id}", axum::routing::delete(routes::delete_trigger), ) .route( "/api/workflows", axum::routing::get(routes::list_workflows).post(routes::create_workflow), ) .route( "/api/workflows/{id}/run", axum::routing::post(routes::run_workflow), ) .route( "/api/workflows/{id}/runs", axum::routing::get(routes::list_workflow_runs), ) .route("/api/shutdown", axum::routing::post(routes::shutdown)) .layer(axum::middleware::from_fn_with_state( api_key_state, middleware::auth, )) .layer(axum::middleware::from_fn(middleware::request_logging)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) .with_state(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("Failed to bind test server"); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); TestServer { base_url: format!("http://{}", addr), state, _tmp: tmp, } } #[tokio::test] async fn test_auth_health_is_public() { let server = start_test_server_with_auth("secret-key-123").await; let client = reqwest::Client::new(); // /api/health should be accessible without auth let resp = client .get(format!("{}/api/health", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); } #[tokio::test] async fn test_auth_rejects_no_token() { let server = start_test_server_with_auth("secret-key-123").await; let client = reqwest::Client::new(); // Protected endpoint without auth header → 401 // Note: /api/status is public (dashboard needs it), so use a protected endpoint let resp = client .get(format!("{}/api/commands", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 401); let body: serde_json::Value = resp.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("Missing")); } #[tokio::test] async fn test_auth_rejects_wrong_token() { let server = start_test_server_with_auth("secret-key-123").await; let client = reqwest::Client::new(); // Wrong bearer token → 401 // Note: /api/status is public (dashboard needs it), so use a protected endpoint let resp = client .get(format!("{}/api/commands", server.base_url)) .header("authorization", "Bearer wrong-key") .send() .await .unwrap(); assert_eq!(resp.status(), 401); let body: serde_json::Value = resp.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("Invalid")); } #[tokio::test] async fn test_auth_accepts_correct_token() { let server = start_test_server_with_auth("secret-key-123").await; let client = reqwest::Client::new(); // Correct bearer token → 200 let resp = client .get(format!("{}/api/status", server.base_url)) .header("authorization", "Bearer secret-key-123") .send() .await .unwrap(); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["status"], "running"); } #[tokio::test] async fn test_auth_disabled_when_no_key() { // Empty API key = auth disabled let server = start_test_server().await; let client = reqwest::Client::new(); // Protected endpoint accessible without auth when no key is configured let resp = client .get(format!("{}/api/status", server.base_url)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); }