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 @@
|
||||
# Unit tests
|
||||
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Unit tests for auth.py - JWT, TOTP, password hashing, encryption.
|
||||
"""
|
||||
import pytest
|
||||
import jwt
|
||||
import pyotp
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from auth import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
user_from_access_token,
|
||||
verify_totp,
|
||||
load_totp_secret,
|
||||
save_totp_secret,
|
||||
load_password_hash,
|
||||
save_password_hash,
|
||||
increment_token_version,
|
||||
verify_token_version,
|
||||
_encrypt,
|
||||
_decrypt,
|
||||
load_passkey_credentials,
|
||||
save_passkey_credential,
|
||||
clear_passkey_credentials,
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
"""Test password hashing and verification."""
|
||||
|
||||
def test_hash_and_verify_password(self):
|
||||
"""Test that password hashing and verification works."""
|
||||
password = "test_password_123"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
assert hashed != password
|
||||
assert verify_password(password, hashed)
|
||||
|
||||
def test_verify_wrong_password(self):
|
||||
"""Test that wrong password fails verification."""
|
||||
password = "correct_password"
|
||||
wrong_password = "wrong_password"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
assert not verify_password(wrong_password, hashed)
|
||||
|
||||
def test_different_hashes_for_same_password(self):
|
||||
"""Test that same password produces different hashes (salt)."""
|
||||
password = "test_password"
|
||||
hash1 = get_password_hash(password)
|
||||
hash2 = get_password_hash(password)
|
||||
|
||||
assert hash1 != hash2
|
||||
assert verify_password(password, hash1)
|
||||
assert verify_password(password, hash2)
|
||||
|
||||
|
||||
class TestJWTTokens:
|
||||
"""Test JWT token creation and validation."""
|
||||
|
||||
def test_create_access_token(self, mock_config):
|
||||
"""Test access token creation."""
|
||||
data = {"sub": "testuser", "role": "admin"}
|
||||
token = create_access_token(data)
|
||||
|
||||
assert token is not None
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
assert payload["sub"] == "testuser"
|
||||
assert payload["role"] == "admin"
|
||||
assert payload["type"] == "access"
|
||||
assert "exp" in payload
|
||||
|
||||
def test_create_access_token_with_custom_expiry(self, mock_config):
|
||||
"""Test access token with custom expiration."""
|
||||
data = {"sub": "testuser"}
|
||||
expires_delta = timedelta(minutes=60)
|
||||
token = create_access_token(data, expires_delta)
|
||||
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
exp_time = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Should expire in approximately 60 minutes
|
||||
time_diff = (exp_time - now).total_seconds()
|
||||
assert 3500 < time_diff < 3700 # ~60 minutes with some tolerance
|
||||
|
||||
def test_create_refresh_token(self, mock_config, temp_auth_file):
|
||||
"""Test refresh token creation."""
|
||||
data = {"sub": "testuser", "role": "member"}
|
||||
token = create_refresh_token(data)
|
||||
|
||||
assert token is not None
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
assert payload["sub"] == "testuser"
|
||||
assert payload["role"] == "member"
|
||||
assert payload["type"] == "refresh"
|
||||
assert "tv" in payload # token version
|
||||
assert "exp" in payload
|
||||
|
||||
def test_user_from_valid_access_token(self, mock_config, temp_rbac_file):
|
||||
"""Test extracting user from valid access token."""
|
||||
token = create_access_token({"sub": "testuser", "role": "admin"})
|
||||
user = user_from_access_token(token)
|
||||
|
||||
assert user.username == "testuser"
|
||||
assert user.role == "admin"
|
||||
assert user.pages == "*"
|
||||
assert user.sidebar_links == "*"
|
||||
|
||||
def test_user_from_expired_token(self, mock_config, temp_rbac_file):
|
||||
"""Test that expired token raises HTTPException."""
|
||||
token = create_access_token(
|
||||
{"sub": "testuser", "role": "admin"},
|
||||
expires_delta=timedelta(seconds=-1)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_user_from_invalid_token(self, mock_config, temp_rbac_file):
|
||||
"""Test that invalid token raises HTTPException."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token("invalid.token.here")
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_user_from_refresh_token_fails(self, mock_config, temp_rbac_file, temp_auth_file):
|
||||
"""Test that refresh token cannot be used as access token."""
|
||||
token = create_refresh_token({"sub": "testuser", "role": "admin"})
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_user_from_token_without_username(self, mock_config, temp_rbac_file):
|
||||
"""Test that token without username fails."""
|
||||
# Create token without 'sub' field
|
||||
token = jwt.encode(
|
||||
{"role": "admin", "type": "access", "exp": datetime.now(timezone.utc) + timedelta(minutes=30)},
|
||||
mock_config.SECRET_KEY,
|
||||
algorithm=mock_config.ALGORITHM
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
class TestEncryption:
|
||||
"""Test encryption and decryption functions."""
|
||||
|
||||
def test_encrypt_decrypt(self, mock_config):
|
||||
"""Test basic encryption and decryption."""
|
||||
plaintext = "my-secret-totp-key"
|
||||
encrypted = _encrypt(plaintext)
|
||||
|
||||
assert encrypted != plaintext
|
||||
assert encrypted != ""
|
||||
|
||||
decrypted = _decrypt(encrypted)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_encrypt_empty_string(self, mock_config):
|
||||
"""Test encrypting empty string."""
|
||||
encrypted = _encrypt("")
|
||||
assert encrypted == ""
|
||||
|
||||
decrypted = _decrypt("")
|
||||
assert decrypted == ""
|
||||
|
||||
def test_decrypt_invalid_ciphertext(self, mock_config):
|
||||
"""Test decrypting invalid ciphertext returns original."""
|
||||
invalid = "not-a-valid-encrypted-string"
|
||||
decrypted = _decrypt(invalid)
|
||||
|
||||
# Should fallback to returning the original string
|
||||
assert decrypted == invalid
|
||||
|
||||
def test_encrypt_decrypt_unicode(self, mock_config):
|
||||
"""Test encryption with unicode characters."""
|
||||
plaintext = "测试密钥🔐"
|
||||
encrypted = _encrypt(plaintext)
|
||||
decrypted = _decrypt(encrypted)
|
||||
|
||||
assert decrypted == plaintext
|
||||
|
||||
|
||||
class TestTOTP:
|
||||
"""Test TOTP functionality."""
|
||||
|
||||
def test_save_and_load_totp_secret(self, mock_config, temp_auth_file):
|
||||
"""Test saving and loading TOTP secret."""
|
||||
secret = "JBSWY3DPEHPK3PXP"
|
||||
save_totp_secret(secret)
|
||||
|
||||
loaded = load_totp_secret()
|
||||
assert loaded == secret
|
||||
|
||||
def test_load_totp_secret_from_env(self, mock_config, temp_auth_file, monkeypatch):
|
||||
"""Test loading TOTP secret from environment variable."""
|
||||
monkeypatch.setenv("TOTP_SECRET", "ENV_SECRET_KEY")
|
||||
import importlib
|
||||
import config
|
||||
importlib.reload(config)
|
||||
|
||||
# When no secret in file, should return env var
|
||||
loaded = load_totp_secret()
|
||||
assert loaded == "ENV_SECRET_KEY"
|
||||
|
||||
def test_verify_totp_valid_code(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test verifying valid TOTP code."""
|
||||
save_totp_secret(sample_totp_secret)
|
||||
totp = pyotp.TOTP(sample_totp_secret)
|
||||
valid_code = totp.now()
|
||||
|
||||
assert verify_totp(valid_code)
|
||||
|
||||
def test_verify_totp_invalid_code(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test verifying invalid TOTP code."""
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
assert not verify_totp("000000")
|
||||
|
||||
def test_verify_totp_no_secret_enabled(self, mock_config, temp_auth_file):
|
||||
"""Test that TOTP verification passes when 2FA not enabled."""
|
||||
# No secret saved, should return True (2FA disabled)
|
||||
assert verify_totp("any_code")
|
||||
|
||||
def test_verify_totp_replay_protection(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test that same TOTP code cannot be used twice."""
|
||||
save_totp_secret(sample_totp_secret)
|
||||
totp = pyotp.TOTP(sample_totp_secret)
|
||||
valid_code = totp.now()
|
||||
|
||||
# First use should succeed
|
||||
assert verify_totp(valid_code)
|
||||
|
||||
# Second use of same code should fail (replay protection)
|
||||
assert not verify_totp(valid_code)
|
||||
|
||||
|
||||
class TestPasswordPersistence:
|
||||
"""Test password hash persistence."""
|
||||
|
||||
def test_save_and_load_password_hash(self, mock_config, temp_auth_file):
|
||||
"""Test saving and loading password hash."""
|
||||
hashed = get_password_hash("new_password")
|
||||
save_password_hash(hashed)
|
||||
|
||||
loaded = load_password_hash()
|
||||
assert loaded == hashed
|
||||
|
||||
def test_load_password_hash_from_env(self, mock_config, temp_auth_file):
|
||||
"""Test loading password hash from environment variable."""
|
||||
# When no hash in file, should return env var
|
||||
loaded = load_password_hash()
|
||||
assert loaded == mock_config.ADMIN_PASSWORD_HASH
|
||||
|
||||
|
||||
class TestPasskeyCredentials:
|
||||
"""Test passkey credential management."""
|
||||
|
||||
def test_save_and_load_passkey_credentials(self, mock_config, temp_auth_file):
|
||||
"""Test saving and loading passkey credentials."""
|
||||
cred1 = {"id": "cred1", "public_key": "key1"}
|
||||
cred2 = {"id": "cred2", "public_key": "key2"}
|
||||
|
||||
save_passkey_credential(cred1)
|
||||
save_passkey_credential(cred2)
|
||||
|
||||
creds = load_passkey_credentials()
|
||||
assert len(creds) == 2
|
||||
assert cred1 in creds
|
||||
assert cred2 in creds
|
||||
|
||||
def test_clear_passkey_credentials(self, mock_config, temp_auth_file):
|
||||
"""Test clearing all passkey credentials."""
|
||||
save_passkey_credential({"id": "cred1"})
|
||||
save_passkey_credential({"id": "cred2"})
|
||||
|
||||
clear_passkey_credentials()
|
||||
|
||||
creds = load_passkey_credentials()
|
||||
assert len(creds) == 0
|
||||
|
||||
|
||||
class TestTokenVersion:
|
||||
"""Test token version management for refresh token rotation."""
|
||||
|
||||
def test_increment_token_version(self, mock_config, temp_auth_file):
|
||||
"""Test incrementing token version."""
|
||||
v1 = increment_token_version()
|
||||
assert v1 == 1
|
||||
|
||||
v2 = increment_token_version()
|
||||
assert v2 == 2
|
||||
|
||||
v3 = increment_token_version()
|
||||
assert v3 == 3
|
||||
|
||||
def test_verify_token_version(self, mock_config, temp_auth_file):
|
||||
"""Test verifying token version."""
|
||||
v1 = increment_token_version()
|
||||
assert verify_token_version(v1)
|
||||
|
||||
v2 = increment_token_version()
|
||||
assert verify_token_version(v2)
|
||||
assert not verify_token_version(v1) # Old version should fail
|
||||
|
||||
def test_token_version_in_refresh_token(self, mock_config, temp_auth_file):
|
||||
"""Test that refresh token includes current token version."""
|
||||
current_version = increment_token_version()
|
||||
token = create_refresh_token({"sub": "testuser"})
|
||||
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
assert payload["tv"] == current_version
|
||||
@@ -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)
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Unit tests for rbac.py - Role-based access control.
|
||||
"""
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
from rbac import (
|
||||
load_rbac,
|
||||
save_rbac,
|
||||
update_rbac,
|
||||
resolve_role,
|
||||
get_pages,
|
||||
get_sidebar_links,
|
||||
get_sidebar_order,
|
||||
DEFAULT_RBAC,
|
||||
ROLE_GROUP_MAP,
|
||||
)
|
||||
|
||||
|
||||
class TestLoadRBAC:
|
||||
"""Test loading RBAC configuration."""
|
||||
|
||||
def test_load_rbac_from_file(self, mock_config, temp_rbac_file):
|
||||
"""Test loading RBAC from existing file."""
|
||||
rbac = load_rbac()
|
||||
|
||||
assert "role_defaults" in rbac
|
||||
assert "user_overrides" in rbac
|
||||
assert "admin" in rbac["role_defaults"]
|
||||
assert "member" in rbac["role_defaults"]
|
||||
assert "viewer" in rbac["role_defaults"]
|
||||
|
||||
def test_load_rbac_missing_file(self, mock_config, temp_volume_root):
|
||||
"""Test loading RBAC when file doesn't exist returns defaults."""
|
||||
# Don't create rbac file
|
||||
rbac = load_rbac()
|
||||
|
||||
assert rbac == DEFAULT_RBAC
|
||||
|
||||
def test_load_rbac_corrupted_file(self, mock_config, temp_volume_root):
|
||||
"""Test loading RBAC with corrupted file returns defaults."""
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
os.makedirs(os.path.dirname(rbac_file), exist_ok=True)
|
||||
|
||||
# Write invalid JSON
|
||||
with open(rbac_file, "w") as f:
|
||||
f.write("invalid json {{{")
|
||||
|
||||
rbac = load_rbac()
|
||||
assert rbac == DEFAULT_RBAC
|
||||
|
||||
|
||||
class TestSaveRBAC:
|
||||
"""Test saving RBAC configuration."""
|
||||
|
||||
def test_save_rbac(self, mock_config, temp_volume_root):
|
||||
"""Test saving RBAC configuration."""
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
test_data = {
|
||||
"role_defaults": {"admin": {"pages": "*"}},
|
||||
"user_overrides": {"testuser": {"pages": ["dashboard"]}}
|
||||
}
|
||||
|
||||
save_rbac(test_data)
|
||||
|
||||
assert os.path.exists(rbac_file)
|
||||
with open(rbac_file) as f:
|
||||
loaded = json.load(f)
|
||||
assert loaded == test_data
|
||||
|
||||
def test_save_rbac_creates_directory(self, mock_config, temp_volume_root):
|
||||
"""Test that save_rbac creates directory if it doesn't exist."""
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
|
||||
# Remove directory
|
||||
import shutil
|
||||
if os.path.exists(os.path.dirname(rbac_file)):
|
||||
shutil.rmtree(os.path.dirname(rbac_file))
|
||||
|
||||
test_data = {"role_defaults": {}, "user_overrides": {}}
|
||||
save_rbac(test_data)
|
||||
|
||||
assert os.path.exists(rbac_file)
|
||||
|
||||
|
||||
class TestUpdateRBAC:
|
||||
"""Test atomic RBAC updates."""
|
||||
|
||||
def test_update_rbac_with_mutator(self, mock_config, temp_rbac_file):
|
||||
"""Test updating RBAC with mutator function."""
|
||||
def add_user_override(data):
|
||||
data["user_overrides"]["newuser"] = {"pages": ["dashboard", "docker"]}
|
||||
return "success"
|
||||
|
||||
result = update_rbac(add_user_override)
|
||||
|
||||
assert result == "success"
|
||||
|
||||
# Verify changes persisted
|
||||
rbac = load_rbac()
|
||||
assert "newuser" in rbac["user_overrides"]
|
||||
assert rbac["user_overrides"]["newuser"]["pages"] == ["dashboard", "docker"]
|
||||
|
||||
def test_update_rbac_thread_safe(self, mock_config, temp_rbac_file):
|
||||
"""Test that update_rbac is thread-safe."""
|
||||
import threading
|
||||
|
||||
results = []
|
||||
|
||||
def increment_counter(data):
|
||||
current = data.get("counter", 0)
|
||||
data["counter"] = current + 1
|
||||
return data["counter"]
|
||||
|
||||
# Run multiple updates concurrently
|
||||
threads = []
|
||||
for _ in range(10):
|
||||
t = threading.Thread(target=lambda: results.append(update_rbac(increment_counter)))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Final counter should be 10
|
||||
rbac = load_rbac()
|
||||
assert rbac.get("counter") == 10
|
||||
|
||||
|
||||
class TestResolveRole:
|
||||
"""Test role resolution from groups."""
|
||||
|
||||
def test_resolve_role_admin(self):
|
||||
"""Test resolving admin role."""
|
||||
groups = ["dashboard_admin", "other_group"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "admin"
|
||||
|
||||
def test_resolve_role_member(self):
|
||||
"""Test resolving member role."""
|
||||
groups = ["dashboard_member"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "member"
|
||||
|
||||
def test_resolve_role_viewer(self):
|
||||
"""Test resolving viewer role."""
|
||||
groups = ["dashboard_viewer", "unrelated_group"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "viewer"
|
||||
|
||||
def test_resolve_role_no_match(self):
|
||||
"""Test resolving role with no matching groups."""
|
||||
groups = ["other_group", "another_group"]
|
||||
role = resolve_role(groups)
|
||||
assert role is None
|
||||
|
||||
def test_resolve_role_empty_groups(self):
|
||||
"""Test resolving role with empty groups list."""
|
||||
role = resolve_role([])
|
||||
assert role is None
|
||||
|
||||
def test_resolve_role_priority(self):
|
||||
"""Test that first matching group is used."""
|
||||
groups = ["dashboard_admin", "dashboard_member"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "admin"
|
||||
|
||||
|
||||
class TestGetPages:
|
||||
"""Test page access resolution."""
|
||||
|
||||
def test_get_pages_admin_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for admin with default settings."""
|
||||
pages = get_pages("testuser", "admin")
|
||||
assert pages == "*"
|
||||
|
||||
def test_get_pages_member_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for member with default settings."""
|
||||
pages = get_pages("testuser", "member")
|
||||
assert isinstance(pages, list)
|
||||
assert "dashboard" in pages
|
||||
assert "docker" in pages
|
||||
|
||||
def test_get_pages_viewer_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for viewer with default settings."""
|
||||
pages = get_pages("testuser", "viewer")
|
||||
assert pages == ["dashboard"]
|
||||
|
||||
def test_get_pages_with_user_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages with user-specific override."""
|
||||
# Add user override
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"pages": ["dashboard", "files"]}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
pages = get_pages("customuser", "admin")
|
||||
assert pages == ["dashboard", "files"]
|
||||
|
||||
def test_get_pages_unknown_role(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for unknown role returns empty list."""
|
||||
pages = get_pages("testuser", "unknown_role")
|
||||
assert pages == []
|
||||
|
||||
|
||||
class TestGetSidebarLinks:
|
||||
"""Test sidebar link access resolution."""
|
||||
|
||||
def test_get_sidebar_links_admin_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links for admin."""
|
||||
links = get_sidebar_links("testuser", "admin")
|
||||
assert links == "*"
|
||||
|
||||
def test_get_sidebar_links_member_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links for member."""
|
||||
links = get_sidebar_links("testuser", "member")
|
||||
assert links == "*"
|
||||
|
||||
def test_get_sidebar_links_viewer_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links for viewer."""
|
||||
links = get_sidebar_links("testuser", "viewer")
|
||||
assert links == ["dashboard"]
|
||||
|
||||
def test_get_sidebar_links_with_user_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links with user override."""
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {
|
||||
"pages": "*",
|
||||
"sidebar_links": ["dashboard", "docker", "files"]
|
||||
}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
links = get_sidebar_links("customuser", "admin")
|
||||
assert links == ["dashboard", "docker", "files"]
|
||||
|
||||
def test_get_sidebar_links_no_override(self, mock_config, temp_rbac_file):
|
||||
"""Test that user without sidebar_links override gets role default."""
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"pages": ["dashboard"]}
|
||||
# No sidebar_links specified
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
links = get_sidebar_links("customuser", "admin")
|
||||
assert links == "*" # Should fall back to admin default
|
||||
|
||||
|
||||
class TestGetSidebarOrder:
|
||||
"""Test sidebar order retrieval."""
|
||||
|
||||
def test_get_sidebar_order_no_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar order when user has no override."""
|
||||
order = get_sidebar_order("testuser")
|
||||
assert order is None
|
||||
|
||||
def test_get_sidebar_order_with_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar order with user override."""
|
||||
custom_order = {
|
||||
"Main": ["dashboard", "docker"],
|
||||
"Media": ["navidrome", "immich"]
|
||||
}
|
||||
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"sidebar_order": custom_order}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
order = get_sidebar_order("customuser")
|
||||
assert order == custom_order
|
||||
|
||||
|
||||
class TestDefaultRBAC:
|
||||
"""Test default RBAC configuration."""
|
||||
|
||||
def test_default_rbac_structure(self):
|
||||
"""Test that DEFAULT_RBAC has correct structure."""
|
||||
assert "role_defaults" in DEFAULT_RBAC
|
||||
assert "user_overrides" in DEFAULT_RBAC
|
||||
|
||||
assert "admin" in DEFAULT_RBAC["role_defaults"]
|
||||
assert "member" in DEFAULT_RBAC["role_defaults"]
|
||||
assert "viewer" in DEFAULT_RBAC["role_defaults"]
|
||||
|
||||
def test_default_rbac_admin_permissions(self):
|
||||
"""Test admin has full permissions by default."""
|
||||
admin = DEFAULT_RBAC["role_defaults"]["admin"]
|
||||
assert admin["pages"] == "*"
|
||||
assert admin["sidebar_links"] == "*"
|
||||
|
||||
def test_default_rbac_member_permissions(self):
|
||||
"""Test member has limited permissions."""
|
||||
member = DEFAULT_RBAC["role_defaults"]["member"]
|
||||
assert isinstance(member["pages"], list)
|
||||
assert "dashboard" in member["pages"]
|
||||
assert member["sidebar_links"] == "*"
|
||||
|
||||
def test_default_rbac_viewer_permissions(self):
|
||||
"""Test viewer has minimal permissions."""
|
||||
viewer = DEFAULT_RBAC["role_defaults"]["viewer"]
|
||||
assert viewer["pages"] == ["dashboard"]
|
||||
assert viewer["sidebar_links"] == ["dashboard"]
|
||||
|
||||
|
||||
class TestRoleGroupMap:
|
||||
"""Test role group mapping."""
|
||||
|
||||
def test_role_group_map_completeness(self):
|
||||
"""Test that ROLE_GROUP_MAP has all expected mappings."""
|
||||
assert "dashboard_admin" in ROLE_GROUP_MAP
|
||||
assert "dashboard_member" in ROLE_GROUP_MAP
|
||||
assert "dashboard_viewer" in ROLE_GROUP_MAP
|
||||
|
||||
assert ROLE_GROUP_MAP["dashboard_admin"] == "admin"
|
||||
assert ROLE_GROUP_MAP["dashboard_member"] == "member"
|
||||
assert ROLE_GROUP_MAP["dashboard_viewer"] == "viewer"
|
||||
Reference in New Issue
Block a user