feat: add comprehensive unit and integration testing infrastructure
- 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
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user