"""Comprehensive unit tests for app/auth.py module.""" from unittest.mock import AsyncMock, MagicMock, 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 = await 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 = await 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.""" import importlib import app.auth try: with patch("app.config.settings") as mock_settings: mock_settings.auth_enabled = True mock_settings.authentik_client_id = None mock_settings.authentik_client_secret = None importlib.reload(app.auth) assert app.auth.OAUTH_CONFIGURED is False finally: importlib.reload(app.auth) def test_oauth_configured_with_credentials(self): """Test OAuth is configured when credentials are provided.""" import importlib import app.auth try: with patch("app.config.settings") as mock_settings: 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" importlib.reload(app.auth) assert app.auth.OAUTH_CONFIGURED is True assert app.auth.OAUTH_PROVIDER_NAME == "Test SSO" finally: importlib.reload(app.auth) @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" mock_settings.multi_user_enabled = False 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 = {} mock_db = MagicMock() with patch("app.auth._ensure_user_profile"): result = await auth(mock_request, db=mock_db) # 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" mock_settings.multi_user_enabled = False 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 = {} mock_db = MagicMock() result = await auth(mock_request, db=mock_db) # 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" mock_settings.multi_user_enabled = False 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"} mock_db = MagicMock() with patch("app.auth._ensure_user_profile"): result = await auth(mock_request, db=mock_db) # 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_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"}