stemedb/crates/stemedb-api/tests/http_integration.rs
jordan c59066949a feat: Add quickstart "Beyond Hello World" sections with Skeptic and Layered endpoints
- Add Layered() method to Go SDK for per-source-class consensus queries
- Add LayeredQueryParams, LayeredResult, TierResolution types to Go SDK
- Create conflict example demonstrating Skeptic and Layered endpoints
- Update quickstart.md with sections 6 (conflict detection) and 7 (authority tiers)
- Remove tracked Go binary and add data/ to .gitignore

The new quickstart sections demonstrate Episteme's differentiating features:
- Skeptic endpoint shows "Trust but Verify" conflict analysis
- Layered endpoint shows per-tier resolution (Clinical vs Anecdotal)

Note: Pre-existing large files flagged by pre-commit hook (technical debt from prior sessions)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:00:59 -07:00

645 lines
22 KiB
Rust

//! 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);
}
}