//! HTTP integration tests for basic endpoint functionality. //! //! Coverage: //! - GET /v1/health - Health check endpoint //! - Basic response structure validation //! - Server availability tests //! - Real TCP listener tests (ConnectInfo injection) #![allow(clippy::expect_used)] mod common; use axum::{ body::Body, http::{Request, StatusCode}, }; use tower::ServiceExt; use stemedb_api::create_router; // ============================================================================ // Health Check Tests // ============================================================================ #[tokio::test] async fn test_health_check() { let env = common::create_test_env().await; let app = create_router(env.state); let request = Request::builder().uri("/v1/health").method("GET").body(Body::empty()).expect("Request"); let response = app.oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("Body"); let json: serde_json::Value = serde_json::from_slice(&body).expect("JSON"); assert_eq!(json["status"], "healthy"); assert!(json["version"].is_string()); assert_eq!(json["assertions_count"], 0); } /// Test health check over a real TCP connection. /// /// This catches the ConnectInfo injection bug where /// rate_limit_middleware requires ConnectInfo but the server didn't /// provide it via into_make_service_with_connect_info(). #[tokio::test] async fn test_health_check_over_tcp() { use std::net::SocketAddr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; let env = common::create_test_env().await; let app = create_router(env.state); // Bind to a random available port let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("local_addr"); // Serve with ConnectInfo injection (the fix for the 500 bug) tokio::spawn(async move { axum::serve(listener, app.into_make_service_with_connect_info::()) .await .expect("server"); }); // Give the server a moment to start tokio::time::sleep(std::time::Duration::from_millis(50)).await; // Make a raw HTTP/1.1 request over TCP let mut stream = tokio::net::TcpStream::connect(addr).await.expect("connect"); let request = format!("GET /v1/health HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", addr); stream.write_all(request.as_bytes()).await.expect("write"); let mut response = String::new(); stream.read_to_string(&mut response).await.expect("read"); // Verify we got 200 OK, not 500 assert!( response.starts_with("HTTP/1.1 200"), "health check over TCP should return 200, not 500 (ConnectInfo must be injected). Got: {}", response.lines().next().unwrap_or("empty") ); // Extract JSON body (after the blank line separating headers from body) let body = response.split("\r\n\r\n").nth(1).expect("response body"); let json: serde_json::Value = serde_json::from_str(body).expect("json parse"); assert_eq!(json["status"], "healthy"); } // ============================================================================ // Signature Verification Tests (pre-WAL validation) // ============================================================================ /// Test: POST /v1/assert with invalid signatures returns 400 (not 201). /// /// Regression test for the "assert returns 201 but data is silently dropped" bug. /// Previously, the API accepted structurally valid but cryptographically invalid /// signatures, wrote them to the WAL, and returned 201. The IngestWorker would /// then silently reject them, permanently blocking the ingestion pipeline. #[tokio::test] async fn test_assert_invalid_signature_returns_400() { use serde_json::json; let env = common::create_test_env().await; let app = create_router(env.state); // Construct assertion with structurally valid but cryptographically invalid signature. // agent_id is a SHA-256 hash (not a valid Ed25519 public key). // signature is random 64 bytes. let body = json!({ "subject": "test/bug_regression", "predicate": "has_value", "object": {"type": "Text", "value": "hello"}, "confidence": 0.9, "source_hash": "0".repeat(64), "signatures": [{ "agent_id": "a".repeat(64), "signature": "b".repeat(128), "timestamp": 1700000000 }], "timestamp": 1700000000 }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("Content-Type", "application/json") .body(Body::from(serde_json::to_vec(&body).expect("json"))) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); assert_eq!( response.status(), StatusCode::BAD_REQUEST, "Invalid signature should return 400, not 201" ); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("Body"); let json: serde_json::Value = serde_json::from_slice(&body).expect("JSON"); // Verify error message mentions signature let error_msg = json["error"].as_str().unwrap_or(""); assert!( error_msg.contains("Signature") || error_msg.contains("signature"), "Error should mention signature failure, got: {}", error_msg ); } /// Test: POST /v1/assert with valid Ed25519 signature returns 201. #[tokio::test] async fn test_assert_valid_signature_returns_201() { let env = common::create_test_env().await; let app = create_router(env.state); let body = common::create_signed_assertion_json("test/valid", "has_value", 42.0); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("Content-Type", "application/json") .body(Body::from(serde_json::to_vec(&body).expect("json"))) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::CREATED, "Valid signature should return 201"); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("Body"); let json: serde_json::Value = serde_json::from_slice(&body).expect("JSON"); assert_eq!(json["status"], "created"); } /// Test: POST /v1/assert with null byte in subject returns 400. #[tokio::test] async fn test_assert_null_byte_subject_returns_400() { let env = common::create_test_env().await; let app = create_router(env.state); // Use a properly signed assertion but with null byte in subject let body = common::create_signed_assertion_json("test\x00injected", "has_value", 1.0); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("Content-Type", "application/json") .body(Body::from(serde_json::to_vec(&body).expect("json"))) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); // Should fail with 400 due to null byte in subject assert_eq!( response.status(), StatusCode::BAD_REQUEST, "Null byte in subject should return 400" ); }