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
+169
View File
@@ -458,4 +458,173 @@ mod tests {
assert!(!vector_only.use_fts);
assert!(vector_only.use_vector);
}
// --- Edge case tests ---
#[test]
fn test_rrf_both_empty() {
let config = SearchConfig::default();
let results = reciprocal_rank_fusion(Vec::new(), Vec::new(), &config);
assert!(results.is_empty());
}
#[test]
fn test_rrf_fts_only_no_vector() {
let config = SearchConfig::default().with_limit(10);
let chunk1 = Uuid::new_v4();
let chunk2 = Uuid::new_v4();
let chunk3 = Uuid::new_v4();
let doc = Uuid::new_v4();
let fts_results = vec![
make_result(chunk1, doc, 1),
make_result(chunk2, doc, 2),
make_result(chunk3, doc, 3),
];
let results = reciprocal_rank_fusion(fts_results, Vec::new(), &config);
assert_eq!(results.len(), 3);
// All results should come from FTS only
assert!(results.iter().all(|r| r.from_fts()));
assert!(results.iter().all(|r| !r.from_vector()));
assert!(results.iter().all(|r| !r.is_hybrid()));
// Scores should be in descending order
for w in results.windows(2) {
assert!(w[0].score >= w[1].score);
}
}
#[test]
fn test_rrf_vector_only_no_fts() {
let config = SearchConfig::default().with_limit(10);
let chunk1 = Uuid::new_v4();
let chunk2 = Uuid::new_v4();
let chunk3 = Uuid::new_v4();
let doc = Uuid::new_v4();
let vector_results = vec![
make_result(chunk1, doc, 1),
make_result(chunk2, doc, 2),
make_result(chunk3, doc, 3),
];
let results = reciprocal_rank_fusion(Vec::new(), vector_results, &config);
assert_eq!(results.len(), 3);
// All results should come from vector only
assert!(results.iter().all(|r| r.from_vector()));
assert!(results.iter().all(|r| !r.from_fts()));
assert!(results.iter().all(|r| !r.is_hybrid()));
// Scores should be in descending order
for w in results.windows(2) {
assert!(w[0].score >= w[1].score);
}
}
#[test]
fn test_rrf_duplicate_chunks_merged() {
let config = SearchConfig::default().with_limit(10);
let shared_chunk = Uuid::new_v4();
let fts_only_chunk = Uuid::new_v4();
let vector_only_chunk = Uuid::new_v4();
let doc = Uuid::new_v4();
// shared_chunk appears at rank 2 in FTS and rank 3 in vector
let fts_results = vec![
make_result(fts_only_chunk, doc, 1),
make_result(shared_chunk, doc, 2),
];
let vector_results = vec![
make_result(vector_only_chunk, doc, 1),
make_result(shared_chunk, doc, 3),
];
let results = reciprocal_rank_fusion(fts_results, vector_results, &config);
// Should have 3 unique chunks (not 4)
assert_eq!(results.len(), 3);
// Find the shared chunk in results
let shared = results.iter().find(|r| r.chunk_id == shared_chunk).unwrap();
assert!(shared.is_hybrid());
assert_eq!(shared.fts_rank, Some(2));
assert_eq!(shared.vector_rank, Some(3));
// The shared chunk's pre-normalization score is 1/(k+2) + 1/(k+3),
// which is higher than either single-method chunk at rank 1: 1/(k+1).
// After normalization the shared chunk should be the top result.
assert_eq!(results[0].chunk_id, shared_chunk);
}
#[test]
fn test_rrf_limit_zero_returns_empty() {
let config = SearchConfig::default().with_limit(0);
let doc = Uuid::new_v4();
let fts_results = vec![
make_result(Uuid::new_v4(), doc, 1),
make_result(Uuid::new_v4(), doc, 2),
];
let results = reciprocal_rank_fusion(fts_results, Vec::new(), &config);
assert!(results.is_empty());
}
#[test]
fn test_rrf_min_score_one_filters_all() {
// RRF scores are always < 1.0 before normalization (1/(k+rank) where k>=1, rank>=1).
// After normalization the top result gets score=1.0, so min_score=1.0 should
// keep only the single top result. To truly filter everything, we need
// min_score > 1.0 -- but with_min_score clamps to 1.0.
// With a single result: normalized score = 1.0, so it passes min_score=1.0.
// With multiple results: only the top (score=1.0) survives.
// To filter ALL results we need to ensure none reach 1.0 -- but normalization
// always makes the max = 1.0. So min_score=1.0 keeps exactly 1 result (the top).
//
// Verified: the retain check is `score >= min_score` and the top score
// is normalized to exactly 1.0, so one result survives.
let config = SearchConfig::default().with_limit(10).with_min_score(1.0);
let doc = Uuid::new_v4();
let fts_results = vec![
make_result(Uuid::new_v4(), doc, 1),
make_result(Uuid::new_v4(), doc, 2),
make_result(Uuid::new_v4(), doc, 3),
];
let results = reciprocal_rank_fusion(fts_results, Vec::new(), &config);
// After normalization the top result has score 1.0, so exactly 1 survives
assert_eq!(results.len(), 1);
assert!((results[0].score - 1.0).abs() < 0.001);
}
#[test]
fn test_search_config_fts_only() {
let config = SearchConfig::default().fts_only();
assert!(config.use_fts);
assert!(!config.use_vector);
// Other defaults should be preserved
assert_eq!(config.limit, 10);
assert_eq!(config.rrf_k, 60);
assert!((config.min_score - 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_search_config_vector_only() {
let config = SearchConfig::default().vector_only();
assert!(!config.use_fts);
assert!(config.use_vector);
// Other defaults should be preserved
assert_eq!(config.limit, 10);
assert_eq!(config.rrf_k, 60);
assert!((config.min_score - 0.0).abs() < f32::EPSILON);
}
}