## Problem CLI-created community corpus items (tier 3) were stored correctly but invisible via API queries. Two issues blocked discoverability: 1. **Prefix mismatch**: API hardcoded 'community://pattern/' for aggregated patterns, but CLI creates 'community://rust/http/...' URIs 2. **Query parameter parsing**: Axum's default parser doesn't support bracket notation (?sources[]=value) used by the dashboard Result: 0/22 CLI-created items were queryable. ## Solution ### Fix 1: Broaden Community Prefix - Changed: 'community://pattern/' → 'community://' in corpus handler - Impact: Now matches both aggregated patterns AND CLI-created items - Backward compatible: Broader prefix includes narrower results ### Fix 2: Add QsQuery Extractor - Added: serde_qs dependency + custom QsQuery extractor - Supports: Bracket notation for array parameters (?sources[]=a&sources[]=b) - Compatible: Works with JavaScript URLSearchParams standard - Tested: 3 new unit tests for extractor behavior ## Verification - ✅ All 22 CLI-created community items now queryable (was 0) - ✅ Source filtering works: community (22), RFC (2), vendor (5) - ✅ Multi-source queries work: ?sources[]=community&sources[]=rfc → 24 - ✅ All 89 API tests pass + 3 new extractor tests - ✅ Clippy clean (0 warnings) - ✅ No regressions in existing functionality ## Files Changed - crates/stemedb-api/Cargo.toml: Add serde_qs dependency - crates/stemedb-api/src/extractors.rs: New QsQuery extractor (117 lines) - crates/stemedb-api/src/handlers/aphoria/corpus.rs: Use QsQuery, broaden prefix - crates/stemedb-api/src/lib.rs: Export extractors module Also includes: Scale-adaptive thresholds, wiki corpus extraction, documentation updates, and dashboard UI improvements from prior work. Co-Authored-By: Claude Sonnet 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(), None);
|
|
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(), None);
|
|
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(), None);
|
|
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());
|
|
}
|