//! Comprehensive HTTP integration tests for the StemeDB API. //! //! These tests verify the full HTTP layer without background workers, //! focusing on request validation, error handling, and response structure. //! //! Coverage: //! - POST /v1/assert - Assertion creation //! - POST /v1/vote - Vote submission //! - GET /v1/query - Query with lens parameter //! - Error responses (400, 500) //! - Rate limiting via QuotaStore (when enabled) #![allow(clippy::expect_used)] use axum::{ body::Body, http::{Request, StatusCode}, }; use ed25519_dalek::{Signer, SigningKey}; use rand::rngs::OsRng; use serde_json::json; use std::sync::Arc; use tower::ServiceExt; use stemedb_api::{create_router, create_router_with_meter, AppState}; use stemedb_storage::{GenericQuotaStore, QuotaStore, SledStore}; use stemedb_wal::Journal; // ============================================================================ // Test Environment Setup // ============================================================================ /// Test environment that keeps temp directories alive for the test duration. struct TestEnvironment { _temp_dir: tempfile::TempDir, state: AppState, } /// Helper to create a test environment with temporary directories. async fn create_test_env() -> TestEnvironment { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); let wal_dir = temp_dir.path().join("wal"); let db_dir = temp_dir.path().join("db"); std::fs::create_dir_all(&wal_dir).expect("failed to create wal dir"); std::fs::create_dir_all(&db_dir).expect("failed to create db dir"); let journal = Journal::open(&wal_dir).expect("failed to open journal"); let store = SledStore::open(&db_dir).expect("failed to open store"); let state = AppState::new(journal, store); TestEnvironment { _temp_dir: temp_dir, state } } /// Helper to sign a message using Ed25519. fn sign_message(message: &str) -> ([u8; 32], [u8; 64]) { let mut csprng = OsRng; let signing_key = SigningKey::generate(&mut csprng); let verifying_key = signing_key.verifying_key(); let signature = signing_key.sign(message.as_bytes()); (verifying_key.to_bytes(), signature.to_bytes()) } // ============================================================================ // POST /v1/assert - Create Assertion Tests // ============================================================================ #[tokio::test] async fn test_assert_valid_creation() { let env = create_test_env().await; let app = create_router(env.state); let subject = "Test_Entity"; let predicate = "test_property"; let message = format!("{}:{}", subject, predicate); let (agent_id, signature) = sign_message(&message); let assertion = json!({ "subject": subject, "predicate": predicate, "object": {"type": "Number", "value": 42.0}, "confidence": 0.95, "signatures": [{ "agent_id": hex::encode(agent_id), "signature": hex::encode(signature), "timestamp": 1234567890 }], "source_hash": hex::encode([0u8; 32]) }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&assertion).expect("JSON serialization"))) .expect("Failed to build request"); let response = app.oneshot(request).await.expect("Request failed"); assert_eq!(response.status(), StatusCode::CREATED); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("Failed to read body"); let json: serde_json::Value = serde_json::from_slice(&body).expect("Failed to parse JSON"); assert_eq!(json["status"], "created"); assert!(json["hash"].is_string()); assert_eq!(json["hash"].as_str().expect("hash").len(), 64); // BLAKE3 = 32 bytes = 64 hex } #[tokio::test] async fn test_assert_response_includes_hash() { let env = create_test_env().await; let app = create_router(env.state); let (agent_id, signature) = sign_message("Test:test"); let assertion = json!({ "subject": "Test", "predicate": "test", "object": {"type": "Text", "value": "test_value"}, "confidence": 0.8, "signatures": [{ "agent_id": hex::encode(agent_id), "signature": hex::encode(signature), "timestamp": 1000000000 }], "source_hash": hex::encode([1u8; 32]) }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&assertion).expect("JSON"))) .expect("Request build"); let response = app.oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::CREATED); 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 parse"); // Verify hash is present and correctly formatted let hash = json["hash"].as_str().expect("hash should be present"); assert_eq!(hash.len(), 64); assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); } #[tokio::test] async fn test_assert_missing_signature_fails() { let env = create_test_env().await; let app = create_router(env.state); let assertion = json!({ "subject": "Test", "predicate": "test", "object": {"type": "Number", "value": 1.0}, "confidence": 0.9, "signatures": [], // Empty signatures "source_hash": hex::encode([0u8; 32]) }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&assertion).expect("JSON"))) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); 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!(json["error"] .as_str() .expect("error message") .contains("At least one signature is required")); } // ============================================================================ // POST /v1/vote - Submit Vote Tests // ============================================================================ #[tokio::test] async fn test_vote_valid_submission() { let env = create_test_env().await; let app = create_router(env.state); let (agent_id, signature) = sign_message("vote_message"); let vote = json!({ "assertion_hash": hex::encode([0u8; 32]), "agent_id": hex::encode(agent_id), "weight": 0.75, "signature": hex::encode(signature) }); let request = Request::builder() .uri("/v1/vote") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&vote).expect("JSON"))) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::CREATED); 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"); assert!(json["hash"].is_string()); assert_eq!(json["hash"].as_str().expect("hash").len(), 64); } #[tokio::test] async fn test_vote_response_structure() { let env = create_test_env().await; let app = create_router(env.state); let (agent_id, signature) = sign_message("test"); let vote = json!({ "assertion_hash": hex::encode([1u8; 32]), "agent_id": hex::encode(agent_id), "weight": 1.0, "signature": hex::encode(signature) }); let request = Request::builder() .uri("/v1/vote") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&vote).expect("JSON"))) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); 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 response structure matches CreateResponse assert!(json.get("hash").is_some()); assert!(json.get("status").is_some()); assert_eq!(json.as_object().expect("object").len(), 2); } #[tokio::test] async fn test_vote_invalid_weight_fails() { let env = create_test_env().await; let app = create_router(env.state); let (agent_id, signature) = sign_message("test"); // Test weight > 1.0 let vote = json!({ "assertion_hash": hex::encode([0u8; 32]), "agent_id": hex::encode(agent_id), "weight": 1.5, "signature": hex::encode(signature) }); let request = Request::builder() .uri("/v1/vote") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&vote).expect("JSON"))) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); 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!(json["error"].as_str().expect("error").contains("Weight must be between 0.0 and 1.0")); } // ============================================================================ // GET /v1/query - Query Assertions Tests // ============================================================================ #[tokio::test] async fn test_query_basic_with_subject_predicate() { let env = create_test_env().await; let app = create_router(env.state); let request = Request::builder() .uri("/v1/query?subject=Test_Entity&predicate=test_property") .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"); // Verify response structure (empty since DB is empty) assert!(json.get("assertions").is_some()); assert!(json.get("total_count").is_some()); assert!(json.get("has_more").is_some()); assert_eq!(json["assertions"], json!([])); assert_eq!(json["total_count"], 0); } #[tokio::test] async fn test_query_with_lens_recency() { let env = create_test_env().await; let app = create_router(env.state); let request = Request::builder() .uri("/v1/query?subject=Test&predicate=prop&lens=Recency") .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"); // When a lens is applied (even with empty results), conflict_score and // resolution_confidence should be None (empty result set) assert_eq!(json["assertions"], json!([])); assert_eq!(json["total_count"], 0); assert!(json.get("conflict_score").is_none() || json["conflict_score"].is_null()); assert!(json.get("resolution_confidence").is_none() || json["resolution_confidence"].is_null()); } #[tokio::test] async fn test_query_lifecycle_filtering() { let env = create_test_env().await; let app = create_router(env.state); let request = Request::builder() .uri("/v1/query?subject=Test&lifecycle=Approved") .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["assertions"], json!([])); assert_eq!(json["total_count"], 0); } #[tokio::test] async fn test_query_with_limit() { let env = create_test_env().await; let app = create_router(env.state); let request = Request::builder() .uri("/v1/query?subject=Test&limit=10") .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"); // Verify query executes successfully (limit parameter is accepted) assert!(json.get("assertions").is_some()); } // ============================================================================ // Error Response Tests // ============================================================================ #[tokio::test] async fn test_400_bad_request_invalid_input() { let env = create_test_env().await; let app = create_router(env.state); // Invalid JSON payload let invalid_json = b"{invalid json}"; let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(invalid_json.to_vec())) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_error_message_format() { let env = create_test_env().await; let app = create_router(env.state); // Send assertion with invalid confidence let (agent_id, signature) = sign_message("test"); let assertion = json!({ "subject": "Test", "predicate": "test", "object": {"type": "Number", "value": 1.0}, "confidence": 2.0, // Invalid: > 1.0 "signatures": [{ "agent_id": hex::encode(agent_id), "signature": hex::encode(signature), "timestamp": 1000 }], "source_hash": hex::encode([0u8; 32]) }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&assertion).expect("JSON"))) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); 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 response structure matches ErrorResponse DTO assert!(json.get("error").is_some()); assert!(json.get("code").is_some()); assert!(json["error"].as_str().expect("error message").contains("Confidence")); } #[tokio::test] async fn test_hex_validation_wrong_length() { let env = create_test_env().await; let app = create_router(env.state); let (agent_id, signature) = sign_message("test"); let assertion = json!({ "subject": "Test", "predicate": "test", "object": {"type": "Number", "value": 1.0}, "confidence": 0.9, "signatures": [{ "agent_id": hex::encode(agent_id), "signature": hex::encode(signature), "timestamp": 1000 }], "source_hash": "abc" // Too short - should be 64 hex chars }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&assertion).expect("JSON"))) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); 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!(json["error"].as_str().expect("error").contains("Expected 64 hex characters")); } // ============================================================================ // Rate Limiting Tests (QuotaStore Middleware) // ============================================================================ #[tokio::test] async fn test_quota_consumption_with_meter() { let temp_dir = tempfile::tempdir().expect("temp dir"); let wal_dir = temp_dir.path().join("wal"); let db_dir = temp_dir.path().join("db"); std::fs::create_dir_all(&wal_dir).expect("wal dir"); std::fs::create_dir_all(&db_dir).expect("db dir"); let journal = Journal::open(&wal_dir).expect("journal"); let store = Arc::new(SledStore::open(&db_dir).expect("store")); // Create AppState manually to share quota_store let quota_store = Arc::new(GenericQuotaStore::new(store.clone())); let state = AppState { journal: Arc::new(tokio::sync::Mutex::new(journal)), store: store.clone(), quota_store: quota_store.clone(), }; let app = create_router_with_meter(state); let (agent_id, signature) = sign_message("test"); let agent_id_hex = hex::encode(agent_id); // Set a low quota limit for testing quota_store.set_quota_limit(&agent_id, 50).await.expect("set quota"); let assertion = json!({ "subject": "QuotaTest", "predicate": "test", "object": {"type": "Number", "value": 1.0}, "confidence": 0.9, "signatures": [{ "agent_id": agent_id_hex, "signature": hex::encode(signature), "timestamp": 1000 }], "source_hash": hex::encode([0u8; 32]) }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .header("x-agent-id", &agent_id_hex) .body(Body::from(serde_json::to_vec(&assertion).expect("JSON"))) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); // Should succeed and include quota headers assert_eq!(response.status(), StatusCode::CREATED); let headers = response.headers(); assert!(headers.get("x-quota-remaining").is_some()); assert!(headers.get("x-quota-limit").is_some()); assert!(headers.get("x-quota-reset").is_some()); } #[tokio::test] async fn test_quota_exceeded_response() { let temp_dir = tempfile::tempdir().expect("temp dir"); let wal_dir = temp_dir.path().join("wal"); let db_dir = temp_dir.path().join("db"); std::fs::create_dir_all(&wal_dir).expect("wal dir"); std::fs::create_dir_all(&db_dir).expect("db dir"); let journal = Journal::open(&wal_dir).expect("journal"); let store = Arc::new(SledStore::open(&db_dir).expect("store")); let quota_store = Arc::new(GenericQuotaStore::new(store.clone())); let state = AppState { journal: Arc::new(tokio::sync::Mutex::new(journal)), store: store.clone(), quota_store: quota_store.clone(), }; let app = create_router_with_meter(state); let (agent_id, _) = sign_message("test"); let agent_id_hex = hex::encode(agent_id); // Set quota to 0 to immediately trigger quota exceeded quota_store.set_quota_limit(&agent_id, 0).await.expect("set quota"); let request = Request::builder() .uri("/v1/query?subject=Test") .method("GET") .header("x-agent-id", &agent_id_hex) .body(Body::empty()) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); // Should return 429 Too Many Requests assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); 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!(json.get("error").is_some()); assert_eq!(json["code"], "QUOTA_EXCEEDED"); assert!(json.get("remaining").is_some()); assert!(json.get("limit").is_some()); assert!(json.get("reset_at").is_some()); } // ============================================================================ // Additional Edge Cases // ============================================================================ #[tokio::test] async fn test_health_endpoint() { let env = 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.get("version").is_some()); } #[tokio::test] async fn test_not_found_endpoint() { let env = create_test_env().await; let app = create_router(env.state); let request = Request::builder() .uri("/v1/nonexistent") .method("GET") .body(Body::empty()) .expect("Request"); let response = app.oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_query_with_all_lenses() { let env = create_test_env().await; let app = create_router(env.state); // Test that all lens values are accepted let lenses = vec!["Recency", "Consensus", "Confidence", "Authority", "VoteAwareConsensus"]; for lens in lenses { let request = Request::builder() .uri(format!("/v1/query?subject=Test&predicate=prop&lens={}", lens)) .method("GET") .body(Body::empty()) .expect("Request"); let response = app.clone().oneshot(request).await.expect("Request"); assert_eq!(response.status(), StatusCode::OK, "Lens {} should be accepted", lens); } }