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,10 +1,8 @@
|
||||
"""
|
||||
Integration tests for authentication flow.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
import pyotp
|
||||
from fastapi import status
|
||||
from auth_service import get_password_hash, save_totp_secret, save_password_hash
|
||||
|
||||
|
||||
class TestLoginFlow:
|
||||
@@ -12,14 +10,15 @@ class TestLoginFlow:
|
||||
|
||||
def test_login_success_no_2fa(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test successful login without 2FA."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
# Set up password
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -33,6 +32,8 @@ class TestLoginFlow:
|
||||
|
||||
def test_login_success_with_2fa(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test successful login with 2FA."""
|
||||
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
@@ -42,8 +43,7 @@ class TestLoginFlow:
|
||||
valid_code = totp.now()
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": valid_code}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": valid_code}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -53,13 +53,14 @@ class TestLoginFlow:
|
||||
|
||||
def test_login_wrong_password(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test login with wrong password."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
password = "correct_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": "wrong_password", "totp_code": ""}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": "wrong_password", "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -67,27 +68,29 @@ class TestLoginFlow:
|
||||
|
||||
def test_login_wrong_username(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test login with wrong username."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "wronguser", "password": password, "totp_code": ""}
|
||||
"/api/auth/login", json={"username": "wronguser", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_missing_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test login with 2FA enabled but no code provided."""
|
||||
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -95,27 +98,29 @@ class TestLoginFlow:
|
||||
|
||||
def test_login_invalid_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test login with invalid 2FA code."""
|
||||
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": "000000"}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": "000000"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_sets_cookies(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test that login sets auth cookies."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -129,10 +134,7 @@ class TestRefreshFlow:
|
||||
|
||||
def test_refresh_with_valid_token(self, test_app, mock_config, temp_auth_file, valid_refresh_token):
|
||||
"""Test refreshing with valid refresh token."""
|
||||
response = test_app.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": valid_refresh_token}
|
||||
)
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": valid_refresh_token})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -152,19 +154,13 @@ class TestRefreshFlow:
|
||||
|
||||
def test_refresh_with_invalid_token(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test refreshing with invalid token."""
|
||||
response = test_app.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": "invalid.token.here"}
|
||||
)
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": "invalid.token.here"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_refresh_with_expired_token(self, test_app, mock_config, temp_auth_file, expired_access_token):
|
||||
"""Test refreshing with expired token."""
|
||||
response = test_app.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": expired_access_token}
|
||||
)
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": expired_access_token})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -177,10 +173,7 @@ class TestRefreshFlow:
|
||||
|
||||
def test_refresh_with_access_token_fails(self, test_app, mock_config, temp_auth_file, valid_access_token):
|
||||
"""Test that access token cannot be used for refresh."""
|
||||
response = test_app.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": valid_access_token}
|
||||
)
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": valid_access_token})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid token type" in response.json()["detail"]
|
||||
@@ -241,11 +234,7 @@ class TestProxyAuth:
|
||||
def test_proxy_auth_from_trusted_source(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test proxy auth from trusted proxy."""
|
||||
response = test_app.get(
|
||||
"/api/auth/proxy",
|
||||
headers={
|
||||
"Remote-User": "proxyuser",
|
||||
"Remote-Groups": "dashboard_admin"
|
||||
}
|
||||
"/api/auth/proxy", headers={"Remote-User": "proxyuser", "Remote-Groups": "dashboard_admin"}
|
||||
)
|
||||
|
||||
# Note: This test may need adjustment based on actual proxy auth implementation
|
||||
@@ -256,11 +245,7 @@ class TestProxyAuth:
|
||||
"""Test proxy auth from untrusted source."""
|
||||
# Mock request from untrusted IP
|
||||
response = test_app.get(
|
||||
"/api/auth/proxy",
|
||||
headers={
|
||||
"Remote-User": "proxyuser",
|
||||
"Remote-Groups": "dashboard_admin"
|
||||
}
|
||||
"/api/auth/proxy", headers={"Remote-User": "proxyuser", "Remote-Groups": "dashboard_admin"}
|
||||
)
|
||||
|
||||
# Should reject untrusted proxy
|
||||
@@ -272,6 +257,8 @@ class TestPasswordChange:
|
||||
|
||||
def test_change_password_success(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test successful password change."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
old_password = "old_password"
|
||||
new_password = "new_password_123"
|
||||
|
||||
@@ -282,16 +269,15 @@ class TestPasswordChange:
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
headers=admin_headers,
|
||||
json={
|
||||
"current_password": old_password,
|
||||
"new_password": new_password
|
||||
}
|
||||
json={"current_password": old_password, "new_password": new_password},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_change_password_wrong_old_password(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test password change with wrong old password."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
old_password = "old_password"
|
||||
hashed = get_password_hash(old_password)
|
||||
save_password_hash(hashed)
|
||||
@@ -299,22 +285,233 @@ class TestPasswordChange:
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
headers=admin_headers,
|
||||
json={
|
||||
"current_password": "wrong_password",
|
||||
"new_password": "new_password_123"
|
||||
}
|
||||
json={"current_password": "wrong_password", "new_password": "new_password_123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_change_password_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test password change without authentication."""
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
json={
|
||||
"current_password": "old",
|
||||
"new_password": "new"
|
||||
}
|
||||
)
|
||||
response = test_app.post("/api/auth/change-password", json={"current_password": "old", "new_password": "new"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_change_password_too_short(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test password change with password too short."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
old_password = "old_password"
|
||||
hashed = get_password_hash(old_password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
headers=admin_headers,
|
||||
json={"current_password": old_password, "new_password": "short"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "at least 8 characters" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestRBACConfig:
|
||||
"""Test RBAC configuration endpoints."""
|
||||
|
||||
def test_get_rbac_config_as_admin(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test getting RBAC config as admin."""
|
||||
response = test_app.get("/api/auth/rbac/config", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "role_defaults" in data or "user_overrides" in data
|
||||
|
||||
def test_get_rbac_config_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot get RBAC config."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.get("/api/auth/rbac/config", headers=headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_get_rbac_config_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test getting RBAC config without authentication."""
|
||||
response = test_app.get("/api/auth/rbac/config")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_set_user_override(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test setting user page override."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/overrides/testuser", headers=admin_headers, json={"pages": ["overview", "docker"]}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_set_user_override_missing_pages(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test setting user override without pages field."""
|
||||
response = test_app.put("/api/auth/rbac/overrides/testuser", headers=admin_headers, json={})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing pages field" in response.json()["detail"]
|
||||
|
||||
def test_set_user_override_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot set user override."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.put("/api/auth/rbac/overrides/testuser", headers=headers, json={"pages": ["overview"]})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_delete_user_override(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test deleting user override."""
|
||||
# First set an override
|
||||
test_app.put("/api/auth/rbac/overrides/testuser", headers=admin_headers, json={"pages": ["overview"]})
|
||||
|
||||
# Then delete it
|
||||
response = test_app.delete("/api/auth/rbac/overrides/testuser", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_delete_user_override_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot delete user override."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.delete("/api/auth/rbac/overrides/testuser", headers=headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_update_role_config(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test updating role configuration."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/roles/member",
|
||||
headers=admin_headers,
|
||||
json={"pages": ["overview", "docker"], "sidebar_links": ["navidrome", "jellyfin"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_update_role_config_with_wildcard(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test updating role with wildcard pages."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/roles/admin", headers=admin_headers, json={"pages": "*", "sidebar_links": "*"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_update_role_config_missing_pages(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test updating role without pages field."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/roles/member", headers=admin_headers, json={"sidebar_links": ["navidrome"]}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing pages field" in response.json()["detail"]
|
||||
|
||||
def test_update_role_config_invalid_pages_type(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test updating role with invalid pages type."""
|
||||
response = test_app.put("/api/auth/rbac/roles/member", headers=admin_headers, json={"pages": "invalid"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "pages must be" in response.json()["detail"]
|
||||
|
||||
def test_update_role_config_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot update role config."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.put("/api/auth/rbac/roles/member", headers=headers, json={"pages": ["overview"]})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestPreferences:
|
||||
"""Test user preferences endpoints."""
|
||||
|
||||
def test_get_preferences(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test getting user preferences."""
|
||||
response = test_app.get("/api/auth/preferences", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "sidebar_order" in data
|
||||
|
||||
def test_get_preferences_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test getting preferences without authentication."""
|
||||
response = test_app.get("/api/auth/preferences")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_save_preferences(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test saving user preferences."""
|
||||
response = test_app.put(
|
||||
"/api/auth/preferences",
|
||||
headers=admin_headers,
|
||||
json={"sidebar_order": {"main": ["overview", "docker", "files"], "media": ["navidrome", "jellyfin"]}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_save_preferences_missing_sidebar_order(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test saving preferences without sidebar_order."""
|
||||
response = test_app.put("/api/auth/preferences", headers=admin_headers, json={})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing sidebar_order" in response.json()["detail"]
|
||||
|
||||
def test_save_preferences_invalid_type(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test saving preferences with invalid type."""
|
||||
response = test_app.put("/api/auth/preferences", headers=admin_headers, json={"sidebar_order": "invalid"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing sidebar_order dict" in response.json()["detail"]
|
||||
|
||||
def test_save_preferences_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test saving preferences without authentication."""
|
||||
response = test_app.put("/api/auth/preferences", json={"sidebar_order": {}})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user