stemedb/crates/stemedb-api/tests/integration_tests.rs
jordan 1ce4004807 feat: Complete Phase 2 (The Cortex) - query, lens, and API layers
This commit adds the read path (Cortex) to complement the write path (Spine):

## Crates
- stemedb-api: HTTP API with axum + utoipa OpenAPI
  - /v1/assert, /v1/query, /v1/epoch, /v1/skeptic, /v1/trace, /v1/audit
  - Metered endpoints with quota enforcement
  - Ed25519 signature verification
- stemedb-lens: Truth resolution lenses
  - RecencyLens, ConsensusLens, ConfidenceLens
  - VoteAwareConsensusLens (Ballot Box pattern)
  - TrustAwareAuthorityLens (The Hive pattern)
  - SkepticLens (conflict analysis)
  - EpochAwareLens (paradigm-safe queries)
- stemedb-query: Query engine with materialized views

## Storage Extensions
- VoteStore: Vote aggregation with cached counts
- TrustRankStore: Agent reputation with decay
- AuditStore: Query audit trail
- IndexStore: SP/P/S index structures
- SupersessionStore: Epoch supersession chains

## SDKs
- sdk/go/steme: Go HTTP client with Ed25519 signing
- sdk/go/adk: ADK-Go tools for AI agents

## Documentation
- Updated CLAUDE.md, architecture.md, roadmap.md
- New ai-lookup entries for all services
- Use case docs for consumer health intelligence
- Arena roadmap for simulation advancement

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

692 lines
24 KiB
Rust

//! Integration tests for the StemeDB API.
#![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 tokio::sync::Mutex;
use tower::ServiceExt; // for `oneshot` and `ready`
use stemedb_api::{create_router, AppState};
use stemedb_ingest::Ingestor;
use stemedb_storage::SledStore;
use stemedb_wal::Journal;
/// Test environment that keeps temp directories alive for the test duration.
///
/// The `_temp_dir` field must be kept alive to prevent the directory from
/// being deleted while the Journal and SledStore are still using it.
struct TestEnvironment {
_temp_dir: tempfile::TempDir,
state: AppState,
}
/// Test environment with full ingestor for roundtrip tests.
struct TestEnvironmentWithIngestor {
_temp_dir: tempfile::TempDir,
state: AppState,
ingestor: Ingestor<SledStore>,
}
/// 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 create a test environment with a running ingestor for roundtrip tests.
///
/// Note: We need to share the same store between AppState and Ingestor.
/// AppState::new() takes ownership, so we need a different approach:
/// we'll create the ingestor first, then construct AppState manually.
async fn create_test_env_with_ingestor() -> TestEnvironmentWithIngestor {
use stemedb_storage::GenericQuotaStore;
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");
// Create shared store
let store = Arc::new(SledStore::open(&db_dir).expect("failed to open store"));
// Journal for API (writing)
let journal_for_api =
Arc::new(Mutex::new(Journal::open(&wal_dir).expect("failed to open journal for API")));
// Journal for ingestor (reading) - WAL allows multiple readers
let journal_for_ingestor =
Arc::new(Mutex::new(Journal::open(&wal_dir).expect("failed to open journal for ingestor")));
// Create ingestor with shared store
let ingestor = Ingestor::new(journal_for_ingestor, store.clone())
.await
.expect("failed to create ingestor");
// Create quota store for AppState
let quota_store = Arc::new(GenericQuotaStore::new(store.clone()));
// Construct AppState manually to share the store
let state = AppState { journal: journal_for_api, store, quota_store };
TestEnvironmentWithIngestor { _temp_dir: temp_dir, state, ingestor }
}
#[tokio::test]
async fn test_health_check() {
let env = create_test_env().await;
let app = create_router(env.state);
let request = Request::builder().uri("/v1/health").method("GET").body(Body::empty()).unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "healthy");
assert!(json["version"].is_string());
assert_eq!(json["assertions_count"], 0);
}
#[tokio::test]
async fn test_dto_confidence_validation() {
let env = create_test_env().await;
let app = create_router(env.state);
// Test confidence > 1.0
let invalid_assertion = json!({
"subject": "Tesla",
"predicate": "has_revenue",
"object": {"type": "Number", "value": 100.0},
"confidence": 1.5,
"signatures": [{
"agent_id": "a".repeat(64),
"signature": "b".repeat(128),
"timestamp": 1234567890
}],
"source_hash": "c".repeat(64)
});
let request = Request::builder()
.uri("/v1/assert")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&invalid_assertion).unwrap()))
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].as_str().unwrap().contains("Confidence must be between 0.0 and 1.0"));
// Test confidence < 0.0
let invalid_assertion = json!({
"subject": "Tesla",
"predicate": "has_revenue",
"object": {"type": "Number", "value": 100.0},
"confidence": -0.5,
"signatures": [{
"agent_id": "a".repeat(64),
"signature": "b".repeat(128),
"timestamp": 1234567890
}],
"source_hash": "c".repeat(64)
});
let request = Request::builder()
.uri("/v1/assert")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&invalid_assertion).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].as_str().unwrap().contains("Confidence must be between 0.0 and 1.0"));
}
#[tokio::test]
async fn test_dto_weight_validation() {
let env = create_test_env().await;
let app = create_router(env.state);
// Test weight > 1.0
let invalid_vote = json!({
"assertion_hash": "a".repeat(64),
"agent_id": "b".repeat(64),
"weight": 1.5,
"signature": "c".repeat(128)
});
let request = Request::builder()
.uri("/v1/vote")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&invalid_vote).unwrap()))
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].as_str().unwrap().contains("Weight must be between 0.0 and 1.0"));
// Test weight < 0.0
let invalid_vote = json!({
"assertion_hash": "a".repeat(64),
"agent_id": "b".repeat(64),
"weight": -0.5,
"signature": "c".repeat(128)
});
let request = Request::builder()
.uri("/v1/vote")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&invalid_vote).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].as_str().unwrap().contains("Weight must be between 0.0 and 1.0"));
}
#[tokio::test]
async fn test_hex_decode_wrong_length() {
let env = create_test_env().await;
let app = create_router(env.state);
// Test assertion with wrong-length source_hash
let invalid_assertion = json!({
"subject": "Tesla",
"predicate": "has_revenue",
"object": {"type": "Number", "value": 100.0},
"confidence": 0.9,
"signatures": [{
"agent_id": "a".repeat(64),
"signature": "b".repeat(128),
"timestamp": 1234567890
}],
"source_hash": "abc" // Too short
});
let request = Request::builder()
.uri("/v1/assert")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&invalid_assertion).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let error_msg = json["error"].as_str().unwrap();
assert!(error_msg.contains("Expected 64 hex characters"));
assert!(error_msg.contains("got 3"));
}
#[tokio::test]
async fn test_hex_decode_valid() {
let env = create_test_env().await;
let app = create_router(env.state);
// Test assertion with valid hex lengths
// Note: This will succeed in parsing/validation but may fail in the ingest worker
// We're primarily testing that hex validation accepts correct-length strings
let valid_assertion = json!({
"subject": "Tesla",
"predicate": "has_revenue",
"object": {"type": "Number", "value": 100.0},
"confidence": 0.9,
"signatures": [{
"agent_id": "a".repeat(64),
"signature": "b".repeat(128),
"timestamp": 1234567890
}],
"source_hash": "c".repeat(64)
});
let request = Request::builder()
.uri("/v1/assert")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&valid_assertion).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
// Accept either 201 (success) or 500 (ingest worker not running in test)
// We're primarily testing that the hex validation doesn't reject valid lengths
assert!(
response.status() == StatusCode::CREATED
|| response.status() == StatusCode::INTERNAL_SERVER_ERROR,
"Expected 201 or 500, got {}",
response.status()
);
}
#[tokio::test]
async fn test_dto_to_assertion_invalid_hex() {
let env = create_test_env().await;
let app = create_router(env.state);
// Test assertion with invalid hex characters
let invalid_assertion = json!({
"subject": "Tesla",
"predicate": "has_revenue",
"object": {"type": "Number", "value": 100.0},
"confidence": 0.9,
"signatures": [{
"agent_id": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", // 'z' is not a valid hex character (64 z's)
"signature": "b".repeat(128),
"timestamp": 1234567890
}],
"source_hash": "c".repeat(64)
});
let request = Request::builder()
.uri("/v1/assert")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&invalid_assertion).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let error = json["error"].as_str().unwrap();
// The error could be "Invalid hex encoding" or similar - just check it's a bad request error
assert!(!error.is_empty(), "Error message should not be empty");
}
#[tokio::test]
async fn test_query_empty_results() {
let env = create_test_env().await;
let app = create_router(env.state);
let request = Request::builder()
.uri("/v1/query?subject=nonexistent")
.method("GET")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["assertions"], json!([]));
assert_eq!(json["total_count"], 0);
assert_eq!(json["has_more"], false);
}
// ============================================================================
// Epoch Endpoint Integration Tests
// ============================================================================
#[tokio::test]
async fn test_create_epoch_success() {
let env = create_test_env().await;
let app = create_router(env.state);
let epoch = json!({
"name": "GAAP-2024",
"start_timestamp": 1704067200
});
let request = Request::builder()
.uri("/v1/epoch")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&epoch).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(status, StatusCode::CREATED, "Body: {:?}", json);
assert_eq!(json["status"], "created");
// BLAKE3 hash = 32 bytes = 64 hex characters
assert_eq!(json["hash"].as_str().unwrap().len(), 64);
}
#[tokio::test]
async fn test_create_epoch_empty_name_fails() {
let env = create_test_env().await;
let app = create_router(env.state);
let epoch = json!({
"name": "",
"start_timestamp": 1704067200
});
let request = Request::builder()
.uri("/v1/epoch")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&epoch).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].as_str().unwrap().contains("cannot be empty"));
}
#[tokio::test]
async fn test_create_epoch_whitespace_name_fails() {
let env = create_test_env().await;
let app = create_router(env.state);
let epoch = json!({
"name": " ",
"start_timestamp": 1704067200
});
let request = Request::builder()
.uri("/v1/epoch")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&epoch).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].as_str().unwrap().contains("cannot be empty"));
}
#[tokio::test]
async fn test_create_epoch_with_supersedes() {
let env = create_test_env().await;
let app = create_router(env.state);
// Supersession requires both supersedes hash AND supersession_type
let epoch = json!({
"name": "GAAP-2025",
"supersedes": "a".repeat(64),
"supersession_type": "Temporal",
"start_timestamp": 1735689600
});
let request = Request::builder()
.uri("/v1/epoch")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&epoch).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "created");
}
#[tokio::test]
async fn test_create_epoch_supersedes_without_type_fails() {
let env = create_test_env().await;
let app = create_router(env.state);
// Providing supersedes but NOT supersession_type should fail
let epoch = json!({
"name": "GAAP-2025",
"supersedes": "a".repeat(64),
// Missing supersession_type!
"start_timestamp": 1735689600
});
let request = Request::builder()
.uri("/v1/epoch")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&epoch).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].as_str().unwrap().contains("supersession_type is required"));
}
#[tokio::test]
async fn test_create_epoch_invalid_supersedes_hex() {
let env = create_test_env().await;
let app = create_router(env.state);
let epoch = json!({
"name": "GAAP-2025",
"supersedes": "invalid", // Too short, should be 64 hex chars
"supersession_type": "Invalidate",
"start_timestamp": 1735689600
});
let request = Request::builder()
.uri("/v1/epoch")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&epoch).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
// Should complain about hex length
assert!(json["error"].as_str().unwrap().contains("Expected 64 hex characters"));
}
#[tokio::test]
async fn test_create_epoch_deterministic_id() {
let env = create_test_env().await;
let app = create_router(env.state);
// Create the same epoch twice - should get the same ID
let epoch = json!({
"name": "Deterministic-Test",
"start_timestamp": 1000
});
let request1 = Request::builder()
.uri("/v1/epoch")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&epoch).unwrap()))
.unwrap();
let response1 = app.clone().oneshot(request1).await.unwrap();
assert_eq!(response1.status(), StatusCode::CREATED);
let body1 = axum::body::to_bytes(response1.into_body(), usize::MAX).await.unwrap();
let json1: serde_json::Value = serde_json::from_slice(&body1).unwrap();
let hash1 = json1["hash"].as_str().unwrap();
let request2 = Request::builder()
.uri("/v1/epoch")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&epoch).unwrap()))
.unwrap();
let response2 = app.oneshot(request2).await.unwrap();
assert_eq!(response2.status(), StatusCode::CREATED);
let body2 = axum::body::to_bytes(response2.into_body(), usize::MAX).await.unwrap();
let json2: serde_json::Value = serde_json::from_slice(&body2).unwrap();
let hash2 = json2["hash"].as_str().unwrap();
// Same input should produce same ID
assert_eq!(hash1, hash2, "Same epoch input should produce deterministic ID");
}
#[tokio::test]
async fn test_create_epoch_defaults_timestamp() {
let env = create_test_env().await;
let app = create_router(env.state);
// Omit start_timestamp - should default to current time
let epoch = json!({
"name": "Default-Timestamp-Test"
});
let request = Request::builder()
.uri("/v1/epoch")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&epoch).unwrap()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
// If we got 201, the timestamp defaulted successfully
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "created");
}
// ============================================================================
// Full Pipeline Integration Tests (POST → Ingest → GET roundtrip)
// ============================================================================
/// Create a properly signed assertion for testing.
fn create_signed_assertion_json(subject: &str, predicate: &str, value: f64) -> serde_json::Value {
let mut csprng = OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let verifying_key = signing_key.verifying_key();
let message = format!("{}:{}", subject, predicate);
let signature = signing_key.sign(message.as_bytes());
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
json!({
"subject": subject,
"predicate": predicate,
"object": {"type": "Number", "value": value},
"confidence": 0.95,
"source_class": "Expert",
"lifecycle": "Proposed",
"signatures": [{
"agent_id": hex::encode(verifying_key.to_bytes()),
"signature": hex::encode(signature.to_bytes()),
"timestamp": timestamp
}],
"source_hash": "0".repeat(64),
"timestamp": timestamp
})
}
/// Test full assertion roundtrip: POST → Ingest → GET
///
/// This test validates the complete pipeline:
/// 1. POST an assertion to /v1/assert (writes to WAL)
/// 2. Run the ingestor to process the WAL entry
/// 3. GET /v1/query to verify the assertion is queryable
#[tokio::test]
async fn test_assertion_roundtrip_with_ingestor() {
let env = create_test_env_with_ingestor().await;
let app = create_router(env.state.clone());
let subject = "RoundtripTest_Entity";
let predicate = "test_property";
let value = 42.0;
// 1. POST the assertion
let assertion = create_signed_assertion_json(subject, predicate, value);
let request = Request::builder()
.uri("/v1/assert")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&assertion).unwrap()))
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED, "POST should succeed with 201");
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let post_result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(post_result["status"], "created");
let assertion_hash = post_result["hash"].as_str().expect("hash should be present");
assert_eq!(assertion_hash.len(), 64, "hash should be 64 hex chars");
// 2. Run the ingestor to process the pending record (synchronous for testing)
let bytes_processed = env.ingestor.process_pending().await.expect("ingestor should process");
assert!(bytes_processed > 0, "Should have processed WAL bytes");
// 3. GET the assertion via query
let request = Request::builder()
.uri(format!("/v1/query?subject={}&predicate={}", subject, predicate))
.method("GET")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let query_result: serde_json::Value = serde_json::from_slice(&body).unwrap();
// Verify the assertion is returned correctly
assert_eq!(query_result["total_count"], 1, "Should find exactly 1 assertion");
let assertions = query_result["assertions"].as_array().expect("assertions array");
assert_eq!(assertions.len(), 1);
let found = &assertions[0];
assert_eq!(found["subject"], subject);
assert_eq!(found["predicate"], predicate);
assert_eq!(found["object"]["type"], "Number");
assert!((found["object"]["value"].as_f64().unwrap() - value).abs() < 0.001);
assert_eq!(found["confidence"], 0.95);
}