""" Unit tests for config.py - IP parsing, environment validation. """ from unittest.mock import Mock import pytest from config import ( _ip_matches_ranges, _parse_ip, get_client_ip, get_forwarded_client_ip, is_lan_ip, is_tailscale_ip, is_trusted_proxy, ) class TestParseIP: """Test IP address parsing.""" def test_parse_valid_ipv4(self): """Test parsing valid IPv4 address.""" ip = _parse_ip("192.168.1.1") assert ip is not None assert str(ip) == "192.168.1.1" def test_parse_valid_ipv6(self): """Test parsing valid IPv6 address.""" ip = _parse_ip("2001:db8::1") assert ip is not None assert str(ip) == "2001:db8::1" def test_parse_localhost_ipv4(self): """Test parsing localhost IPv4.""" ip = _parse_ip("127.0.0.1") assert ip is not None assert str(ip) == "127.0.0.1" def test_parse_localhost_ipv6(self): """Test parsing localhost IPv6.""" ip = _parse_ip("::1") assert ip is not None assert str(ip) == "::1" def test_parse_invalid_ip(self): """Test parsing invalid IP returns None.""" assert _parse_ip("not.an.ip.address") is None assert _parse_ip("999.999.999.999") is None assert _parse_ip("") is None assert _parse_ip(" ") is None def test_parse_ip_with_whitespace(self): """Test parsing IP with surrounding whitespace.""" ip = _parse_ip(" 192.168.1.1 ") assert ip is not None assert str(ip) == "192.168.1.1" class TestIPMatchesRanges: """Test IP range matching.""" def test_match_single_ip(self): """Test matching against single IP.""" assert _ip_matches_ranges("192.168.1.1", ["192.168.1.1"]) assert not _ip_matches_ranges("192.168.1.2", ["192.168.1.1"]) def test_match_cidr_range(self): """Test matching against CIDR range.""" ranges = ["192.168.1.0/24"] assert _ip_matches_ranges("192.168.1.1", ranges) assert _ip_matches_ranges("192.168.1.100", ranges) assert _ip_matches_ranges("192.168.1.254", ranges) assert not _ip_matches_ranges("192.168.2.1", ranges) def test_match_multiple_ranges(self): """Test matching against multiple ranges.""" ranges = ["192.168.1.0/24", "10.0.0.0/8", "172.16.0.1"] assert _ip_matches_ranges("192.168.1.50", ranges) assert _ip_matches_ranges("10.5.10.20", ranges) assert _ip_matches_ranges("172.16.0.1", ranges) assert not _ip_matches_ranges("8.8.8.8", ranges) def test_match_ipv6_range(self): """Test matching IPv6 against range.""" ranges = ["2001:db8::/32"] assert _ip_matches_ranges("2001:db8::1", ranges) assert _ip_matches_ranges("2001:db8:1234::5678", ranges) assert not _ip_matches_ranges("2001:db9::1", ranges) def test_match_invalid_ip(self): """Test that invalid IP returns False.""" assert not _ip_matches_ranges("invalid", ["192.168.1.0/24"]) assert not _ip_matches_ranges("", ["192.168.1.0/24"]) def test_match_empty_ranges(self): """Test matching against empty ranges.""" assert not _ip_matches_ranges("192.168.1.1", []) def test_match_with_whitespace_in_ranges(self): """Test matching with whitespace in range strings.""" ranges = [" 192.168.1.0/24 ", " 10.0.0.1 "] assert _ip_matches_ranges("192.168.1.50", ranges) assert _ip_matches_ranges("10.0.0.1", ranges) def test_match_with_empty_string_in_ranges(self): """Test that empty strings in ranges are skipped.""" ranges = ["192.168.1.0/24", "", " ", "10.0.0.1"] assert _ip_matches_ranges("192.168.1.50", ranges) assert _ip_matches_ranges("10.0.0.1", ranges) def test_match_with_invalid_range(self): """Test that invalid ranges are skipped.""" ranges = ["192.168.1.0/24", "invalid-range", "10.0.0.1"] assert _ip_matches_ranges("192.168.1.50", ranges) assert _ip_matches_ranges("10.0.0.1", ranges) assert not _ip_matches_ranges("8.8.8.8", ranges) class TestIsLanIP: """Test LAN IP detection.""" def test_is_lan_ip_default_range(self, mock_config): """Test LAN IP detection with default range.""" # Default is 192.168.31.0/24 assert is_lan_ip("192.168.31.1") assert is_lan_ip("192.168.31.100") assert is_lan_ip("192.168.31.254") assert not is_lan_ip("192.168.30.1") assert not is_lan_ip("192.168.32.1") def test_is_lan_ip_not_tailscale(self, mock_config): """Test that Tailscale IPs are not considered LAN.""" assert not is_lan_ip("100.64.0.1") assert not is_lan_ip("100.78.131.124") class TestIsTailscaleIP: """Test Tailscale IP detection.""" def test_is_tailscale_ip_valid(self): """Test Tailscale IP detection.""" assert is_tailscale_ip("100.64.0.1") assert is_tailscale_ip("100.78.131.124") assert is_tailscale_ip("100.100.100.100") assert is_tailscale_ip("100.127.255.254") def test_is_tailscale_ip_invalid(self): """Test non-Tailscale IPs.""" assert not is_tailscale_ip("100.63.255.255") # Just below range assert not is_tailscale_ip("100.128.0.0") # Just above range assert not is_tailscale_ip("192.168.1.1") assert not is_tailscale_ip("10.0.0.1") def test_is_tailscale_ip_boundary(self): """Test Tailscale IP range boundaries.""" # 100.64.0.0/10 means 100.64.0.0 to 100.127.255.255 assert is_tailscale_ip("100.64.0.0") # Start of range assert is_tailscale_ip("100.127.255.255") # End of range class TestIsTrustedProxy: """Test trusted proxy detection.""" def test_is_trusted_proxy_default(self, mock_config): """Test trusted proxy detection with default config.""" # Default: 127.0.0.1,::1,172.21.0.1 assert is_trusted_proxy("127.0.0.1") assert is_trusted_proxy("::1") assert is_trusted_proxy("172.21.0.1") assert not is_trusted_proxy("192.168.1.1") class TestGetForwardedClientIP: """Test forwarded client IP extraction.""" def test_get_forwarded_ip_from_trusted_proxy(self, mock_config): """Test extracting forwarded IP from trusted proxy.""" request = Mock() request.client = Mock() request.client.host = "127.0.0.1" # Trusted proxy request.headers = {"x-forwarded-for": "203.0.113.1"} ip = get_forwarded_client_ip(request) assert ip == "203.0.113.1" def test_get_forwarded_ip_from_untrusted_proxy(self, mock_config): """Test that untrusted proxy returns None.""" request = Mock() request.client = Mock() request.client.host = "8.8.8.8" # Not trusted request.headers = {"x-forwarded-for": "203.0.113.1"} ip = get_forwarded_client_ip(request) assert ip is None def test_get_forwarded_ip_multiple_hops(self, mock_config): """Test extracting first IP from multiple forwarded IPs.""" request = Mock() request.client = Mock() request.client.host = "127.0.0.1" request.headers = {"x-forwarded-for": "203.0.113.1, 198.51.100.1, 192.0.2.1"} ip = get_forwarded_client_ip(request) assert ip == "203.0.113.1" def test_get_forwarded_ip_no_header(self, mock_config): """Test when x-forwarded-for header is missing.""" request = Mock() request.client = Mock() request.client.host = "127.0.0.1" request.headers = {} ip = get_forwarded_client_ip(request) assert ip is None def test_get_forwarded_ip_empty_header(self, mock_config): """Test when x-forwarded-for header is empty.""" request = Mock() request.client = Mock() request.client.host = "127.0.0.1" request.headers = {"x-forwarded-for": ""} ip = get_forwarded_client_ip(request) assert ip is None def test_get_forwarded_ip_no_client(self, mock_config): """Test when request has no client.""" request = Mock() request.client = None request.headers = {"x-forwarded-for": "203.0.113.1"} ip = get_forwarded_client_ip(request) assert ip is None class TestGetClientIP: """Test client IP extraction.""" def test_get_client_ip_direct(self, mock_config): """Test getting client IP from direct connection.""" request = Mock() request.client = Mock() request.client.host = "192.168.1.100" request.headers = {} ip = get_client_ip(request) assert ip == "192.168.1.100" def test_get_client_ip_via_trusted_proxy(self, mock_config): """Test getting client IP via trusted proxy.""" request = Mock() request.client = Mock() request.client.host = "127.0.0.1" request.headers = {"x-forwarded-for": "203.0.113.1"} ip = get_client_ip(request) assert ip == "203.0.113.1" def test_get_client_ip_via_untrusted_proxy(self, mock_config): """Test getting client IP via untrusted proxy falls back to direct.""" request = Mock() request.client = Mock() request.client.host = "8.8.8.8" request.headers = {"x-forwarded-for": "203.0.113.1"} ip = get_client_ip(request) assert ip == "8.8.8.8" def test_get_client_ip_no_client(self, mock_config): """Test getting client IP when request has no client.""" request = Mock() request.client = None request.headers = {} ip = get_client_ip(request) assert ip == "" class TestConfigValidation: """Test configuration validation.""" def test_secret_key_validation(self, monkeypatch): """Test that short SECRET_KEY raises error.""" monkeypatch.setenv("SECRET_KEY", "short") with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"): import importlib import config importlib.reload(config) def test_secret_key_empty(self, monkeypatch): """Test that empty SECRET_KEY raises error.""" monkeypatch.delenv("SECRET_KEY", raising=False) with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"): import importlib import config importlib.reload(config)