# -*- coding: utf-8 -*- """ Tests für Network Security Module. Testet Access Control, Rate Limiting und DDoS Protection. """ import asyncio import time import pytest from datetime import datetime, timedelta from trixy_core.network.security.access_control import ( AccessController, AccessRule, AccessDecision, IPMatcher, CIDRMatcher, MACMatcher, WildcardMatcher, ) from trixy_core.network.security.rate_limiter import ( ConnectionRateLimiter, RateLimitConfig, RateLimitResult, IPRateLimiter, GlobalRateLimiter, TokenBucket, ) from trixy_core.network.security.ddos import ( DDoSProtection, DDoSConfig, ThreatLevel, ConnectionTracker, SuspiciousActivityDetector, ) # ============================================================================= # Access Control Tests # ============================================================================= class TestIPMatcher: """Tests für IPMatcher.""" def test_ipv4_exact_match(self): """IPv4 exakter Match.""" matcher = IPMatcher("192.168.1.100") assert matcher.matches("192.168.1.100") is True assert matcher.matches("192.168.1.101") is False def test_ipv6_exact_match(self): """IPv6 exakter Match.""" matcher = IPMatcher("::1") assert matcher.matches("::1") is True assert matcher.matches("::2") is False def test_invalid_ip_no_match(self): """Ungültige IP matcht nicht.""" matcher = IPMatcher("192.168.1.100") assert matcher.matches("invalid") is False assert matcher.matches("") is False class TestCIDRMatcher: """Tests für CIDRMatcher.""" def test_cidr_in_range(self): """IP im CIDR-Bereich.""" matcher = CIDRMatcher("192.168.1.0/24") assert matcher.matches("192.168.1.0") is True assert matcher.matches("192.168.1.100") is True assert matcher.matches("192.168.1.255") is True def test_cidr_out_of_range(self): """IP außerhalb des CIDR-Bereichs.""" matcher = CIDRMatcher("192.168.1.0/24") assert matcher.matches("192.168.2.1") is False assert matcher.matches("10.0.0.1") is False def test_cidr_slash_32(self): """CIDR /32 = einzelne IP.""" matcher = CIDRMatcher("192.168.1.100/32") assert matcher.matches("192.168.1.100") is True assert matcher.matches("192.168.1.101") is False def test_cidr_slash_16(self): """CIDR /16 = Class B Netzwerk.""" matcher = CIDRMatcher("10.0.0.0/16") assert matcher.matches("10.0.0.1") is True assert matcher.matches("10.0.255.255") is True assert matcher.matches("10.1.0.1") is False class TestMACMatcher: """Tests für MACMatcher.""" def test_mac_colon_format(self): """MAC mit Doppelpunkt-Format.""" matcher = MACMatcher("AA:BB:CC:DD:EE:FF") assert matcher.matches("AA:BB:CC:DD:EE:FF") is True assert matcher.matches("aa:bb:cc:dd:ee:ff") is True # Case-insensitive def test_mac_dash_format(self): """MAC mit Bindestrich-Format.""" matcher = MACMatcher("AA-BB-CC-DD-EE-FF") assert matcher.matches("AA:BB:CC:DD:EE:FF") is True def test_mac_no_separator(self): """MAC ohne Trennzeichen.""" matcher = MACMatcher("AABBCCDDEEFF") assert matcher.matches("AA:BB:CC:DD:EE:FF") is True def test_mac_no_match(self): """MAC matcht nicht.""" matcher = MACMatcher("AA:BB:CC:DD:EE:FF") assert matcher.matches("11:22:33:44:55:66") is False class TestWildcardMatcher: """Tests für WildcardMatcher.""" def test_wildcard_single_segment(self): """Wildcard für ein Segment.""" matcher = WildcardMatcher("192.168.1.*") assert matcher.matches("192.168.1.0") is True assert matcher.matches("192.168.1.255") is True assert matcher.matches("192.168.2.1") is False def test_wildcard_multiple_segments(self): """Wildcard für mehrere Segmente.""" matcher = WildcardMatcher("192.168.*.*") assert matcher.matches("192.168.0.1") is True assert matcher.matches("192.168.255.255") is True assert matcher.matches("192.169.0.1") is False def test_wildcard_prefix(self): """Wildcard am Anfang.""" matcher = WildcardMatcher("*.168.1.100") assert matcher.matches("192.168.1.100") is True assert matcher.matches("10.168.1.100") is True class TestAccessRule: """Tests für AccessRule.""" def test_rule_creation(self): """Regel erstellen.""" rule = AccessRule( pattern="192.168.1.0/24", decision=AccessDecision.ALLOW, description="Lokales Netzwerk" ) assert rule.pattern == "192.168.1.0/24" assert rule.decision == AccessDecision.ALLOW assert rule.hit_count == 0 def test_rule_expiry(self): """Regel-Ablauf prüfen.""" # Nicht abgelaufene Regel rule = AccessRule( pattern="test", decision=AccessDecision.DENY, expires_at=datetime.now() + timedelta(hours=1) ) assert rule.is_expired() is False # Abgelaufene Regel rule_expired = AccessRule( pattern="test", decision=AccessDecision.DENY, expires_at=datetime.now() - timedelta(hours=1) ) assert rule_expired.is_expired() is True def test_rule_hit_count(self): """Treffer zählen.""" rule = AccessRule(pattern="test", decision=AccessDecision.ALLOW) assert rule.hit_count == 0 rule.record_hit() assert rule.hit_count == 1 rule.record_hit() assert rule.hit_count == 2 class TestAccessController: """Tests für AccessController.""" def test_default_deny(self): """Standard: Deny.""" controller = AccessController(default_allow=False) assert controller.check_ip("192.168.1.100") == AccessDecision.DENY def test_default_allow(self): """Standard: Allow.""" controller = AccessController(default_allow=True) assert controller.check_ip("192.168.1.100") == AccessDecision.ALLOW def test_whitelist_ip(self): """Whitelist einzelne IP.""" controller = AccessController(default_allow=False) controller.whitelist("192.168.1.100", "Erlaubte IP") assert controller.check_ip("192.168.1.100") == AccessDecision.ALLOW assert controller.check_ip("192.168.1.101") == AccessDecision.DENY def test_whitelist_cidr(self): """Whitelist CIDR-Bereich.""" controller = AccessController(default_allow=False) controller.whitelist("192.168.1.0/24", "Lokales Netzwerk") assert controller.check_ip("192.168.1.50") == AccessDecision.ALLOW assert controller.check_ip("192.168.2.50") == AccessDecision.DENY def test_blacklist_overrides_whitelist(self): """Blacklist hat Vorrang vor Whitelist.""" controller = AccessController(default_allow=False) controller.whitelist("192.168.1.0/24", "Netzwerk") controller.blacklist("192.168.1.100", "Gesperrte IP") assert controller.check_ip("192.168.1.50") == AccessDecision.ALLOW assert controller.check_ip("192.168.1.100") == AccessDecision.DENY def test_mac_whitelist(self): """MAC-Whitelist.""" controller = AccessController(default_allow=False) controller.whitelist_mac("AA:BB:CC:DD:EE:FF", "Erlaubtes Gerät") assert controller.check_mac("AA:BB:CC:DD:EE:FF") == AccessDecision.ALLOW assert controller.check_mac("11:22:33:44:55:66") == AccessDecision.DENY def test_combined_check(self): """Kombinierte IP + MAC Prüfung.""" controller = AccessController(default_allow=False) controller.whitelist("192.168.1.0/24") controller.whitelist_mac("AA:BB:CC:DD:EE:FF") # Beide erlaubt assert controller.check("192.168.1.100", "AA:BB:CC:DD:EE:FF") == AccessDecision.ALLOW # IP erlaubt, MAC nicht assert controller.check("192.168.1.100", "11:22:33:44:55:66") == AccessDecision.DENY # IP nicht erlaubt assert controller.check("10.0.0.1", "AA:BB:CC:DD:EE:FF") == AccessDecision.DENY def test_expired_rules_ignored(self): """Abgelaufene Regeln werden ignoriert.""" controller = AccessController(default_allow=False) controller.whitelist( "192.168.1.100", expires_at=datetime.now() - timedelta(hours=1) ) assert controller.check_ip("192.168.1.100") == AccessDecision.DENY def test_remove_from_whitelist(self): """Regel von Whitelist entfernen.""" controller = AccessController(default_allow=False) controller.whitelist("192.168.1.100") assert controller.check_ip("192.168.1.100") == AccessDecision.ALLOW controller.remove_from_whitelist("192.168.1.100") assert controller.check_ip("192.168.1.100") == AccessDecision.DENY def test_clear_expired(self): """Abgelaufene Regeln bereinigen.""" controller = AccessController(default_allow=False) controller.whitelist("192.168.1.1", expires_at=datetime.now() - timedelta(hours=1)) controller.whitelist("192.168.1.2", expires_at=datetime.now() + timedelta(hours=1)) controller.whitelist("192.168.1.3") # Kein Ablauf count = controller.clear_expired() assert count == 1 def test_statistics(self): """Statistiken abrufen.""" controller = AccessController(default_allow=False) controller.whitelist("192.168.1.0/24") controller.blacklist("10.0.0.0/8") stats = controller.get_statistics() assert stats["whitelist_count"] == 1 assert stats["blacklist_count"] == 1 def test_serialization(self): """Serialisierung und Deserialisierung.""" controller = AccessController(default_allow=False) controller.whitelist("192.168.1.0/24", "Netzwerk") controller.blacklist("10.0.0.1", "Gesperrt") data = controller.to_dict() restored = AccessController.from_dict(data) assert restored.check_ip("192.168.1.100") == AccessDecision.ALLOW assert restored.check_ip("10.0.0.1") == AccessDecision.DENY # ============================================================================= # Rate Limiter Tests # ============================================================================= class TestTokenBucket: """Tests für TokenBucket.""" def test_initial_capacity(self): """Initiale Kapazität.""" bucket = TokenBucket(capacity=10, tokens=10.0, refill_rate=1.0) assert bucket.tokens == 10.0 def test_consume_tokens(self): """Tokens verbrauchen.""" bucket = TokenBucket(capacity=10, tokens=10.0, refill_rate=1.0) assert bucket.consume(5) is True # Tokens können durch Refill leicht höher sein assert 4.5 <= bucket.tokens <= 5.5 assert bucket.consume(5) is True # Fast 0, aber Refill kann kleine Menge hinzufügen assert bucket.tokens < 0.5 assert bucket.consume(1) is False # Nicht genug Tokens def test_refill(self): """Token-Refill.""" bucket = TokenBucket(capacity=10, tokens=0.0, refill_rate=100.0) time.sleep(0.05) # 50ms = 5 Tokens bei 100/s bucket.refill() assert bucket.tokens >= 4.0 # Mindestens 4 Tokens def test_capacity_limit(self): """Kapazitätslimit.""" bucket = TokenBucket(capacity=10, tokens=10.0, refill_rate=100.0) time.sleep(0.1) bucket.refill() assert bucket.tokens == 10.0 # Nicht über Kapazität def test_blocking(self): """Bucket blockieren.""" bucket = TokenBucket(capacity=10, tokens=10.0, refill_rate=1.0) assert bucket.is_blocked() is False bucket.block(1.0) assert bucket.is_blocked() is True time.sleep(1.1) assert bucket.is_blocked() is False class TestIPRateLimiter: """Tests für IPRateLimiter.""" def test_allow_under_limit(self): """Erlauben unter Limit.""" config = RateLimitConfig(requests_per_second=10.0, burst_size=5) limiter = IPRateLimiter(config) for _ in range(5): result = limiter.check("192.168.1.100") assert result == RateLimitResult.ALLOWED def test_limit_exceeded(self): """Limit überschritten.""" config = RateLimitConfig(requests_per_second=1.0, burst_size=2) limiter = IPRateLimiter(config) assert limiter.check("192.168.1.100") == RateLimitResult.ALLOWED assert limiter.check("192.168.1.100") == RateLimitResult.ALLOWED assert limiter.check("192.168.1.100") == RateLimitResult.LIMITED def test_different_ips_separate_limits(self): """Verschiedene IPs haben separate Limits.""" config = RateLimitConfig(requests_per_second=1.0, burst_size=2) limiter = IPRateLimiter(config) # IP 1 ausschöpfen limiter.check("192.168.1.1") limiter.check("192.168.1.1") assert limiter.check("192.168.1.1") == RateLimitResult.LIMITED # IP 2 noch erlaubt assert limiter.check("192.168.1.2") == RateLimitResult.ALLOWED def test_blocking_after_threshold(self): """Blockierung nach Schwellenwert.""" config = RateLimitConfig( requests_per_second=1.0, burst_size=1, block_threshold=3, block_duration=10.0 ) limiter = IPRateLimiter(config) # Erste Anfrage OK limiter.check("192.168.1.100") # Violations sammeln for _ in range(3): limiter.check("192.168.1.100") # Sollte jetzt blockiert sein result = limiter.check("192.168.1.100") assert result == RateLimitResult.BLOCKED def test_unblock_ip(self): """IP entsperren.""" config = RateLimitConfig( requests_per_second=1.0, burst_size=1, block_threshold=2, block_duration=10.0 ) limiter = IPRateLimiter(config) # Blockieren limiter.check("192.168.1.100") limiter.check("192.168.1.100") limiter.check("192.168.1.100") assert limiter.check("192.168.1.100") == RateLimitResult.BLOCKED # Entsperren limiter.unblock("192.168.1.100") assert limiter.check("192.168.1.100") == RateLimitResult.ALLOWED def test_get_status(self): """Status abrufen.""" config = RateLimitConfig(requests_per_second=10.0, burst_size=5) limiter = IPRateLimiter(config) limiter.check("192.168.1.100") limiter.check("192.168.1.100") status = limiter.get_status("192.168.1.100") assert status["exists"] is True assert status["tokens"] < 5.0 def test_reset(self): """Reset einer IP.""" config = RateLimitConfig(requests_per_second=1.0, burst_size=2) limiter = IPRateLimiter(config) limiter.check("192.168.1.100") limiter.check("192.168.1.100") limiter.reset("192.168.1.100") status = limiter.get_status("192.168.1.100") assert status["exists"] is False def test_cleanup(self): """Cleanup inaktiver Buckets.""" config = RateLimitConfig(requests_per_second=10.0, burst_size=5) limiter = IPRateLimiter(config) limiter.check("192.168.1.100") # Bucket sollte existieren assert limiter.get_status("192.168.1.100")["exists"] is True # Cleanup (wird normalerweise nach Timeout aufgerufen) count = limiter.cleanup() # Bei kurzer Inaktivität wird nichts entfernt assert count == 0 class TestGlobalRateLimiter: """Tests für GlobalRateLimiter.""" def test_allow_under_limit(self): """Erlauben unter globalem Limit.""" limiter = GlobalRateLimiter(max_requests_per_second=100.0, burst_size=10) for _ in range(10): result = limiter.check() assert result == RateLimitResult.ALLOWED def test_limit_exceeded(self): """Globales Limit überschritten.""" limiter = GlobalRateLimiter(max_requests_per_second=1.0, burst_size=3) limiter.check() limiter.check() limiter.check() assert limiter.check() == RateLimitResult.LIMITED def test_get_status(self): """Status abrufen.""" limiter = GlobalRateLimiter(max_requests_per_second=10.0, burst_size=10) limiter.check() limiter.check() status = limiter.get_status() assert status["total_requests"] == 2 assert status["tokens_available"] < 10.0 class TestConnectionRateLimiter: """Tests für kombinierten Rate-Limiter.""" def test_combined_limits(self): """Kombinierte IP- und Global-Limits.""" ip_config = RateLimitConfig(requests_per_second=10.0, burst_size=5) limiter = ConnectionRateLimiter( ip_config=ip_config, global_rps=100.0, global_burst=50 ) # Unter beiden Limits result = limiter.check("192.168.1.100") assert result == RateLimitResult.ALLOWED def test_ip_limit_triggers_first(self): """IP-Limit greift zuerst.""" ip_config = RateLimitConfig(requests_per_second=1.0, burst_size=2) limiter = ConnectionRateLimiter( ip_config=ip_config, global_rps=1000.0, global_burst=1000 ) limiter.check("192.168.1.100") limiter.check("192.168.1.100") result = limiter.check("192.168.1.100") assert result == RateLimitResult.LIMITED def test_statistics(self): """Kombinierte Statistiken.""" ip_config = RateLimitConfig(requests_per_second=10.0, burst_size=5) limiter = ConnectionRateLimiter(ip_config=ip_config) limiter.check("192.168.1.100") stats = limiter.get_statistics() assert "ip" in stats assert "global" in stats # ============================================================================= # DDoS Protection Tests # ============================================================================= class TestConnectionTracker: """Tests für ConnectionTracker.""" def test_register_connection(self): """Verbindung registrieren.""" config = DDoSConfig(max_connections_per_ip=5) tracker = ConnectionTracker(config) conn_id, allowed = tracker.register_connection("192.168.1.100") assert allowed is True assert conn_id != "" def test_max_connections_per_ip(self): """Maximale Verbindungen pro IP.""" config = DDoSConfig(max_connections_per_ip=2) tracker = ConnectionTracker(config) conn1, allowed1 = tracker.register_connection("192.168.1.100") conn2, allowed2 = tracker.register_connection("192.168.1.100") _, allowed3 = tracker.register_connection("192.168.1.100") assert allowed1 is True assert allowed2 is True assert allowed3 is False def test_unregister_connection(self): """Verbindung entfernen.""" config = DDoSConfig(max_connections_per_ip=2) tracker = ConnectionTracker(config) conn_id, _ = tracker.register_connection("192.168.1.100") tracker.register_connection("192.168.1.100") assert tracker.get_connection_count("192.168.1.100") == 2 tracker.unregister_connection(conn_id) assert tracker.get_connection_count("192.168.1.100") == 1 def test_ban_ip(self): """IP sperren.""" config = DDoSConfig() tracker = ConnectionTracker(config) tracker.ban_ip("192.168.1.100", duration=10.0) assert tracker.is_banned("192.168.1.100") is True _, allowed = tracker.register_connection("192.168.1.100") assert allowed is False def test_unban_ip(self): """IP entsperren.""" config = DDoSConfig() tracker = ConnectionTracker(config) tracker.ban_ip("192.168.1.100") assert tracker.is_banned("192.168.1.100") is True tracker.unban_ip("192.168.1.100") assert tracker.is_banned("192.168.1.100") is False def test_global_connection_limit(self): """Globales Verbindungslimit.""" config = DDoSConfig(max_global_connections=3) tracker = ConnectionTracker(config) tracker.register_connection("192.168.1.1") tracker.register_connection("192.168.1.2") tracker.register_connection("192.168.1.3") _, allowed = tracker.register_connection("192.168.1.4") assert allowed is False def test_statistics(self): """Statistiken abrufen.""" config = DDoSConfig() tracker = ConnectionTracker(config) tracker.register_connection("192.168.1.100") tracker.register_connection("192.168.1.101") stats = tracker.get_statistics() assert stats["total_connections"] == 2 assert stats["unique_ips"] == 2 class TestSuspiciousActivityDetector: """Tests für SuspiciousActivityDetector.""" def test_record_event(self): """Event aufzeichnen.""" config = DDoSConfig(monitoring_window=60.0) detector = SuspiciousActivityDetector(config) detector.record_event("192.168.1.100", "connection_denied") level = detector.analyze("192.168.1.100") assert level == ThreatLevel.LOW def test_threat_level_escalation(self): """Bedrohungsstufe eskaliert.""" config = DDoSConfig(monitoring_window=60.0) detector = SuspiciousActivityDetector(config) # Viele Events = höhere Bedrohung for _ in range(50): detector.record_event("192.168.1.100", "connection_denied") level = detector.analyze("192.168.1.100") assert level >= ThreatLevel.MEDIUM def test_get_suspicious_ips(self): """Verdächtige IPs abrufen.""" config = DDoSConfig(monitoring_window=60.0) detector = SuspiciousActivityDetector(config) for _ in range(20): detector.record_event("192.168.1.100", "connection_denied") suspicious = detector.get_suspicious_ips(ThreatLevel.LOW) assert len(suspicious) > 0 assert suspicious[0][0] == "192.168.1.100" class TestDDoSProtection: """Tests für DDoSProtection.""" @pytest.mark.asyncio async def test_on_connect_allowed(self): """Verbindung erlaubt.""" config = DDoSConfig(max_connections_per_ip=5) protection = DDoSProtection(config) conn_id, allowed = await protection.on_connect("192.168.1.100") assert allowed is True assert conn_id != "" @pytest.mark.asyncio async def test_on_connect_denied(self): """Verbindung verweigert.""" config = DDoSConfig(max_connections_per_ip=1) protection = DDoSProtection(config) await protection.on_connect("192.168.1.100") _, allowed = await protection.on_connect("192.168.1.100") assert allowed is False @pytest.mark.asyncio async def test_on_disconnect(self): """Verbindung getrennt.""" config = DDoSConfig(max_connections_per_ip=2) protection = DDoSProtection(config) conn_id, _ = await protection.on_connect("192.168.1.100") await protection.on_connect("192.168.1.100") await protection.on_disconnect(conn_id) # Jetzt sollte wieder Platz sein _, allowed = await protection.on_connect("192.168.1.100") assert allowed is True @pytest.mark.asyncio async def test_ban_unban(self): """IP sperren und entsperren.""" protection = DDoSProtection() protection.ban_ip("192.168.1.100") assert protection.is_allowed("192.168.1.100") is False protection.unban_ip("192.168.1.100") assert protection.is_allowed("192.168.1.100") is True @pytest.mark.asyncio async def test_threat_callback(self): """Threat-Callback wird aufgerufen.""" config = DDoSConfig(max_connections_per_ip=1) protection = DDoSProtection(config) threats_detected = [] @protection.on_threat def handle_threat(ip: str, level: ThreatLevel): threats_detected.append((ip, level)) # Viele fehlgeschlagene Verbindungsversuche await protection.on_connect("192.168.1.100") for _ in range(10): await protection.on_connect("192.168.1.100") # Nach vielen Versuchen sollte Callback aufgerufen werden # (abhängig von der Threat-Level-Logik) @pytest.mark.asyncio async def test_statistics(self): """Statistiken abrufen.""" protection = DDoSProtection() await protection.on_connect("192.168.1.100") stats = protection.get_statistics() assert "connections" in stats assert "threats" in stats assert "config" in stats # ============================================================================= # Async Tests # ============================================================================= class TestAsyncRateLimiter: """Tests für async Rate-Limiter-Methoden.""" @pytest.mark.asyncio async def test_ip_rate_limiter_async(self): """Async IP Rate-Limiter.""" config = RateLimitConfig(requests_per_second=10.0, burst_size=5) limiter = IPRateLimiter(config) result = await limiter.check_async("192.168.1.100") assert result == RateLimitResult.ALLOWED @pytest.mark.asyncio async def test_global_rate_limiter_async(self): """Async Global Rate-Limiter.""" limiter = GlobalRateLimiter(max_requests_per_second=10.0, burst_size=5) result = await limiter.check_async() assert result == RateLimitResult.ALLOWED @pytest.mark.asyncio async def test_connection_rate_limiter_async(self): """Async kombinierter Rate-Limiter.""" ip_config = RateLimitConfig(requests_per_second=10.0, burst_size=5) limiter = ConnectionRateLimiter(ip_config=ip_config) result = await limiter.check_async("192.168.1.100") assert result == RateLimitResult.ALLOWED