//! HTTP integration tests for advanced features and edge cases. //! //! Coverage: //! - GET /v1/health - Health check endpoint //! - POST /v1/admin/decay-trust-ranks - Admin decay operations //! - Rate limiting via QuotaStore middleware //! - 404 Not Found responses //! - Edge cases and error scenarios #![allow(clippy::expect_used)] mod common; use axum::{ body::Body, http::{Request, StatusCode}, }; use serde_json::json; use std::sync::Arc; use tower::ServiceExt; use stemedb_api::{create_router, create_router_with_meter, AppState}; use stemedb_storage::{GenericEscalationStore, GenericQuotaStore, QuotaStore, SledStore}; use stemedb_wal::Journal; // ============================================================================ // Health and System Tests // ============================================================================ #[tokio::test] async fn test_health_endpoint() { 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.get("version").is_some()); } #[tokio::test] async fn test_not_found_endpoint() { let env = common::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); } // ============================================================================ // POST /v1/admin/decay-trust-ranks - Admin Decay Tests // ============================================================================ #[tokio::test] async fn test_decay_trust_ranks_empty_store() { let env = common::create_test_env().await; let app = create_router(env.state); let request = Request::builder() .uri("/v1/admin/decay-trust-ranks") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&json!({})).expect("JSON"))) .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["decayed_count"], 0); assert!(json["timestamp_used"].is_number()); assert_eq!(json["status"], "Decay operation completed"); } #[tokio::test] async fn test_decay_trust_ranks_with_custom_params() { let env = common::create_test_env().await; let app = create_router(env.state); let request_body = json!({ "now": 1704067200, "half_life_seconds": 604800 }); let request = Request::builder() .uri("/v1/admin/decay-trust-ranks") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&request_body).expect("JSON"))) .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["timestamp_used"], 1704067200); assert_eq!(json["half_life_used"], 604800); } #[tokio::test] async fn test_decay_trust_ranks_response_structure() { let env = common::create_test_env().await; let app = create_router(env.state); let request = Request::builder() .uri("/v1/admin/decay-trust-ranks") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&json!({})).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 all expected fields are present assert!(json.get("decayed_count").is_some()); assert!(json.get("timestamp_used").is_some()); assert!(json.get("half_life_used").is_some()); assert!(json.get("status").is_some()); assert_eq!(json.as_object().expect("object").len(), 4); } #[tokio::test] async fn test_decay_trust_ranks_actually_decays() { use stemedb_storage::{GenericTrustRankStore, TrustRank, TrustRankStore}; let env = common::create_test_env().await; // Pre-populate TrustRank with an old timestamp let agent_id = [42u8; 32]; let mut trust_rank = TrustRank::new(agent_id, 1000); trust_rank.score = 0.8; let trust_store = GenericTrustRankStore::new((*env.state.store).clone()); trust_store.put_trust_rank(&trust_rank).await.expect("put trust rank"); let app = create_router(env.state.clone()); // Decay with a timestamp far in the future (30+ days) let thirty_one_days = 31 * 24 * 60 * 60; let request_body = json!({ "now": 1000 + thirty_one_days, "half_life_seconds": 30 * 24 * 60 * 60 // 30 days }); let request = Request::builder() .uri("/v1/admin/decay-trust-ranks") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&request_body).expect("JSON"))) .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"); // Should have decayed exactly 1 TrustRank assert_eq!(json["decayed_count"], 1); // Verify the score was actually decayed (0.8 * 0.5^(31/30) ≈ 0.37) let decayed_trust = trust_store.get_trust_rank(&agent_id).await.expect("get trust rank"); assert!( decayed_trust.score < 0.5, "Score should be significantly decayed, got {}", decayed_trust.score ); } // ============================================================================ // 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 escalation_store = Arc::new(GenericEscalationStore::new(store.clone())); let state = AppState { journal: Arc::new(tokio::sync::Mutex::new(journal)), store: store.clone(), quota_store: quota_store.clone(), escalation_store, }; let app = create_router_with_meter(state); let (agent_id, signature) = common::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 escalation_store = Arc::new(GenericEscalationStore::new(store.clone())); let state = AppState { journal: Arc::new(tokio::sync::Mutex::new(journal)), store: store.clone(), quota_store: quota_store.clone(), escalation_store, }; let app = create_router_with_meter(state); let (agent_id, _) = common::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()); } #[tokio::test] async fn test_quota_headers_format() { 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 escalation_store = Arc::new(GenericEscalationStore::new(store.clone())); let state = AppState { journal: Arc::new(tokio::sync::Mutex::new(journal)), store: store.clone(), quota_store: quota_store.clone(), escalation_store, }; let app = create_router_with_meter(state); let (agent_id, _) = common::sign_message("test"); let agent_id_hex = hex::encode(agent_id); quota_store.set_quota_limit(&agent_id, 100).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"); assert_eq!(response.status(), StatusCode::OK); let headers = response.headers(); // Verify quota headers are present and parseable let remaining = headers.get("x-quota-remaining").expect("remaining header"); let limit = headers.get("x-quota-limit").expect("limit header"); let reset = headers.get("x-quota-reset").expect("reset header"); assert!(remaining.to_str().expect("remaining str").parse::().is_ok()); assert!(limit.to_str().expect("limit str").parse::().is_ok()); assert!(reset.to_str().expect("reset str").parse::().is_ok()); }