Add CRC32C checksums to WAL record format (v2), implement crash recovery with automatic truncation of corrupt records, add feature-gated group commit buffer for batched fsync under concurrent load, and implement log rotation via segment files with global offset addressing. Key changes: - Record format v2: [len:u32][crc32c:u32][blake3:32][payload:N] - recover_file() scans and truncates corrupt tail records - GroupCommitBuffer batches fsync via MPSC channel (tokio feature gate) - SegmentManager with binary search resolution and cursor-based cleanup - Journal::read() auto-refreshes segments on miss for writer/reader split - Split recovery.rs and key_codec.rs into directory modules for 500-line max Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
338 lines
12 KiB
Rust
338 lines
12 KiB
Rust
//! 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::{HybridStore, QuotaStore};
|
|
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 write_journal = Journal::open(&wal_dir).expect("write journal");
|
|
let read_journal = Journal::open(&wal_dir).expect("read journal");
|
|
let store = Arc::new(HybridStore::open(&db_dir).expect("store"));
|
|
|
|
let state = AppState::new(write_journal, read_journal, store.clone());
|
|
let quota_store = state.quota_store.clone();
|
|
|
|
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 write_journal = Journal::open(&wal_dir).expect("write journal");
|
|
let read_journal = Journal::open(&wal_dir).expect("read journal");
|
|
let store = Arc::new(HybridStore::open(&db_dir).expect("store"));
|
|
|
|
let state = AppState::new(write_journal, read_journal, store.clone());
|
|
let quota_store = state.quota_store.clone();
|
|
|
|
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 write_journal = Journal::open(&wal_dir).expect("write journal");
|
|
let read_journal = Journal::open(&wal_dir).expect("read journal");
|
|
let store = Arc::new(HybridStore::open(&db_dir).expect("store"));
|
|
|
|
let state = AppState::new(write_journal, read_journal, store.clone());
|
|
let quota_store = state.quota_store.clone();
|
|
|
|
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::<u64>().is_ok());
|
|
assert!(limit.to_str().expect("limit str").parse::<u64>().is_ok());
|
|
assert!(reset.to_str().expect("reset str").parse::<u64>().is_ok());
|
|
}
|