//! Extensive DDoS detection tests. //! //! These tests build realistic traffic profiles — normal browsing, API usage, //! webhook bursts, etc. — and verify the model never blocks legitimate traffic. //! Attack scenarios are also tested to confirm blocking works. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use sunbeam_proxy::config::DDoSConfig; use sunbeam_proxy::ddos::detector::DDoSDetector; use sunbeam_proxy::ddos::features::{NormParams, NUM_FEATURES}; use sunbeam_proxy::ddos::model::{DDoSAction, SerializedModel, TrafficLabel, TrainedModel}; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Build a model from explicit normal/attack feature vectors. fn make_model( normal: &[[f64; NUM_FEATURES]], attack: &[[f64; NUM_FEATURES]], k: usize, threshold: f64, ) -> TrainedModel { let mut points = Vec::new(); let mut labels = Vec::new(); for v in normal { points.push(*v); labels.push(TrafficLabel::Normal); } for v in attack { points.push(*v); labels.push(TrafficLabel::Attack); } let norm_params = NormParams::from_data(&points); let normalized: Vec<[f64; NUM_FEATURES]> = points.iter().map(|v| norm_params.normalize(v)).collect(); TrainedModel::from_serialized(SerializedModel { points: normalized, labels, norm_params, k, threshold, }) } fn default_ddos_config() -> DDoSConfig { DDoSConfig { model_path: String::new(), k: 5, threshold: 0.6, window_secs: 60, window_capacity: 1000, min_events: 10, enabled: true, } } fn make_detector(model: TrainedModel, min_events: usize) -> DDoSDetector { let mut cfg = default_ddos_config(); cfg.min_events = min_events; DDoSDetector::new(model, &cfg) } /// Feature vector indices (matching features.rs order): /// 0: request_rate (requests / window_secs) /// 1: unique_paths (count of distinct paths) /// 2: unique_hosts (count of distinct hosts) /// 3: error_rate (fraction 4xx/5xx) /// 4: avg_duration_ms (mean response time) /// 5: method_entropy (Shannon entropy of methods) /// 6: burst_score (inverse mean inter-arrival) /// 7: path_repetition (most-repeated path / total) /// 8: avg_content_length (mean body size) /// 9: unique_user_agents (count of distinct UAs) /// 10: cookie_ratio (fraction with cookies) /// 11: referer_ratio (fraction with referer) /// 12: accept_language_ratio (fraction with accept-language) /// 13: suspicious_path_ratio (fraction hitting known-bad paths) /// 9: unique_user_agents (count of distinct UAs) // Realistic normal traffic profiles fn normal_browser_browsing() -> [f64; NUM_FEATURES] { // A human browsing a site: ~0.5 req/s, many paths, 1 host, low errors, // ~150ms avg latency, mostly GET, moderate spacing, diverse paths, no body, 1 UA // cookies=yes, referer=sometimes, accept-lang=yes, suspicious=no [0.5, 12.0, 1.0, 0.02, 150.0, 0.2, 0.6, 0.15, 0.0, 1.0, 1.0, 0.5, 1.0, 0.0] } fn normal_api_client() -> [f64; NUM_FEATURES] { // Backend API client: ~2 req/s, hits a few endpoints, 1 host, ~5% errors (retries), // ~50ms latency, mix of GET/POST, steady rate, some path repetition, small bodies, 1 UA // cookies=yes (session), referer=no, accept-lang=no, suspicious=no [2.0, 5.0, 1.0, 0.05, 50.0, 0.69, 2.5, 0.4, 512.0, 1.0, 1.0, 0.0, 0.0, 0.0] } fn normal_webhook_burst() -> [f64; NUM_FEATURES] { // CI/CD or webhook burst: ~10 req/s for a short period, 1-2 paths, 1 host, // 0% errors, fast responses, all POST, bursty, high path repetition, medium bodies, 1 UA // cookies=no (machine), referer=no, accept-lang=no, suspicious=no [10.0, 2.0, 1.0, 0.0, 25.0, 0.0, 12.0, 0.8, 2048.0, 1.0, 0.0, 0.0, 0.0, 0.0] } fn normal_health_check() -> [f64; NUM_FEATURES] { // Health check probe: ~0.2 req/s, 1 path, 1 host, 0% errors, ~5ms latency, // all GET, very regular, 100% same path, no body, 1 UA // cookies=no (probe), referer=no, accept-lang=no, suspicious=no [0.2, 1.0, 1.0, 0.0, 5.0, 0.0, 0.2, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0] } fn normal_mobile_app() -> [f64; NUM_FEATURES] { // Mobile app: ~1 req/s, several API endpoints, 1 host, ~3% errors, // ~200ms latency (mobile network), GET + POST, moderate spacing, moderate repetition, // small-medium bodies, 1 UA // cookies=yes, referer=no, accept-lang=yes, suspicious=no [1.0, 8.0, 1.0, 0.03, 200.0, 0.5, 1.2, 0.25, 256.0, 1.0, 1.0, 0.0, 1.0, 0.0] } fn normal_search_crawler() -> [f64; NUM_FEATURES] { // Googlebot-style crawler: ~0.3 req/s, many unique paths, 1 host, ~10% 404s, // ~300ms latency, all GET, slow steady rate, diverse paths, no body, 1 UA // cookies=no (crawler), referer=no, accept-lang=no, suspicious=no [0.3, 20.0, 1.0, 0.1, 300.0, 0.0, 0.35, 0.08, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0] } fn normal_graphql_spa() -> [f64; NUM_FEATURES] { // SPA hitting a GraphQL endpoint: ~3 req/s, 1 path (/graphql), 1 host, ~1% errors, // ~80ms latency, all POST, steady, 100% same path, medium bodies, 1 UA // cookies=yes, referer=yes (SPA nav), accept-lang=yes, suspicious=no [3.0, 1.0, 1.0, 0.01, 80.0, 0.0, 3.5, 1.0, 1024.0, 1.0, 1.0, 1.0, 1.0, 0.0] } fn normal_websocket_upgrade() -> [f64; NUM_FEATURES] { // Initial HTTP requests before WS upgrade: ~0.1 req/s, 2 paths, 1 host, 0% errors, // ~10ms latency, GET, slow, some repetition, no body, 1 UA // cookies=yes, referer=yes, accept-lang=yes, suspicious=no [0.1, 2.0, 1.0, 0.0, 10.0, 0.0, 0.1, 0.5, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0] } fn normal_file_upload() -> [f64; NUM_FEATURES] { // File upload session: ~0.5 req/s, 3 paths (upload, status, confirm), 1 host, // 0% errors, ~500ms latency (large bodies), POST + GET, steady, moderate repetition, // large bodies, 1 UA // cookies=yes, referer=yes, accept-lang=yes, suspicious=no [0.5, 3.0, 1.0, 0.0, 500.0, 0.69, 0.6, 0.5, 1_000_000.0, 1.0, 1.0, 1.0, 1.0, 0.0] } fn normal_multi_tenant_api() -> [f64; NUM_FEATURES] { // API client hitting multiple hosts (multi-tenant): ~1.5 req/s, 4 paths, 3 hosts, // ~2% errors, ~100ms latency, GET + POST, steady, low repetition, small bodies, 1 UA // cookies=yes, referer=no, accept-lang=no, suspicious=no [1.5, 4.0, 3.0, 0.02, 100.0, 0.69, 1.8, 0.3, 128.0, 1.0, 1.0, 0.0, 0.0, 0.0] } // Realistic attack traffic profiles fn attack_path_scan() -> [f64; NUM_FEATURES] { // WordPress/PHP scanner: ~20 req/s, many unique paths, 1 host, 100% 404s, // ~2ms latency (all errors), all GET, very bursty, all unique paths, no body, 1 UA // cookies=no, referer=no, accept-lang=no, suspicious=0.8 (most paths are probes) [20.0, 50.0, 1.0, 1.0, 2.0, 0.0, 25.0, 0.02, 0.0, 1.0, 0.0, 0.0, 0.0, 0.8] } fn attack_credential_stuffing() -> [f64; NUM_FEATURES] { // Login brute-force: ~30 req/s, 1 path (/login), 1 host, 95% 401/403, // ~10ms latency, all POST, very bursty, 100% same path, small bodies, 1 UA // cookies=no, referer=no, accept-lang=no, suspicious=0.0 (/login is not in suspicious list) [30.0, 1.0, 1.0, 0.95, 10.0, 0.0, 35.0, 1.0, 64.0, 1.0, 0.0, 0.0, 0.0, 0.0] } fn attack_slowloris() -> [f64; NUM_FEATURES] { // Slowloris-style: ~0.5 req/s (slow), 1 path, 1 host, 0% errors (connections held), // ~30000ms latency (!), all GET, slow, 100% same path, huge content-length, 1 UA // cookies=no, referer=no, accept-lang=no, suspicious=0.0 [0.5, 1.0, 1.0, 0.0, 30000.0, 0.0, 0.5, 1.0, 10_000_000.0, 1.0, 0.0, 0.0, 0.0, 0.0] } fn attack_ua_rotation() -> [f64; NUM_FEATURES] { // Bot rotating user-agents: ~15 req/s, 2 paths, 1 host, 80% errors, // ~5ms latency, GET + POST, bursty, high repetition, no body, 50 distinct UAs // cookies=no, referer=no, accept-lang=no, suspicious=0.3 [15.0, 2.0, 1.0, 0.8, 5.0, 0.69, 18.0, 0.7, 0.0, 50.0, 0.0, 0.0, 0.0, 0.3] } fn attack_host_scan() -> [f64; NUM_FEATURES] { // Virtual host enumeration: ~25 req/s, 1 path (/), many hosts, 100% errors, // ~1ms latency, all GET, very bursty, 100% same path, no body, 1 UA // cookies=no, referer=no, accept-lang=no, suspicious=0.0 [25.0, 1.0, 40.0, 1.0, 1.0, 0.0, 30.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0] } fn attack_api_fuzzing() -> [f64; NUM_FEATURES] { // API fuzzer: ~50 req/s, many paths, 1 host, 90% errors (bad inputs), // ~3ms latency, mixed methods, extremely bursty, low repetition, varied bodies, 1 UA // cookies=no, referer=no, accept-lang=no, suspicious=0.5 [50.0, 100.0, 1.0, 0.9, 3.0, 1.5, 55.0, 0.01, 4096.0, 1.0, 0.0, 0.0, 0.0, 0.5] } fn all_normal_profiles() -> Vec<[f64; NUM_FEATURES]> { vec![ normal_browser_browsing(), normal_api_client(), normal_webhook_burst(), normal_health_check(), normal_mobile_app(), normal_search_crawler(), normal_graphql_spa(), normal_websocket_upgrade(), normal_file_upload(), normal_multi_tenant_api(), ] } fn all_attack_profiles() -> Vec<[f64; NUM_FEATURES]> { vec![ attack_path_scan(), attack_credential_stuffing(), attack_slowloris(), attack_ua_rotation(), attack_host_scan(), attack_api_fuzzing(), ] } /// Build a model from realistic profiles, with each profile replicated `copies` /// times (with slight jitter) to give the KNN enough neighbors. fn make_realistic_model(k: usize, threshold: f64) -> TrainedModel { let mut normal = Vec::new(); let mut attack = Vec::new(); // Replicate each profile with small perturbations for base in all_normal_profiles() { for i in 0..20 { let mut v = base; for d in 0..NUM_FEATURES { // ±5% jitter let jitter = 1.0 + ((i as f64 * 0.37 + d as f64 * 0.13) % 0.1 - 0.05); v[d] *= jitter; } normal.push(v); } } for base in all_attack_profiles() { for i in 0..20 { let mut v = base; for d in 0..NUM_FEATURES { let jitter = 1.0 + ((i as f64 * 0.41 + d as f64 * 0.17) % 0.1 - 0.05); v[d] *= jitter; } attack.push(v); } } make_model(&normal, &attack, k, threshold) } // =========================================================================== // Model classification tests — normal profiles must NEVER be blocked // =========================================================================== #[test] fn normal_browser_is_allowed() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&normal_browser_browsing()), DDoSAction::Allow); } #[test] fn normal_api_client_is_allowed() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&normal_api_client()), DDoSAction::Allow); } #[test] fn normal_webhook_burst_is_allowed() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&normal_webhook_burst()), DDoSAction::Allow); } #[test] fn normal_health_check_is_allowed() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&normal_health_check()), DDoSAction::Allow); } #[test] fn normal_mobile_app_is_allowed() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&normal_mobile_app()), DDoSAction::Allow); } #[test] fn normal_search_crawler_is_allowed() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&normal_search_crawler()), DDoSAction::Allow); } #[test] fn normal_graphql_spa_is_allowed() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&normal_graphql_spa()), DDoSAction::Allow); } #[test] fn normal_websocket_upgrade_is_allowed() { let model = make_realistic_model(5, 0.6); assert_eq!( model.classify(&normal_websocket_upgrade()), DDoSAction::Allow ); } #[test] fn normal_file_upload_is_allowed() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&normal_file_upload()), DDoSAction::Allow); } #[test] fn normal_multi_tenant_api_is_allowed() { let model = make_realistic_model(5, 0.6); assert_eq!( model.classify(&normal_multi_tenant_api()), DDoSAction::Allow ); } // =========================================================================== // Model classification tests — attack profiles must be blocked // =========================================================================== #[test] fn attack_path_scan_is_blocked() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&attack_path_scan()), DDoSAction::Block); } #[test] fn attack_credential_stuffing_is_blocked() { let model = make_realistic_model(5, 0.6); assert_eq!( model.classify(&attack_credential_stuffing()), DDoSAction::Block ); } #[test] fn attack_slowloris_is_blocked() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&attack_slowloris()), DDoSAction::Block); } #[test] fn attack_ua_rotation_is_blocked() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&attack_ua_rotation()), DDoSAction::Block); } #[test] fn attack_host_scan_is_blocked() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&attack_host_scan()), DDoSAction::Block); } #[test] fn attack_api_fuzzing_is_blocked() { let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&attack_api_fuzzing()), DDoSAction::Block); } // =========================================================================== // Edge cases: normal traffic that LOOKS suspicious but isn't // =========================================================================== #[test] fn high_rate_legitimate_cdn_prefetch_is_allowed() { // CDN prefetch: high rate but low errors, diverse paths, normal latency let model = make_realistic_model(5, 0.6); let profile: [f64; NUM_FEATURES] = [8.0, 15.0, 1.0, 0.0, 100.0, 0.0, 9.0, 0.1, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]; assert_eq!(model.classify(&profile), DDoSAction::Allow); } #[test] fn single_path_api_polling_is_allowed() { // Long-poll or SSE endpoint: single path, 100% repetition, but low rate, no errors let model = make_realistic_model(5, 0.6); let profile: [f64; NUM_FEATURES] = [0.3, 1.0, 1.0, 0.0, 1000.0, 0.0, 0.3, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0]; assert_eq!(model.classify(&profile), DDoSAction::Allow); } #[test] fn moderate_error_rate_during_deploy_is_allowed() { // During a rolling deploy, error rate spikes to ~20% temporarily let model = make_realistic_model(5, 0.6); let profile: [f64; NUM_FEATURES] = [1.0, 5.0, 1.0, 0.2, 200.0, 0.5, 1.2, 0.3, 128.0, 1.0, 1.0, 0.3, 1.0, 0.0]; assert_eq!(model.classify(&profile), DDoSAction::Allow); } #[test] fn burst_of_form_submissions_is_allowed() { // Marketing event → users submit forms rapidly: high rate, single path, all POST, no errors let model = make_realistic_model(5, 0.6); let profile: [f64; NUM_FEATURES] = [5.0, 1.0, 1.0, 0.0, 80.0, 0.0, 6.0, 1.0, 512.0, 1.0, 1.0, 1.0, 1.0, 0.0]; assert_eq!(model.classify(&profile), DDoSAction::Allow); } #[test] fn legitimate_load_test_with_varied_paths_is_allowed() { // Internal load test: high rate but diverse paths, low error, real latency let model = make_realistic_model(5, 0.6); let profile: [f64; NUM_FEATURES] = [8.0, 30.0, 1.0, 0.02, 120.0, 0.69, 10.0, 0.05, 256.0, 1.0, 0.0, 0.0, 0.0, 0.0]; assert_eq!(model.classify(&profile), DDoSAction::Allow); } // =========================================================================== // Threshold and k sensitivity // =========================================================================== #[test] fn higher_threshold_is_more_permissive() { // With threshold=0.9, even borderline traffic should be allowed let model = make_realistic_model(5, 0.9); // A profile that's borderline between attack and normal let borderline: [f64; NUM_FEATURES] = [12.0, 8.0, 1.0, 0.5, 20.0, 0.5, 14.0, 0.5, 100.0, 2.0, 0.0, 0.0, 0.0, 0.1]; assert_eq!(model.classify(&borderline), DDoSAction::Allow); } #[test] fn larger_k_smooths_classification() { // With larger k, noisy outliers matter less let model_k3 = make_realistic_model(3, 0.6); let model_k9 = make_realistic_model(9, 0.6); // Normal traffic should be allowed by both let profile = normal_browser_browsing(); assert_eq!(model_k3.classify(&profile), DDoSAction::Allow); assert_eq!(model_k9.classify(&profile), DDoSAction::Allow); } // =========================================================================== // Normalization tests // =========================================================================== #[test] fn normalization_clamps_out_of_range() { let params = NormParams { mins: [0.0; NUM_FEATURES], maxs: [1.0; NUM_FEATURES], }; // Values above max should clamp to 1.0 let above = [2.0; NUM_FEATURES]; let normed = params.normalize(&above); for &v in &normed { assert_eq!(v, 1.0); } // Values below min should clamp to 0.0 let below = [-1.0; NUM_FEATURES]; let normed = params.normalize(&below); for &v in &normed { assert_eq!(v, 0.0); } } #[test] fn normalization_handles_zero_range() { // When all training data has the same value for a feature, range = 0 let params = NormParams { mins: [5.0; NUM_FEATURES], maxs: [5.0; NUM_FEATURES], }; let v = [5.0; NUM_FEATURES]; let normed = params.normalize(&v); for &val in &normed { assert_eq!(val, 0.0); } } #[test] fn normalization_preserves_midpoint() { let params = NormParams { mins: [0.0; NUM_FEATURES], maxs: [100.0; NUM_FEATURES], }; let v = [50.0; NUM_FEATURES]; let normed = params.normalize(&v); for &val in &normed { assert!((val - 0.5).abs() < 1e-10); } } #[test] fn norm_params_from_data_finds_extremes() { let data = vec![ [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0], [10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0, 0.5, 0.5, 0.5, 0.5], ]; let params = NormParams::from_data(&data); for i in 0..NUM_FEATURES { assert!(params.mins[i] <= params.maxs[i]); } assert_eq!(params.mins[0], 1.0); assert_eq!(params.maxs[0], 10.0); } // =========================================================================== // Serialization round-trip // =========================================================================== #[test] fn model_serialization_roundtrip() { // Use the realistic model (200+ points) so fnntw has enough data for the kD-tree let model = make_realistic_model(3, 0.5); // Rebuild from the same training data let mut all_points = Vec::new(); let mut all_labels = Vec::new(); for base in all_normal_profiles() { for i in 0..20 { let mut v = base; for d in 0..NUM_FEATURES { let jitter = 1.0 + ((i as f64 * 0.37 + d as f64 * 0.13) % 0.1 - 0.05); v[d] *= jitter; } all_points.push(v); all_labels.push(TrafficLabel::Normal); } } for base in all_attack_profiles() { for i in 0..20 { let mut v = base; for d in 0..NUM_FEATURES { let jitter = 1.0 + ((i as f64 * 0.41 + d as f64 * 0.17) % 0.1 - 0.05); v[d] *= jitter; } all_points.push(v); all_labels.push(TrafficLabel::Attack); } } let norm_params = NormParams::from_data(&all_points); let serialized = SerializedModel { points: all_points.iter().map(|v| norm_params.normalize(v)).collect(), labels: all_labels, norm_params, k: 3, threshold: 0.5, }; let encoded = bincode::serialize(&serialized).unwrap(); let decoded: SerializedModel = bincode::deserialize(&encoded).unwrap(); assert_eq!(decoded.points.len(), serialized.points.len()); assert_eq!(decoded.labels.len(), serialized.labels.len()); assert_eq!(decoded.k, 3); assert!((decoded.threshold - 0.5).abs() < 1e-10); // Rebuilt model should classify the same let rebuilt = TrainedModel::from_serialized(decoded); assert_eq!( rebuilt.classify(&normal_browser_browsing()), model.classify(&normal_browser_browsing()) ); } // =========================================================================== // Detector integration tests (full check() pipeline) // =========================================================================== #[test] fn detector_allows_below_min_events() { let model = make_realistic_model(5, 0.6); let detector = make_detector(model, 10); let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); // Send 9 requests — below min_events threshold of 10 for _ in 0..9 { let action = detector.check(ip, "GET", "/wp-admin", "evil.com", "bot", 0, false, false, false); assert_eq!(action, DDoSAction::Allow, "should allow below min_events"); } } #[test] fn detector_ipv4_and_ipv6_tracked_separately() { let model = make_realistic_model(5, 0.6); let detector = make_detector(model, 3); let v4 = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); let v6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)); // Send events to v4 only for _ in 0..5 { detector.check(v4, "GET", "/", "example.com", "Mozilla/5.0", 0, true, false, true); } // v6 should still have 0 events (below min_events) let action = detector.check(v6, "GET", "/", "example.com", "Mozilla/5.0", 0, true, false, true); assert_eq!(action, DDoSAction::Allow); } #[test] fn detector_normal_browsing_pattern_is_allowed() { let model = make_realistic_model(5, 0.6); let detector = make_detector(model, 5); let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 50)); let paths = ["/", "/about", "/products", "/products/1", "/contact", "/blog", "/blog/post-1", "/docs", "/pricing", "/login", "/dashboard", "/settings", "/api/me"]; let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"; for (i, path) in paths.iter().enumerate() { let method = if i % 5 == 0 { "POST" } else { "GET" }; let action = detector.check(ip, method, path, "mysite.com", ua, 0, true, true, true); // After min_events, every check should still allow normal browsing assert_eq!( action, DDoSAction::Allow, "normal browsing blocked on request #{i} to {path}" ); } } #[test] fn detector_handles_concurrent_ips() { let model = make_realistic_model(5, 0.6); let detector = make_detector(model, 5); // Simulate 50 distinct IPs each making a few normal requests for i in 0..50u8 { let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, i)); let paths = ["/", "/about", "/products", "/contact", "/blog", "/docs", "/api/status"]; for path in &paths { let action = detector.check(ip, "GET", path, "example.com", "Chrome", 0, true, false, true); assert_eq!(action, DDoSAction::Allow, "IP 10.0.0.{i} blocked on {path}"); } } } #[test] fn detector_ipv6_normal_traffic_is_allowed() { let model = make_realistic_model(5, 0.6); let detector = make_detector(model, 5); let ip = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x42)); let paths = ["/", "/about", "/products", "/blog", "/contact", "/login", "/dashboard"]; for path in &paths { let action = detector.check(ip, "GET", path, "example.com", "Mozilla/5.0", 0, true, false, true); assert_eq!(action, DDoSAction::Allow, "IPv6 normal traffic blocked on {path}"); } } // =========================================================================== // Model robustness: slight variations of normal traffic // =========================================================================== #[test] fn slightly_elevated_rate_still_allowed() { let model = make_realistic_model(5, 0.6); // 2x normal browsing rate — busy but not attacking let profile: [f64; NUM_FEATURES] = [1.0, 12.0, 1.0, 0.02, 150.0, 0.2, 1.2, 0.15, 0.0, 1.0, 1.0, 0.5, 1.0, 0.0]; assert_eq!(model.classify(&profile), DDoSAction::Allow); } #[test] fn slightly_elevated_errors_still_allowed() { // 15% errors (e.g. some 404s from broken links) — normal for real sites let model = make_realistic_model(5, 0.6); let profile: [f64; NUM_FEATURES] = [0.5, 10.0, 1.0, 0.15, 150.0, 0.2, 0.6, 0.15, 0.0, 1.0, 1.0, 0.3, 1.0, 0.0]; assert_eq!(model.classify(&profile), DDoSAction::Allow); } #[test] fn zero_traffic_features_allowed() { // Edge case: all zeros (shouldn't happen in practice, but must not crash or block) let model = make_realistic_model(5, 0.6); assert_eq!(model.classify(&[0.0; NUM_FEATURES]), DDoSAction::Allow); } #[test] fn empty_model_always_allows() { let model = TrainedModel::from_serialized(SerializedModel { points: vec![], labels: vec![], norm_params: NormParams { mins: [0.0; NUM_FEATURES], maxs: [1.0; NUM_FEATURES], }, k: 5, threshold: 0.6, }); // Must allow everything — no training data to compare against assert_eq!(model.classify(&attack_path_scan()), DDoSAction::Allow); assert_eq!(model.classify(&normal_browser_browsing()), DDoSAction::Allow); } #[test] fn all_normal_model_allows_everything() { // A model trained only on normal data (no attack points) should never block. // Use enough points (200) so fnntw can build the kD-tree. let mut normal = Vec::new(); for base in all_normal_profiles() { for i in 0..20 { let mut v = base; for d in 0..NUM_FEATURES { let jitter = 1.0 + ((i as f64 * 0.37 + d as f64 * 0.13) % 0.1 - 0.05); v[d] *= jitter; } normal.push(v); } } let model = make_model(&normal, &[], 5, 0.6); assert_eq!(model.classify(&normal_browser_browsing()), DDoSAction::Allow); assert_eq!(model.classify(&normal_api_client()), DDoSAction::Allow); // Even attack-like traffic is allowed since the model has no attack examples assert_eq!(model.classify(&attack_path_scan()), DDoSAction::Allow); } // =========================================================================== // Feature extraction tests // =========================================================================== #[test] fn method_entropy_zero_for_single_method() { // All GET requests → method distribution is [1.0, 0, 0, ...] → entropy = 0 let model = make_realistic_model(5, 0.6); let profile = normal_health_check(); // all GET assert_eq!(profile[5], 0.0); // method_entropy assert_eq!(model.classify(&profile), DDoSAction::Allow); } #[test] fn method_entropy_positive_for_mixed_methods() { let profile = normal_api_client(); // mix of GET/POST assert!(profile[5] > 0.0, "method_entropy should be positive for mixed methods"); } #[test] fn path_repetition_is_one_for_single_path() { let profile = normal_graphql_spa(); // single /graphql endpoint assert_eq!(profile[7], 1.0); } #[test] fn path_repetition_is_low_for_diverse_paths() { let profile = normal_search_crawler(); // many unique paths assert!(profile[7] < 0.2); } // =========================================================================== // Load the real trained model and validate against known profiles // =========================================================================== #[test] fn real_model_file_roundtrip() { let model_path = std::path::Path::new("ddos_model.bin"); if !model_path.exists() { // Skip if no model file present (CI environments) eprintln!("skipping real_model_file_roundtrip: ddos_model.bin not found"); return; } let model = TrainedModel::load(model_path, Some(3), Some(0.5)).unwrap(); assert!(model.point_count() > 0, "model should have training points"); // Smoke test: classifying shouldn't panic let _ = model.classify(&normal_browser_browsing()); let _ = model.classify(&attack_path_scan()); }