8e8a64969f
- Add test_extract_metadata_gpt.py with 16 tests (7 passing) - Add test_convert_pdf.py with 13 tests achieving 90.56% coverage - Add test_embed_pdf_metadata.py with 10 tests achieving 46.81% coverage - Add test_finalize_storage.py with 9 tests - Add test_auth_module.py with 14 tests achieving 43.48% coverage Coverage improvements: - convert_to_pdf: 10% → 90.56% - embed_metadata_into_pdf: 14.89% → 46.81% - extract_metadata_with_gpt: 21.84% → 36.78% - auth.py: 27.83% → 43.48% 43 tests passing, working on fixing remaining tests for full coverage Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
512 lines
19 KiB
Python
512 lines
19 KiB
Python
"""Comprehensive unit tests for app/auth.py module."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
from fastapi import Request, status
|
|
from starlette.responses import RedirectResponse
|
|
|
|
from app.auth import get_current_user, get_gravatar_url, require_login
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetCurrentUser:
|
|
"""Tests for get_current_user function."""
|
|
|
|
def test_returns_user_from_session(self):
|
|
"""Test returns user data from request session."""
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.session = {"user": {"id": "123", "name": "John Doe", "email": "john@example.com"}}
|
|
|
|
result = get_current_user(mock_request)
|
|
|
|
assert result == {"id": "123", "name": "John Doe", "email": "john@example.com"}
|
|
|
|
def test_returns_none_when_no_user_in_session(self):
|
|
"""Test returns None when no user in session."""
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.session = {}
|
|
|
|
result = get_current_user(mock_request)
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetGravatarUrl:
|
|
"""Tests for get_gravatar_url function."""
|
|
|
|
def test_generates_gravatar_url(self):
|
|
"""Test generates correct Gravatar URL."""
|
|
email = "test@example.com"
|
|
result = get_gravatar_url(email)
|
|
|
|
assert result.startswith("https://www.gravatar.com/avatar/")
|
|
assert "?d=identicon" in result
|
|
|
|
def test_handles_uppercase_email(self):
|
|
"""Test handles uppercase email correctly."""
|
|
email1 = "Test@Example.COM"
|
|
email2 = "test@example.com"
|
|
|
|
result1 = get_gravatar_url(email1)
|
|
result2 = get_gravatar_url(email2)
|
|
|
|
# Should produce the same hash for case-insensitive emails
|
|
assert result1 == result2
|
|
|
|
def test_strips_whitespace(self):
|
|
"""Test strips whitespace from email."""
|
|
email1 = " test@example.com "
|
|
email2 = "test@example.com"
|
|
|
|
result1 = get_gravatar_url(email1)
|
|
result2 = get_gravatar_url(email2)
|
|
|
|
assert result1 == result2
|
|
|
|
def test_hash_is_md5(self):
|
|
"""Test that the hash is MD5 (32 hexadecimal characters)."""
|
|
email = "test@example.com"
|
|
result = get_gravatar_url(email)
|
|
|
|
# Extract hash from URL
|
|
hash_part = result.split("/avatar/")[1].split("?")[0]
|
|
assert len(hash_part) == 32
|
|
assert all(c in "0123456789abcdef" for c in hash_part)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestRequireLogin:
|
|
"""Tests for require_login decorator."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allows_access_when_auth_disabled(self):
|
|
"""Test allows access when AUTH_ENABLED is False."""
|
|
with patch("app.auth.AUTH_ENABLED", False):
|
|
|
|
@require_login
|
|
async def test_endpoint(request: Request):
|
|
return {"message": "success"}
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.session = {}
|
|
|
|
result = await test_endpoint(mock_request)
|
|
|
|
assert result == {"message": "success"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allows_access_when_user_logged_in(self):
|
|
"""Test allows access when user is logged in."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
|
|
@require_login
|
|
async def test_endpoint(request: Request):
|
|
return {"message": "success"}
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.session = {"user": {"id": "123", "name": "John"}}
|
|
mock_request.url = "http://localhost/test"
|
|
|
|
result = await test_endpoint(mock_request)
|
|
|
|
assert result == {"message": "success"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redirects_to_login_when_not_authenticated(self):
|
|
"""Test redirects to login when user is not authenticated."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
|
|
@require_login
|
|
async def test_endpoint(request: Request):
|
|
return {"message": "success"}
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.session = {}
|
|
mock_request.url = "http://localhost/protected"
|
|
|
|
result = await test_endpoint(mock_request)
|
|
|
|
assert isinstance(result, RedirectResponse)
|
|
assert result.status_code == status.HTTP_302_FOUND
|
|
assert "/login" in result.headers["location"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_saves_redirect_url_in_session(self):
|
|
"""Test saves redirect URL in session before redirecting to login."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
|
|
@require_login
|
|
async def test_endpoint(request: Request):
|
|
return {"message": "success"}
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.session = {}
|
|
mock_request.url = "http://localhost/protected/page"
|
|
|
|
result = await test_endpoint(mock_request)
|
|
|
|
assert "redirect_after_login" in mock_request.session
|
|
assert mock_request.session["redirect_after_login"] == "http://localhost/protected/page"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_works_with_sync_functions(self):
|
|
"""Test decorator works with synchronous functions."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
|
|
@require_login
|
|
def test_sync_endpoint(request: Request):
|
|
return {"message": "success"}
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.session = {"user": {"id": "123"}}
|
|
mock_request.url = "http://localhost/test"
|
|
|
|
result = test_sync_endpoint(mock_request)
|
|
|
|
assert result == {"message": "success"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redirects_sync_function_when_not_authenticated(self):
|
|
"""Test redirects synchronous functions when not authenticated."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
|
|
@require_login
|
|
def test_sync_endpoint(request: Request):
|
|
return {"message": "success"}
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.session = {}
|
|
mock_request.url = "http://localhost/protected"
|
|
|
|
result = test_sync_endpoint(mock_request)
|
|
|
|
assert isinstance(result, RedirectResponse)
|
|
assert result.status_code == status.HTTP_302_FOUND
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestOAuthConfiguration:
|
|
"""Tests for OAuth configuration."""
|
|
|
|
def test_oauth_not_configured_without_credentials(self):
|
|
"""Test OAuth is not configured when credentials are missing."""
|
|
with patch("app.auth.settings") as mock_settings:
|
|
mock_settings.auth_enabled = True
|
|
mock_settings.authentik_client_id = None
|
|
mock_settings.authentik_client_secret = None
|
|
|
|
# Re-import to trigger configuration logic
|
|
import importlib
|
|
|
|
import app.auth
|
|
|
|
importlib.reload(app.auth)
|
|
|
|
from app.auth import OAUTH_CONFIGURED
|
|
|
|
assert OAUTH_CONFIGURED is False
|
|
|
|
def test_oauth_configured_with_credentials(self):
|
|
"""Test OAuth is configured when credentials are provided."""
|
|
with patch("app.auth.settings") as mock_settings:
|
|
with patch("app.auth.oauth") as mock_oauth:
|
|
mock_settings.auth_enabled = True
|
|
mock_settings.authentik_client_id = "test_client_id"
|
|
mock_settings.authentik_client_secret = "test_secret"
|
|
mock_settings.authentik_config_url = "https://auth.example.com/.well-known/openid-configuration"
|
|
mock_settings.oauth_provider_name = "Test SSO"
|
|
|
|
# Re-import to trigger configuration logic
|
|
import importlib
|
|
|
|
import app.auth
|
|
|
|
importlib.reload(app.auth)
|
|
|
|
from app.auth import OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME
|
|
|
|
assert OAUTH_CONFIGURED is True
|
|
assert OAUTH_PROVIDER_NAME == "Test SSO"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestLoginEndpoint:
|
|
"""Tests for login endpoint (when AUTH_ENABLED)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_page_shows_oauth_when_configured(self):
|
|
"""Test login page shows OAuth option when configured."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
with patch("app.auth.OAUTH_CONFIGURED", True):
|
|
with patch("app.auth.OAUTH_PROVIDER_NAME", "Test SSO"):
|
|
with patch("app.auth.templates") as mock_templates:
|
|
with patch("app.auth.settings") as mock_settings:
|
|
mock_settings.version = "1.0.0"
|
|
|
|
from app.auth import login
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.query_params.get.return_value = None
|
|
|
|
await login(mock_request)
|
|
|
|
# Verify template was rendered with OAuth enabled
|
|
mock_templates.TemplateResponse.assert_called_once()
|
|
call_args = mock_templates.TemplateResponse.call_args
|
|
context = call_args[0][1]
|
|
assert context["show_oauth"] is True
|
|
assert context["oauth_provider_name"] == "Test SSO"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestAuthEndpoint:
|
|
"""Tests for local authentication endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_successful_authentication(self):
|
|
"""Test successful local authentication."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
with patch("app.auth.settings") as mock_settings:
|
|
mock_settings.admin_username = "admin"
|
|
mock_settings.admin_password = "secret123"
|
|
|
|
from app.auth import auth
|
|
|
|
mock_request = MagicMock()
|
|
mock_form_data = {"username": "admin", "password": "secret123"}
|
|
mock_request.form = AsyncMock(return_value=mock_form_data)
|
|
mock_request.session = {}
|
|
|
|
result = await auth(mock_request)
|
|
|
|
# Verify redirect to upload page
|
|
assert isinstance(result, RedirectResponse)
|
|
assert result.status_code == 302
|
|
|
|
# Verify user was added to session
|
|
assert "user" in mock_request.session
|
|
assert mock_request.session["user"]["id"] == "admin"
|
|
assert mock_request.session["user"]["is_admin"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_authentication(self):
|
|
"""Test failed authentication with wrong credentials."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
with patch("app.auth.settings") as mock_settings:
|
|
mock_settings.admin_username = "admin"
|
|
mock_settings.admin_password = "secret123"
|
|
|
|
from app.auth import auth
|
|
|
|
mock_request = MagicMock()
|
|
mock_form_data = {"username": "admin", "password": "wrong_password"}
|
|
mock_request.form = AsyncMock(return_value=mock_form_data)
|
|
mock_request.session = {}
|
|
|
|
result = await auth(mock_request)
|
|
|
|
# Verify redirect to login with error
|
|
assert isinstance(result, RedirectResponse)
|
|
assert "error=Invalid" in result.headers["location"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authentication_with_redirect_after_login(self):
|
|
"""Test authentication redirects to saved URL after login."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
with patch("app.auth.settings") as mock_settings:
|
|
mock_settings.admin_username = "admin"
|
|
mock_settings.admin_password = "secret123"
|
|
|
|
from app.auth import auth
|
|
|
|
mock_request = MagicMock()
|
|
mock_form_data = {"username": "admin", "password": "secret123"}
|
|
mock_request.form = AsyncMock(return_value=mock_form_data)
|
|
mock_request.session = {"redirect_after_login": "/protected/page"}
|
|
|
|
result = await auth(mock_request)
|
|
|
|
# Verify redirect to saved URL
|
|
assert isinstance(result, RedirectResponse)
|
|
assert "/protected/page" in result.headers["location"]
|
|
|
|
# Verify redirect_after_login was removed from session
|
|
assert "redirect_after_login" not in mock_request.session
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestOAuthCallback:
|
|
"""Tests for OAuth callback endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_callback_not_available_when_not_configured(self):
|
|
"""Test OAuth callback returns error when OAuth not configured."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
with patch("app.auth.OAUTH_CONFIGURED", False):
|
|
from app.auth import oauth_callback, oauth_login
|
|
|
|
mock_request = MagicMock()
|
|
|
|
result = await oauth_login(mock_request)
|
|
|
|
# Should redirect to login with error
|
|
assert isinstance(result, RedirectResponse)
|
|
assert "error=OAuth+not+configured" in result.headers["location"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_callback_successful_authentication(self):
|
|
"""Test OAuth callback with successful authentication."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
with patch("app.auth.OAUTH_CONFIGURED", True):
|
|
with patch("app.auth.oauth") as mock_oauth:
|
|
with patch("app.auth.settings") as mock_settings:
|
|
mock_settings.admin_group_name = "admin"
|
|
|
|
# Mock OAuth token response
|
|
mock_token = {
|
|
"userinfo": {
|
|
"sub": "user123",
|
|
"name": "John Doe",
|
|
"email": "john@example.com",
|
|
"preferred_username": "johndoe",
|
|
"groups": ["admin", "users"],
|
|
}
|
|
}
|
|
mock_oauth.authentik.authorize_access_token = AsyncMock(return_value=mock_token)
|
|
|
|
from app.auth import oauth_callback
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.session = {}
|
|
|
|
result = await oauth_callback(mock_request)
|
|
|
|
# Verify user was added to session
|
|
assert "user" in mock_request.session
|
|
assert mock_request.session["user"]["email"] == "john@example.com"
|
|
assert mock_request.session["user"]["is_admin"] is True
|
|
|
|
# Verify redirect
|
|
assert isinstance(result, RedirectResponse)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_callback_non_admin_user(self):
|
|
"""Test OAuth callback for non-admin user."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
with patch("app.auth.OAUTH_CONFIGURED", True):
|
|
with patch("app.auth.oauth") as mock_oauth:
|
|
with patch("app.auth.settings") as mock_settings:
|
|
mock_settings.admin_group_name = "admin"
|
|
|
|
# Mock OAuth token response without admin group
|
|
mock_token = {
|
|
"userinfo": {
|
|
"sub": "user456",
|
|
"name": "Jane Doe",
|
|
"email": "jane@example.com",
|
|
"preferred_username": "janedoe",
|
|
"groups": ["users"], # Not admin
|
|
}
|
|
}
|
|
mock_oauth.authentik.authorize_access_token = AsyncMock(return_value=mock_token)
|
|
|
|
from app.auth import oauth_callback
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.session = {}
|
|
|
|
result = await oauth_callback(mock_request)
|
|
|
|
# Verify user is not admin
|
|
assert mock_request.session["user"]["is_admin"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_callback_adds_gravatar_when_no_picture(self):
|
|
"""Test OAuth callback adds Gravatar when no picture provided."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
with patch("app.auth.OAUTH_CONFIGURED", True):
|
|
with patch("app.auth.oauth") as mock_oauth:
|
|
with patch("app.auth.settings") as mock_settings:
|
|
mock_settings.admin_group_name = "admin"
|
|
|
|
# Mock OAuth token response without picture
|
|
mock_token = {
|
|
"userinfo": {
|
|
"sub": "user789",
|
|
"name": "Bob Smith",
|
|
"email": "bob@example.com",
|
|
"preferred_username": "bobsmith",
|
|
# No picture field
|
|
}
|
|
}
|
|
mock_oauth.authentik.authorize_access_token = AsyncMock(return_value=mock_token)
|
|
|
|
from app.auth import oauth_callback
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.session = {}
|
|
|
|
result = await oauth_callback(mock_request)
|
|
|
|
# Verify Gravatar was added
|
|
assert "picture" in mock_request.session["user"]
|
|
assert "gravatar.com/avatar/" in mock_request.session["user"]["picture"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestLogoutEndpoint:
|
|
"""Tests for logout endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logout_clears_session(self):
|
|
"""Test logout clears user from session."""
|
|
with patch("app.auth.AUTH_ENABLED", True):
|
|
from app.auth import logout
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.session = {"user": {"id": "123", "name": "John"}}
|
|
|
|
result = await logout(mock_request)
|
|
|
|
# Verify user was removed from session
|
|
assert "user" not in mock_request.session
|
|
|
|
# Verify redirect to login with message
|
|
assert isinstance(result, RedirectResponse)
|
|
assert "message=You+have+been+logged+out" in result.headers["location"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWhoAmIEndpoint:
|
|
"""Tests for whoami API endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_whoami_returns_user_when_authenticated(self):
|
|
"""Test whoami returns user data when authenticated."""
|
|
from app.auth import whoami
|
|
|
|
mock_request = MagicMock()
|
|
user_data = {"id": "123", "name": "John Doe", "email": "john@example.com"}
|
|
mock_request.session = {"user": user_data}
|
|
|
|
# Since require_login is applied, we need to bypass it for this test
|
|
with patch("app.auth.AUTH_ENABLED", False):
|
|
result = await whoami(mock_request)
|
|
|
|
assert result == user_data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_whoami_returns_error_when_not_authenticated(self):
|
|
"""Test whoami returns error when not authenticated."""
|
|
from app.auth import whoami
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.session = {}
|
|
|
|
with patch("app.auth.AUTH_ENABLED", False):
|
|
result = await whoami(mock_request)
|
|
|
|
assert result == {"error": "Not authenticated"}
|