feat: comprehensive test infrastructure improvements
- Fix unit test imports: add env setup in conftest.py before module imports - Add 24 new auth router tests (RBAC, preferences, password validation) - Add 16 new tests for litellm and chat_summary routers - Apply black formatting and ruff linting across codebase - Add pre-commit hooks configuration (black, ruff, file checks) - Increase CI coverage threshold from 40% to 50% Test Results: - 206 tests passing (91 unit + 115 integration) - Coverage: 58.79% on core modules - auth.py: 57% → 85%, litellm.py: 23% → 87%, chat_summary.py: 41% → 100% - auth_service: 96.51%, config: 100%, rbac: 93.48%
This commit is contained in:
@@ -1,30 +1,33 @@
|
||||
"""
|
||||
Unit tests for auth.py - JWT, TOTP, password hashing, encryption.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pyotp
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from auth_service import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
_decrypt,
|
||||
_encrypt,
|
||||
clear_passkey_credentials,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
user_from_access_token,
|
||||
verify_totp,
|
||||
load_totp_secret,
|
||||
save_totp_secret,
|
||||
load_password_hash,
|
||||
save_password_hash,
|
||||
get_password_hash,
|
||||
increment_token_version,
|
||||
verify_token_version,
|
||||
_encrypt,
|
||||
_decrypt,
|
||||
load_passkey_credentials,
|
||||
load_password_hash,
|
||||
load_totp_secret,
|
||||
save_passkey_credential,
|
||||
clear_passkey_credentials,
|
||||
save_password_hash,
|
||||
save_totp_secret,
|
||||
user_from_access_token,
|
||||
verify_password,
|
||||
verify_token_version,
|
||||
verify_totp,
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
@@ -79,8 +82,8 @@ class TestJWTTokens:
|
||||
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)
|
||||
exp_time = datetime.fromtimestamp(payload["exp"], tz=UTC)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Should expire in approximately 60 minutes
|
||||
time_diff = (exp_time - now).total_seconds()
|
||||
@@ -111,10 +114,7 @@ class TestJWTTokens:
|
||||
|
||||
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)
|
||||
)
|
||||
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)
|
||||
@@ -141,9 +141,9 @@ class TestJWTTokens:
|
||||
"""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)},
|
||||
{"role": "admin", "type": "access", "exp": datetime.now(UTC) + timedelta(minutes=30)},
|
||||
mock_config.SECRET_KEY,
|
||||
algorithm=mock_config.ALGORITHM
|
||||
algorithm=mock_config.ALGORITHM,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -209,7 +209,9 @@ class TestTOTP:
|
||||
|
||||
monkeypatch.setenv("TOTP_SECRET", "ENV_SECRET_KEY")
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
|
||||
# When no secret in file, should return env var
|
||||
@@ -237,7 +239,9 @@ class TestTOTP:
|
||||
# Also clear environment variable fallback
|
||||
monkeypatch.setenv("TOTP_SECRET", "")
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
# No secret saved, should return True (2FA disabled)
|
||||
assert verify_totp("any_code")
|
||||
@@ -248,10 +252,11 @@ class TestTOTP:
|
||||
|
||||
# Ensure auth file is properly initialized with last_totp_ts
|
||||
import json
|
||||
with open(temp_auth_file, 'r') as f:
|
||||
|
||||
with open(temp_auth_file) as f:
|
||||
data = json.load(f)
|
||||
data['last_totp_ts'] = 0
|
||||
with open(temp_auth_file, 'w') as f:
|
||||
data["last_totp_ts"] = 0
|
||||
with open(temp_auth_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
totp = pyotp.TOTP(sample_totp_secret)
|
||||
@@ -280,15 +285,18 @@ class TestPasswordPersistence:
|
||||
"""Test loading password hash from environment variable."""
|
||||
# Clear any hash in the file first
|
||||
import json
|
||||
with open(temp_auth_file, 'r') as f:
|
||||
|
||||
with open(temp_auth_file) as f:
|
||||
data = json.load(f)
|
||||
data['password_hash'] = ""
|
||||
with open(temp_auth_file, 'w') as f:
|
||||
data["password_hash"] = ""
|
||||
with open(temp_auth_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
# Reload auth_service to clear any cached data
|
||||
import auth_service
|
||||
import importlib
|
||||
|
||||
import auth_service
|
||||
|
||||
importlib.reload(auth_service)
|
||||
|
||||
# When no hash in file, should return env var
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"""
|
||||
Unit tests for config.py - IP parsing, environment validation.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from config import (
|
||||
_parse_ip,
|
||||
_ip_matches_ranges,
|
||||
_parse_ip,
|
||||
get_client_ip,
|
||||
get_forwarded_client_ip,
|
||||
is_lan_ip,
|
||||
is_tailscale_ip,
|
||||
is_trusted_proxy,
|
||||
get_forwarded_client_ip,
|
||||
get_client_ip,
|
||||
)
|
||||
|
||||
|
||||
@@ -146,15 +149,15 @@ class TestIsTailscaleIP:
|
||||
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("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
|
||||
assert is_tailscale_ip("100.64.0.0") # Start of range
|
||||
assert is_tailscale_ip("100.127.255.255") # End of range
|
||||
|
||||
|
||||
class TestIsTrustedProxy:
|
||||
@@ -283,8 +286,10 @@ class TestConfigValidation:
|
||||
monkeypatch.setenv("SECRET_KEY", "short")
|
||||
|
||||
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
||||
import config
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
|
||||
def test_secret_key_empty(self, monkeypatch):
|
||||
@@ -292,6 +297,8 @@ class TestConfigValidation:
|
||||
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
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
"""
|
||||
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,
|
||||
DEFAULT_RBAC,
|
||||
ROLE_GROUP_MAP,
|
||||
get_pages,
|
||||
get_sidebar_links,
|
||||
get_sidebar_order,
|
||||
DEFAULT_RBAC,
|
||||
ROLE_GROUP_MAP,
|
||||
load_rbac,
|
||||
resolve_role,
|
||||
save_rbac,
|
||||
update_rbac,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +59,7 @@ class TestSaveRBAC:
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
test_data = {
|
||||
"role_defaults": {"admin": {"pages": "*"}},
|
||||
"user_overrides": {"testuser": {"pages": ["dashboard"]}}
|
||||
"user_overrides": {"testuser": {"pages": ["dashboard"]}},
|
||||
}
|
||||
|
||||
save_rbac(test_data)
|
||||
@@ -74,6 +75,7 @@ class TestSaveRBAC:
|
||||
|
||||
# Remove directory
|
||||
import shutil
|
||||
|
||||
if os.path.exists(os.path.dirname(rbac_file)):
|
||||
shutil.rmtree(os.path.dirname(rbac_file))
|
||||
|
||||
@@ -88,6 +90,7 @@ class TestUpdateRBAC:
|
||||
|
||||
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"
|
||||
@@ -188,6 +191,7 @@ class TestGetPages:
|
||||
|
||||
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"]}
|
||||
@@ -223,11 +227,9 @@ class TestGetSidebarLinks:
|
||||
|
||||
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"]
|
||||
}
|
||||
data["user_overrides"]["customuser"] = {"pages": "*", "sidebar_links": ["dashboard", "docker", "files"]}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
@@ -236,6 +238,7 @@ class TestGetSidebarLinks:
|
||||
|
||||
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
|
||||
@@ -256,10 +259,7 @@ class TestGetSidebarOrder:
|
||||
|
||||
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"]
|
||||
}
|
||||
custom_order = {"Main": ["dashboard", "docker"], "Media": ["navidrome", "immich"]}
|
||||
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"sidebar_order": custom_order}
|
||||
|
||||
Reference in New Issue
Block a user