- Add `content: Option<String>` to SourceRecord with rkyv schema evolution (LegacySourceRecord compat deserializer for backward compatibility) - Add MAX_SOURCE_CONTENT_LEN (1MB) limit with API validation - Strip content from list responses, include in single-source GET - Update Go SDK RegisterSourceRequest with Content field - FCM pipeline extracts PDF text via pdftotext and passes to registration - Dashboard impact panel fetches and displays source content with expand/collapse - Add feed endpoint, dashboard feed panel, and signed assertion support - Update data-structures.md, API docs, and storage docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
200 lines
7.1 KiB
Rust
200 lines
7.1 KiB
Rust
//! HTTP integration tests for basic endpoint functionality.
|
|
//!
|
|
//! Coverage:
|
|
//! - GET /v1/health - Health check endpoint
|
|
//! - Basic response structure validation
|
|
//! - Server availability tests
|
|
//! - Real TCP listener tests (ConnectInfo injection)
|
|
|
|
#![allow(clippy::expect_used)]
|
|
|
|
mod common;
|
|
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode},
|
|
};
|
|
use tower::ServiceExt;
|
|
|
|
use stemedb_api::create_router;
|
|
|
|
// ============================================================================
|
|
// Health Check Tests
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_health_check() {
|
|
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["version"].is_string());
|
|
assert_eq!(json["assertions_count"], 0);
|
|
}
|
|
|
|
/// Test health check over a real TCP connection.
|
|
///
|
|
/// This catches the ConnectInfo<SocketAddr> injection bug where
|
|
/// rate_limit_middleware requires ConnectInfo but the server didn't
|
|
/// provide it via into_make_service_with_connect_info().
|
|
#[tokio::test]
|
|
async fn test_health_check_over_tcp() {
|
|
use std::net::SocketAddr;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
let env = common::create_test_env().await;
|
|
let app = create_router(env.state);
|
|
|
|
// Bind to a random available port
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let addr = listener.local_addr().expect("local_addr");
|
|
|
|
// Serve with ConnectInfo injection (the fix for the 500 bug)
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
|
.await
|
|
.expect("server");
|
|
});
|
|
|
|
// Give the server a moment to start
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
|
|
// Make a raw HTTP/1.1 request over TCP
|
|
let mut stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
|
|
let request = format!("GET /v1/health HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", addr);
|
|
stream.write_all(request.as_bytes()).await.expect("write");
|
|
|
|
let mut response = String::new();
|
|
stream.read_to_string(&mut response).await.expect("read");
|
|
|
|
// Verify we got 200 OK, not 500
|
|
assert!(
|
|
response.starts_with("HTTP/1.1 200"),
|
|
"health check over TCP should return 200, not 500 (ConnectInfo must be injected). Got: {}",
|
|
response.lines().next().unwrap_or("empty")
|
|
);
|
|
|
|
// Extract JSON body (after the blank line separating headers from body)
|
|
let body = response.split("\r\n\r\n").nth(1).expect("response body");
|
|
let json: serde_json::Value = serde_json::from_str(body).expect("json parse");
|
|
assert_eq!(json["status"], "healthy");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Signature Verification Tests (pre-WAL validation)
|
|
// ============================================================================
|
|
|
|
/// Test: POST /v1/assert with invalid signatures returns 400 (not 201).
|
|
///
|
|
/// Regression test for the "assert returns 201 but data is silently dropped" bug.
|
|
/// Previously, the API accepted structurally valid but cryptographically invalid
|
|
/// signatures, wrote them to the WAL, and returned 201. The IngestWorker would
|
|
/// then silently reject them, permanently blocking the ingestion pipeline.
|
|
#[tokio::test]
|
|
async fn test_assert_invalid_signature_returns_400() {
|
|
use serde_json::json;
|
|
|
|
let env = common::create_test_env().await;
|
|
let app = create_router(env.state);
|
|
|
|
// Construct assertion with structurally valid but cryptographically invalid signature.
|
|
// agent_id is a SHA-256 hash (not a valid Ed25519 public key).
|
|
// signature is random 64 bytes.
|
|
let body = json!({
|
|
"subject": "test/bug_regression",
|
|
"predicate": "has_value",
|
|
"object": {"type": "Text", "value": "hello"},
|
|
"confidence": 0.9,
|
|
"source_hash": "0".repeat(64),
|
|
"signatures": [{
|
|
"agent_id": "a".repeat(64),
|
|
"signature": "b".repeat(128),
|
|
"timestamp": 1700000000
|
|
}],
|
|
"timestamp": 1700000000
|
|
});
|
|
|
|
let request = Request::builder()
|
|
.uri("/v1/assert")
|
|
.method("POST")
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(serde_json::to_vec(&body).expect("json")))
|
|
.expect("Request");
|
|
|
|
let response = app.oneshot(request).await.expect("Request");
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Invalid signature should return 400, not 201"
|
|
);
|
|
|
|
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 message mentions signature
|
|
let error_msg = json["error"].as_str().unwrap_or("");
|
|
assert!(
|
|
error_msg.contains("Signature") || error_msg.contains("signature"),
|
|
"Error should mention signature failure, got: {}",
|
|
error_msg
|
|
);
|
|
}
|
|
|
|
/// Test: POST /v1/assert with valid Ed25519 signature returns 201.
|
|
#[tokio::test]
|
|
async fn test_assert_valid_signature_returns_201() {
|
|
let env = common::create_test_env().await;
|
|
let app = create_router(env.state);
|
|
|
|
let body = common::create_signed_assertion_json("test/valid", "has_value", 42.0);
|
|
|
|
let request = Request::builder()
|
|
.uri("/v1/assert")
|
|
.method("POST")
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(serde_json::to_vec(&body).expect("json")))
|
|
.expect("Request");
|
|
|
|
let response = app.oneshot(request).await.expect("Request");
|
|
assert_eq!(response.status(), StatusCode::CREATED, "Valid signature should return 201");
|
|
|
|
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");
|
|
}
|
|
|
|
/// Test: POST /v1/assert with null byte in subject returns 400.
|
|
#[tokio::test]
|
|
async fn test_assert_null_byte_subject_returns_400() {
|
|
let env = common::create_test_env().await;
|
|
let app = create_router(env.state);
|
|
|
|
// Use a properly signed assertion but with null byte in subject
|
|
let body = common::create_signed_assertion_json("test\x00injected", "has_value", 1.0);
|
|
|
|
let request = Request::builder()
|
|
.uri("/v1/assert")
|
|
.method("POST")
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(serde_json::to_vec(&body).expect("json")))
|
|
.expect("Request");
|
|
|
|
let response = app.oneshot(request).await.expect("Request");
|
|
// Should fail with 400 due to null byte in subject
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Null byte in subject should return 400"
|
|
);
|
|
}
|