Improve test infrastructure: StubChannel, gateway helpers, security tests, search edge cases (#623)

* feat(testing): add StubChannel test double for Channel trait

Adds StubChannel to src/testing.rs alongside StubLlm. Supports message
injection via mpsc sender, response/status capture, and configurable
health check toggling. Includes handle methods for use after ownership
transfer to ChannelManager.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(testing): wire StubChannel into TestHarnessBuilder

Add with_stub_channel() builder method that creates a StubChannel
pre-registered in a ChannelManager. Tests can inject messages via
the sender and verify routing through the manager. The channel field
on TestHarness is Optional, defaulting to None for backward compat.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test: gate external-service tests behind integration feature flag

Replace silent try_connect() skip pattern with explicit feature gating.
cargo test now runs only self-contained tests.
cargo test --features integration runs tests requiring PostgreSQL.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test(channels): add ChannelManager unit tests using StubChannel

Cover add/start_all stream merging, respond routing, unknown channel
errors, health_check_all with mixed health, empty-channels error path,
and injection channel merging -- all via StubChannel test double.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: document test tier separation (unit/integration/live)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: add architecture boundary check script

Grep-based checks for three architecture boundaries:
- Direct database driver usage (tokio_postgres/libsql) outside src/db/
- .unwrap()/.expect() in production code (warning only)
- Direct std::env::var reads outside config layer (warning only)

The DB driver check is a hard violation; the other two are warnings
for gradual cleanup. Run with: bash scripts/check-boundaries.sh

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test(search): add RRF edge case tests for empty inputs, limits, and config modes

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test(security): add regression tests for skill installer ZIP and SSRF protections

Add 11 regression tests covering the security controls in skill_tools:

ZIP extraction safety:
- Valid SKILL.md extraction works correctly
- Non-SKILL.md entries are ignored (returns error)
- Path traversal entries (../../SKILL.md) do not match
- Nested path entries (subdir/SKILL.md) do not match
- Oversized entries (>1MB uncompressed) are rejected

SSRF prevention:
- Loopback addresses (127.0.0.1) are blocked
- Private ranges (10.x, 172.16.x, 192.168.x) are blocked
- Link-local addresses (169.254.x) are blocked
- Public IPs (8.8.8.8, 1.1.1.1) are allowed
- IPv4-mapped IPv6 unwrapping logic works correctly
- Metadata endpoints and .internal/.local hostnames are blocked
- Normal hostnames (github.com, clawhub.dev) are allowed

Also documents a known gap: url::Url::host_str() returns bracketed
IPv6 addresses that std::net::IpAddr cannot parse, so IPv4-mapped
IPv6 URLs currently bypass IP-based checks in validate_fetch_url.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor(testing): extract TestGatewayBuilder to eliminate gateway test duplication

Both ws_gateway_integration.rs and openai_compat_integration.rs manually
constructed GatewayState with 19+ fields. Extracted to a shared builder in
src/channels/web/test_helpers.rs that provides sensible defaults and lets
tests override only what they need.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: add implementation plans for testing batches 1 and 2

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(security): close IPv6 SSRF bypass in validate_fetch_url

validate_fetch_url used host_str() which returns bracketed IPv6
(e.g. "[::ffff:7f00:1]") that IpAddr::parse() cannot handle,
silently skipping IP-based SSRF checks for all IPv6 URLs.

Switch to url::Host enum matching to extract proper IpAddr values
without string parsing. IPv4-mapped IPv6 addresses like
::ffff:127.0.0.1 are now correctly unwrapped and blocked.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test(skills): add activation criteria limits enforcement tests

Adds test_activation_criteria_enforce_limits to verify that
enforce_limits() correctly trims excess patterns (>5), keywords (>20),
and tags (>10), and filters out short keywords/tags (<3 chars).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test(wasm): add security regression tests for WASM tool loader

Add 6 tests covering: tool name path separator rejection, empty name
rejection, nonexistent file handling, invalid WASM bytes rejection,
dotfile discovery behavior, and subdirectory non-recursion.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: address PR review feedback

- Remove plan files from repo (ilblackdragon review)
- Replace CLAUDE.md test tier rules with pointer to check-boundaries.sh
- Add Check 4 to check-boundaries.sh: enforces integration tests are
  gated behind the 'integration' feature flag

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: add try_connect silent-skip pattern check to check-boundaries.sh

Check 5 catches try_connect() and similar silent-skip patterns in
integration tests. Tests should use feature gates to fail loudly
when prerequisites are missing, not silently return.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(security): harden skill fetch SSRF checks

* fix(scripts): use bash arrays in check-boundaries.sh tier violation check

Refactor Check 4 in check-boundaries.sh to use bash arrays and printf
instead of string concatenation with echo -e. This is more robust with
special characters in filenames and avoids portability concerns with
echo -e. [skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Zaki Manian
2026-03-07 08:30:47 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent cf96a3253c
commit 45ec691f4c
15 changed files with 1479 additions and 166 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
#![cfg(feature = "postgres")]
#![cfg(all(feature = "postgres", feature = "integration"))]
//! Heartbeat integration test.
//!
//! Exercises the heartbeat system in isolation: connects to the real
+9 -61
View File
@@ -9,9 +9,8 @@ use std::time::Duration;
use async_trait::async_trait;
use rust_decimal::Decimal;
use ironclaw::channels::web::server::{GatewayState, start_server};
use ironclaw::channels::web::sse::SseManager;
use ironclaw::channels::web::ws::WsConnectionTracker;
use ironclaw::channels::web::server::GatewayState;
use ironclaw::channels::web::test_helpers::TestGatewayBuilder;
use ironclaw::error::LlmError;
use ironclaw::llm::{
CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ToolCompletionRequest,
@@ -179,37 +178,11 @@ async fn start_test_server() -> (SocketAddr, Arc<GatewayState>, Arc<MockLlmState
async fn start_test_server_with_provider(
llm_provider: Arc<dyn LlmProvider>,
) -> (SocketAddr, Arc<GatewayState>) {
let state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
sse: SseManager::new(),
workspace: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: None,
tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
scheduler: None,
user_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: Some(llm_provider),
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
registry_entries: Vec::new(),
cost_guard: None,
startup_time: std::time::Instant::now(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), AUTH_TOKEN.to_string())
TestGatewayBuilder::new()
.llm_provider(llm_provider)
.start(AUTH_TOKEN)
.await
.expect("Failed to start test server");
(bound_addr, state)
.expect("Failed to start test server")
}
fn client() -> reqwest::Client {
@@ -668,35 +641,10 @@ async fn test_models_no_auth() {
#[tokio::test]
async fn test_no_llm_provider_returns_503() {
// Create state WITHOUT llm_provider
let state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
sse: SseManager::new(),
workspace: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: None,
tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
scheduler: None,
user_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None, // No LLM!
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
registry_entries: Vec::new(),
cost_guard: None,
startup_time: std::time::Instant::now(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state, AUTH_TOKEN.to_string())
let (bound_addr, _state) = TestGatewayBuilder::new()
.start(AUTH_TOKEN)
.await
.unwrap();
.expect("Failed to start test server");
let url = format!("http://{}/v1/chat/completions", bound_addr);
let resp = client()
+1 -43
View File
@@ -1,4 +1,4 @@
#![cfg(feature = "postgres")]
#![cfg(all(feature = "postgres", feature = "integration"))]
//! Integration tests for the workspace module.
//!
//! Requires a running PostgreSQL with pgvector extension.
@@ -21,18 +21,6 @@ fn get_pool() -> deadpool_postgres::Pool {
.expect("Failed to create pool")
}
/// Try to get a connection, returning None if Postgres is unreachable.
/// Tests call this to skip gracefully in CI where no database is available.
async fn try_connect(pool: &deadpool_postgres::Pool) -> Option<()> {
match pool.get().await {
Ok(_) => Some(()),
Err(e) => {
eprintln!("skipping: database unavailable ({e})");
None
}
}
}
async fn cleanup_user(pool: &deadpool_postgres::Pool, user_id: &str) {
let conn = pool.get().await.expect("Failed to get connection");
conn.execute(
@@ -46,9 +34,6 @@ async fn cleanup_user(pool: &deadpool_postgres::Pool, user_id: &str) {
#[tokio::test]
async fn test_workspace_write_and_read() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let user_id = "test_write_read";
cleanup_user(&pool, user_id).await;
@@ -74,9 +59,6 @@ async fn test_workspace_write_and_read() {
#[tokio::test]
async fn test_workspace_append() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let user_id = "test_append";
cleanup_user(&pool, user_id).await;
@@ -104,9 +86,6 @@ async fn test_workspace_append() {
#[tokio::test]
async fn test_workspace_nested_paths() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let user_id = "test_nested";
cleanup_user(&pool, user_id).await;
@@ -152,9 +131,6 @@ async fn test_workspace_nested_paths() {
#[tokio::test]
async fn test_workspace_delete() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let user_id = "test_delete";
cleanup_user(&pool, user_id).await;
@@ -179,9 +155,6 @@ async fn test_workspace_delete() {
#[tokio::test]
async fn test_workspace_memory_operations() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let user_id = "test_memory_ops";
cleanup_user(&pool, user_id).await;
@@ -210,9 +183,6 @@ async fn test_workspace_memory_operations() {
#[tokio::test]
async fn test_workspace_daily_log() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let user_id = "test_daily_log";
cleanup_user(&pool, user_id).await;
@@ -239,9 +209,6 @@ async fn test_workspace_daily_log() {
#[tokio::test]
async fn test_workspace_fts_search() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let user_id = "test_fts_search";
cleanup_user(&pool, user_id).await;
@@ -300,9 +267,6 @@ async fn test_workspace_fts_search() {
#[tokio::test]
async fn test_workspace_hybrid_search_with_mock_embeddings() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let user_id = "test_hybrid_search";
cleanup_user(&pool, user_id).await;
@@ -342,9 +306,6 @@ async fn test_workspace_hybrid_search_with_mock_embeddings() {
#[tokio::test]
async fn test_workspace_list_all() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let user_id = "test_list_all";
cleanup_user(&pool, user_id).await;
@@ -370,9 +331,6 @@ async fn test_workspace_list_all() {
#[tokio::test]
async fn test_workspace_system_prompt() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let user_id = "test_system_prompt";
cleanup_user(&pool, user_id).await;
+6 -31
View File
@@ -20,10 +20,9 @@ use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use ironclaw::channels::IncomingMessage;
use ironclaw::channels::web::server::{GatewayState, start_server};
use ironclaw::channels::web::sse::SseManager;
use ironclaw::channels::web::server::GatewayState;
use ironclaw::channels::web::test_helpers::TestGatewayBuilder;
use ironclaw::channels::web::types::SseEvent;
use ironclaw::channels::web::ws::WsConnectionTracker;
const AUTH_TOKEN: &str = "test-token-12345";
const TIMEOUT: Duration = Duration::from_secs(5);
@@ -37,37 +36,13 @@ async fn start_test_server() -> (
) {
let (agent_tx, agent_rx) = mpsc::channel(64);
let state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(Some(agent_tx)),
sse: SseManager::new(),
workspace: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: None,
tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
scheduler: None,
user_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
registry_entries: Vec::new(),
cost_guard: None,
startup_time: std::time::Instant::now(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), AUTH_TOKEN.to_string())
let (addr, state) = TestGatewayBuilder::new()
.msg_tx(agent_tx)
.start(AUTH_TOKEN)
.await
.expect("Failed to start test server");
(bound_addr, state, agent_rx)
(addr, state, agent_rx)
}
/// Connect a WebSocket client with auth token in query parameter.