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
+103
View File
@@ -235,3 +235,106 @@ impl Default for ChannelManager {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::IncomingMessage;
use crate::testing::StubChannel;
use futures::StreamExt;
#[tokio::test]
async fn test_add_and_start_all() {
let manager = ChannelManager::new();
let (stub, sender) = StubChannel::new("test");
manager.add(Box::new(stub)).await;
let mut stream = manager.start_all().await.expect("start_all failed");
// Inject a message through the stub
sender
.send(IncomingMessage::new("test", "user1", "hello"))
.await
.expect("send failed");
// Should appear in the merged stream
let msg = stream.next().await.expect("stream ended");
assert_eq!(msg.content, "hello");
assert_eq!(msg.channel, "test");
}
#[tokio::test]
async fn test_respond_routes_to_correct_channel() {
let manager = ChannelManager::new();
let (stub, _sender) = StubChannel::new("alpha");
// Keep a reference for response inspection
let responses = stub.captured_responses_handle();
manager.add(Box::new(stub)).await;
let msg = IncomingMessage::new("alpha", "user1", "request");
manager
.respond(&msg, OutgoingResponse::text("reply"))
.await
.expect("respond failed");
// Verify the stub captured the response
let captured = responses.lock().expect("poisoned");
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].1.content, "reply");
}
#[tokio::test]
async fn test_respond_unknown_channel_errors() {
let manager = ChannelManager::new();
let msg = IncomingMessage::new("nonexistent", "user1", "test");
let result = manager.respond(&msg, OutgoingResponse::text("hi")).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_health_check_all() {
let manager = ChannelManager::new();
let (stub1, _) = StubChannel::new("healthy");
let (stub2, _) = StubChannel::new("sick");
stub2.set_healthy(false);
manager.add(Box::new(stub1)).await;
manager.add(Box::new(stub2)).await;
let results = manager.health_check_all().await;
assert!(results["healthy"].is_ok());
assert!(results["sick"].is_err());
}
#[tokio::test]
async fn test_start_all_no_channels_errors() {
let manager = ChannelManager::new();
let result = manager.start_all().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_injection_channel_merges() {
let manager = ChannelManager::new();
let (stub, _sender) = StubChannel::new("real");
manager.add(Box::new(stub)).await;
let mut stream = manager.start_all().await.expect("start_all failed");
// Use the injection channel (simulating background task)
let inject_tx = manager.inject_sender();
inject_tx
.send(IncomingMessage::new(
"injected",
"system",
"background alert",
))
.await
.expect("inject failed");
let msg = stream.next().await.expect("stream ended");
assert_eq!(msg.content, "background alert");
}
}
+7
View File
@@ -24,6 +24,13 @@ pub mod types;
pub(crate) mod util;
pub mod ws;
/// Test helpers for gateway integration tests.
///
/// Always compiled (not behind `#[cfg(test)]`) so that integration tests in
/// `tests/` -- which import this crate as a regular dependency -- can use
/// [`TestGatewayBuilder`](test_helpers::TestGatewayBuilder).
pub mod test_helpers;
use std::net::SocketAddr;
use std::sync::Arc;
+104
View File
@@ -0,0 +1,104 @@
//! Shared test utilities for gateway integration tests.
//!
//! This module is always compiled (not `#[cfg(test)]`) because integration tests
//! in `tests/` import the crate as a regular dependency and `cfg(test)` is only
//! set when compiling *this* crate's unit tests.
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::mpsc;
use crate::channels::IncomingMessage;
use crate::channels::web::server::{GatewayState, RateLimiter, start_server};
use crate::channels::web::sse::SseManager;
use crate::channels::web::ws::WsConnectionTracker;
/// Builder for constructing a [`GatewayState`] with sensible test defaults.
///
/// Every optional field defaults to `None` and can be overridden via builder
/// methods. Call [`build`](Self::build) to get the `Arc<GatewayState>`, or
/// [`start`](Self::start) to also bind an Axum server on a random port.
pub struct TestGatewayBuilder {
msg_tx: Option<mpsc::Sender<IncomingMessage>>,
llm_provider: Option<Arc<dyn crate::llm::LlmProvider>>,
user_id: String,
}
impl Default for TestGatewayBuilder {
fn default() -> Self {
Self {
msg_tx: None,
llm_provider: None,
user_id: "test-user".to_string(),
}
}
}
impl TestGatewayBuilder {
/// Create a new builder with all defaults.
pub fn new() -> Self {
Self::default()
}
/// Set the agent message sender (the channel the gateway forwards
/// incoming chat messages to).
pub fn msg_tx(mut self, tx: mpsc::Sender<IncomingMessage>) -> Self {
self.msg_tx = Some(tx);
self
}
/// Set the LLM provider (needed for OpenAI-compatible API tests).
pub fn llm_provider(mut self, provider: Arc<dyn crate::llm::LlmProvider>) -> Self {
self.llm_provider = Some(provider);
self
}
/// Override the user ID (default: `"test-user"`).
pub fn user_id(mut self, id: impl Into<String>) -> Self {
self.user_id = id.into();
self
}
/// Build the `Arc<GatewayState>` without starting a server.
pub fn build(self) -> Arc<GatewayState> {
Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(self.msg_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,
user_id: self.user_id,
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: self.llm_provider,
skill_registry: None,
skill_catalog: None,
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
registry_entries: Vec::new(),
cost_guard: None,
startup_time: std::time::Instant::now(),
})
}
/// Build the state and start a gateway server on `127.0.0.1:0` (random
/// port). Returns the bound address and the shared state.
pub async fn start(
self,
auth_token: &str,
) -> Result<(SocketAddr, Arc<GatewayState>), crate::error::ChannelError> {
let state = self.build();
let addr: SocketAddr = "127.0.0.1:0"
.parse()
.expect("hard-coded address must parse");
let bound = start_server(addr, state.clone(), auth_token.to_string()).await?;
Ok((bound, state))
}
}