8431920d26
- Backend: pytest with unit tests (auth, rbac, config) and integration tests (auth flow, docker, files) - Frontend: vitest with unit tests (api client) and component tests (login) - CI: Gitea Actions workflow for automated testing with coverage reports - Documentation: TESTING.md guide with setup, usage, and best practices - Coverage goals: 80%+ backend, 70%+ frontend
298 lines
10 KiB
Python
298 lines
10 KiB
Python
"""
|
|
Unit tests for config.py - IP parsing, environment validation.
|
|
"""
|
|
import pytest
|
|
from unittest.mock import Mock
|
|
from config import (
|
|
_parse_ip,
|
|
_ip_matches_ranges,
|
|
is_lan_ip,
|
|
is_tailscale_ip,
|
|
is_trusted_proxy,
|
|
get_forwarded_client_ip,
|
|
get_client_ip,
|
|
)
|
|
|
|
|
|
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 config
|
|
import importlib
|
|
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 config
|
|
import importlib
|
|
importlib.reload(config)
|