"""Tests for app/auth.py module.""" import asyncio import hashlib import logging 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 _TEST_CREDENTIAL = "test" # noqa: S105 @pytest.mark.unit class TestGetCurrentUser: """Tests for get_current_user function.""" def test_returns_user_from_session(self): """Test that get_current_user returns user data from session.""" mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "test_user", "name": "Test"}} result = get_current_user(mock_request) assert result == {"id": "test_user", "name": "Test"} def test_returns_none_when_no_user(self): """Test that get_current_user 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 def test_returns_none_when_user_is_none(self): """Test that get_current_user returns None when user is None.""" mock_request = MagicMock(spec=Request) mock_request.session = {"user": None} result = get_current_user(mock_request) assert result is None def test_logs_debug_when_session_user_found(self, caplog): """Test that get_current_user emits a DEBUG log when session user is found.""" mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "u1", "preferred_username": "alice"}} mock_request.state = MagicMock(spec=[]) # no api_token_user attribute with caplog.at_level(logging.DEBUG, logger="app.auth"): get_current_user(mock_request) assert any("[AUTH] get_current_user: resolved from session" in m for m in caplog.messages) def test_logs_debug_when_no_user(self, caplog): """Test that get_current_user emits a DEBUG log when no user is present.""" mock_request = MagicMock(spec=Request) mock_request.session = {} mock_request.state = MagicMock(spec=[]) with caplog.at_level(logging.DEBUG, logger="app.auth"): get_current_user(mock_request) assert any("[AUTH] get_current_user: no user in session or API token" in m for m in caplog.messages) def test_logs_debug_when_api_token_user(self, caplog): """Test that get_current_user emits a DEBUG log when resolved from API token.""" mock_request = MagicMock(spec=Request) mock_request.state.api_token_user = {"id": "tok_user"} mock_request.session = {} with caplog.at_level(logging.DEBUG, logger="app.auth"): result = get_current_user(mock_request) assert result == {"id": "tok_user"} assert any("[AUTH] get_current_user: resolved from API token" in m for m in caplog.messages) @pytest.mark.unit class TestGetGravatarUrl: """Tests for get_gravatar_url function.""" def test_generates_correct_url(self): """Test gravatar URL generation with known email.""" email = "test@example.com" expected_hash = hashlib.md5(email.lower().strip().encode("utf-8"), usedforsecurity=False).hexdigest() result = get_gravatar_url(email) assert result == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon" def test_handles_uppercase_email(self): """Test that email is lowercased.""" result_upper = get_gravatar_url("TEST@EXAMPLE.COM") result_lower = get_gravatar_url("test@example.com") assert result_upper == result_lower def test_handles_whitespace(self): """Test that whitespace is stripped.""" result_spaces = get_gravatar_url(" test@example.com ") result_clean = get_gravatar_url("test@example.com") assert result_spaces == result_clean @pytest.mark.unit class TestRequireLogin: """Tests for require_login decorator.""" def test_noop_when_auth_disabled(self): """Test that require_login is a no-op when AUTH_ENABLED is False.""" # AUTH_ENABLED is False in test environment def my_func(): return "hello" decorated = require_login(my_func) # When AUTH_ENABLED is False, the decorator returns the function unchanged assert decorated is my_func @pytest.mark.asyncio async def test_redirects_to_login_when_no_user(self): """Test that require_login redirects to /login when no user in session and auth is enabled.""" with patch("app.auth.AUTH_ENABLED", True): # Import fresh to get patched AUTH_ENABLED from app.auth import require_login @require_login async def protected_endpoint(request: Request): return {"message": "success"} mock_request = MagicMock(spec=Request) mock_request.session = {} mock_request.url = MagicMock() mock_request.url.__str__ = MagicMock(return_value="http://test.com/protected") result = await protected_endpoint(mock_request) assert isinstance(result, RedirectResponse) assert result.status_code == status.HTTP_302_FOUND assert "/login" in str(result.headers.get("location")) @pytest.mark.asyncio async def test_saves_redirect_url_when_not_authenticated(self): """Test that require_login saves the original URL in session.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login @require_login async def protected_endpoint(request: Request): return {"message": "success"} mock_request = MagicMock(spec=Request) mock_request.session = {} mock_request.url = MagicMock() original_url = "http://test.com/protected/page?param=value" mock_request.url.__str__ = MagicMock(return_value=original_url) await protected_endpoint(mock_request) assert mock_request.session.get("redirect_after_login") == original_url @pytest.mark.asyncio async def test_allows_access_when_user_in_session(self): """Test that require_login allows access when user is in session.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login @require_login async def protected_endpoint(request: Request): return {"message": "success", "user": request.session.get("user")} mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "test_user", "name": "Test"}} result = await protected_endpoint(mock_request) assert result["message"] == "success" assert result["user"]["id"] == "test_user" @pytest.mark.asyncio async def test_handles_async_functions(self): """Test that require_login correctly wraps async functions.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login @require_login async def async_endpoint(request: Request, param: str): return {"message": "async", "param": param} mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "test"}} result = await async_endpoint(mock_request, param="test_value") assert result["message"] == "async" assert result["param"] == "test_value" def test_handles_sync_functions(self): """Test that require_login correctly wraps sync functions.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login @require_login def sync_endpoint(request: Request, param: str): return {"message": "sync", "param": param} mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "test"}} # Call the decorated sync function result = asyncio.run(sync_endpoint(request=mock_request, param="test_value")) assert result["message"] == "sync" assert result["param"] == "test_value" @pytest.mark.asyncio async def test_returns_401_for_api_paths_when_not_authenticated(self): """Test that require_login returns 401 (not redirect) for /api/* paths. This prevents the /api/auth/whoami JS probe from overwriting redirect_after_login with an API URL, which would send the user to a JSON endpoint after login instead of the page they actually wanted. """ from fastapi.responses import JSONResponse with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login @require_login async def api_endpoint(request: Request): return {"message": "success"} mock_request = MagicMock(spec=Request) mock_request.session = {} mock_request.url = MagicMock() mock_request.url.__str__ = MagicMock(return_value="http://test.com/api/auth/whoami") result = await api_endpoint(mock_request) assert isinstance(result, JSONResponse) assert result.status_code == status.HTTP_401_UNAUTHORIZED # Redirect URL must NOT be stored for API paths assert "redirect_after_login" not in mock_request.session @pytest.mark.asyncio async def test_does_not_save_redirect_for_api_paths(self): """Test that redirect_after_login is never set for any /api/* request.""" from fastapi.responses import JSONResponse with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login @require_login async def api_endpoint(request: Request): return {"data": "ok"} for api_path in ["/api/documents/upload", "/api/v1/resource", "/api/users/me"]: mock_request = MagicMock(spec=Request) mock_request.session = {} mock_request.url = MagicMock() mock_request.url.__str__ = MagicMock(return_value=f"http://test.com{api_path}") result = await api_endpoint(mock_request) assert isinstance(result, JSONResponse), f"Expected JSONResponse for {api_path}" assert result.status_code == status.HTTP_401_UNAUTHORIZED assert "redirect_after_login" not in mock_request.session, ( f"redirect_after_login must not be set for {api_path}" ) @pytest.mark.asyncio async def test_path_param_before_request_async(self): """Regression: endpoints with a path param before request must not get 'multiple values for argument' when AUTH_ENABLED=True. FastAPI passes all resolved parameters as keyword arguments to the wrapper. The wrapper must forward ``request`` as a keyword argument too, otherwise the positional ``request`` object would bind to the first parameter (e.g. ``pipeline_id``) while FastAPI simultaneously supplies ``pipeline_id`` as a keyword argument → TypeError. """ with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login @require_login async def endpoint_with_path_param(pipeline_id: int, request: Request, extra: str = ""): return {"pipeline_id": pipeline_id, "extra": extra} mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "test"}} # Simulate how FastAPI calls the wrapper: all args as keyword args. result = await endpoint_with_path_param(request=mock_request, pipeline_id=42, extra="hello") assert result["pipeline_id"] == 42 assert result["extra"] == "hello" def test_path_param_before_request_sync(self): """Regression: same as above but for synchronous endpoint functions.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login @require_login def sync_endpoint_with_path_param(item_id: int, request: Request): return {"item_id": item_id} mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "test"}} result = asyncio.run(sync_endpoint_with_path_param(request=mock_request, item_id=7)) assert result["item_id"] == 7 @pytest.mark.integration class TestWhoamiEndpoint: """Tests for the /api/auth/whoami endpoint.""" def test_whoami_returns_user_or_error(self, client): """Test whoami endpoint without auth (auth disabled).""" response = client.get("/api/auth/whoami") assert response.status_code == 200 data = response.json() # When auth is disabled and no user in session, returns error dict assert "error" in data or "id" in data def test_private_endpoint(self, client): """Test /private endpoint without auth (auth disabled).""" response = client.get("/private") assert response.status_code == 200 data = response.json() assert "message" in data @pytest.mark.integration class TestAuthEndpoints: """Integration tests for authentication endpoints.""" def test_login_page_not_available_when_auth_disabled(self, client): """Test that login page returns 404 when auth is disabled (default in tests).""" # In the test environment, AUTH_ENABLED is False by default response = client.get("/login") # When auth is disabled, the auth routes are not registered assert response.status_code == 404 def test_oauth_login_not_available_when_auth_disabled(self, client): """Test that /oauth-login returns 404 when auth is disabled.""" response = client.get("/oauth-login") assert response.status_code == 404 def test_logout_not_available_when_auth_disabled(self, client): """Test that /logout returns 404 when auth is disabled.""" response = client.get("/logout") assert response.status_code == 404 def test_auth_post_not_available_when_auth_disabled(self, client): """Test that POST /auth returns 404 when auth is disabled.""" response = client.post("/auth", data={"username": "admin", "password": _TEST_CREDENTIAL}) assert response.status_code == 404 @pytest.mark.unit class TestSessionValidation: """Tests for session validation edge cases.""" def test_empty_user_object(self): """Test get_current_user with empty user object.""" mock_request = MagicMock(spec=Request) mock_request.session = {"user": {}} result = get_current_user(mock_request) assert result == {} def test_user_object_missing_id(self): """Test session with user missing id field.""" mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"name": "Test", "email": "test@example.com"}} result = get_current_user(mock_request) # Should still return the user object even if id is missing assert result["name"] == "Test" assert "id" not in result def test_user_object_with_extra_fields(self): """Test session with user having extra fields.""" mock_request = MagicMock(spec=Request) user = { "id": "123", "name": "Test", "email": "test@example.com", "is_admin": True, "groups": ["admin"], "picture": "https://example.com/pic.jpg", } mock_request.session = {"user": user} result = get_current_user(mock_request) assert result == user @pytest.mark.asyncio async def test_require_login_with_user_missing_required_fields(self): """Test require_login with user object missing typical fields.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login @require_login async def protected_endpoint(request: Request): return {"message": "success"} # User object exists but is minimal mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "123"}} # Missing name, email, etc. result = await protected_endpoint(mock_request) # Should still allow access as long as user key exists assert result["message"] == "success" @pytest.mark.unit class TestLoginFunction: """Tests for login() function.""" @pytest.mark.asyncio async def test_login_page_renders_with_params(self): """Test login page renders with query parameters.""" from app.auth import login mock_request = MagicMock(spec=Request) mock_request.query_params = {"error": "Test error", "message": "Test message"} with patch("app.auth.templates") as mock_templates: mock_templates.TemplateResponse.return_value = "rendered_template" result = await login(mock_request) # Verify TemplateResponse was called with correct context mock_templates.TemplateResponse.assert_called_once() call_args = mock_templates.TemplateResponse.call_args assert call_args[0][1] == "login.html" context = call_args.kwargs["context"] assert context["error"] == "Test error" assert context["message"] == "Test message" @pytest.mark.asyncio async def test_login_page_without_params(self): """Test login page renders without query parameters.""" from app.auth import login mock_request = MagicMock(spec=Request) mock_request.query_params = {} with patch("app.auth.templates") as mock_templates: mock_templates.TemplateResponse.return_value = "rendered_template" result = await login(mock_request) mock_templates.TemplateResponse.assert_called_once() call_args = mock_templates.TemplateResponse.call_args context = call_args.kwargs["context"] assert context["error"] is None assert context["message"] is None @pytest.mark.unit class TestOAuthLogin: """Tests for oauth_login() function.""" @pytest.mark.asyncio async def test_oauth_login_not_configured(self): """Test oauth_login redirects when OAuth is not configured.""" from app.auth import oauth_login mock_request = MagicMock(spec=Request) with patch("app.auth.OAUTH_CONFIGURED", False): result = await oauth_login(mock_request) assert isinstance(result, RedirectResponse) assert "/login?error=OAuth+not+configured" in result.headers["location"] assert result.status_code == status.HTTP_302_FOUND @pytest.mark.asyncio async def test_oauth_login_configured(self): """Test oauth_login initiates OAuth flow when configured.""" from app.auth import oauth_login mock_request = MagicMock(spec=Request) mock_request.url_for = MagicMock(return_value="http://localhost/oauth-callback") mock_authentik = MagicMock() mock_authentik.authorize_redirect = AsyncMock(return_value="oauth_redirect") with ( patch("app.auth.OAUTH_CONFIGURED", True), patch("app.auth.oauth") as mock_oauth, ): mock_oauth.authentik = mock_authentik result = await oauth_login(mock_request) assert result == "oauth_redirect" mock_authentik.authorize_redirect.assert_called_once_with(mock_request, "http://localhost/oauth-callback") @pytest.mark.unit class TestOAuthCallback: """Tests for oauth_callback() function.""" @pytest.mark.asyncio async def test_oauth_callback_success(self): """Test successful OAuth callback with user info.""" from app.auth import oauth_callback mock_request = MagicMock(spec=Request) mock_request.session = {} mock_db = MagicMock() userinfo = { "email": "test@example.com", "name": "Test User", "preferred_username": "testuser", } mock_authentik = MagicMock() mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) with ( patch("app.auth.oauth") as mock_oauth, patch("app.auth.settings") as mock_settings, patch("app.auth._ensure_user_profile"), ): mock_oauth.authentik = mock_authentik mock_settings.admin_group_name = "admin" result = await oauth_callback(mock_request, db=mock_db) assert isinstance(result, RedirectResponse) assert result.status_code == status.HTTP_302_FOUND # User should be stored in session assert "user" in mock_request.session assert mock_request.session["user"]["email"] == "test@example.com" @pytest.mark.asyncio async def test_oauth_callback_with_gravatar(self): """Test OAuth callback adds Gravatar when no picture provided.""" from app.auth import oauth_callback mock_request = MagicMock(spec=Request) mock_request.session = {} mock_db = MagicMock() userinfo = {"email": "test@example.com", "name": "Test User"} mock_authentik = MagicMock() mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) with ( patch("app.auth.oauth") as mock_oauth, patch("app.auth.settings") as mock_settings, patch("app.auth._ensure_user_profile"), ): mock_oauth.authentik = mock_authentik mock_settings.admin_group_name = "admin" result = await oauth_callback(mock_request, db=mock_db) # Gravatar should be added assert "picture" in mock_request.session["user"] assert "gravatar.com" in mock_request.session["user"]["picture"] @pytest.mark.asyncio async def test_oauth_callback_with_existing_picture(self): """Test OAuth callback preserves existing picture.""" from app.auth import oauth_callback mock_request = MagicMock(spec=Request) mock_request.session = {} mock_db = MagicMock() userinfo = { "email": "test@example.com", "name": "Test User", "picture": "https://example.com/custom-pic.jpg", } mock_authentik = MagicMock() mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) with ( patch("app.auth.oauth") as mock_oauth, patch("app.auth.settings") as mock_settings, patch("app.auth._ensure_user_profile"), ): mock_oauth.authentik = mock_authentik mock_settings.admin_group_name = "admin" result = await oauth_callback(mock_request, db=mock_db) # Custom picture should be preserved, not replaced with Gravatar assert mock_request.session["user"]["picture"] == "https://example.com/custom-pic.jpg" @pytest.mark.asyncio async def test_oauth_callback_admin_group_detection(self): """Test OAuth callback detects admin group membership.""" from app.auth import oauth_callback mock_request = MagicMock(spec=Request) mock_request.session = {} mock_db = MagicMock() userinfo = { "email": "admin@example.com", "name": "Admin User", "groups": ["admin", "users"], } mock_authentik = MagicMock() mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) with ( patch("app.auth.oauth") as mock_oauth, patch("app.auth.settings") as mock_settings, patch("app.auth._ensure_user_profile"), ): mock_oauth.authentik = mock_authentik mock_settings.admin_group_name = "admin" result = await oauth_callback(mock_request, db=mock_db) # User should be marked as admin assert mock_request.session["user"]["is_admin"] is True @pytest.mark.asyncio async def test_oauth_callback_no_admin_group(self): """Test OAuth callback without admin group membership.""" from app.auth import oauth_callback mock_request = MagicMock(spec=Request) mock_request.session = {} mock_db = MagicMock() userinfo = { "email": "user@example.com", "name": "Regular User", "groups": ["users"], } mock_authentik = MagicMock() mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) with ( patch("app.auth.oauth") as mock_oauth, patch("app.auth.settings") as mock_settings, patch("app.auth._ensure_user_profile"), ): mock_oauth.authentik = mock_authentik mock_settings.admin_group_name = "admin" result = await oauth_callback(mock_request, db=mock_db) # User should not be marked as admin assert mock_request.session["user"]["is_admin"] is False @pytest.mark.asyncio async def test_oauth_callback_no_userinfo(self): """Test OAuth callback fails when no userinfo returned.""" from app.auth import oauth_callback mock_request = MagicMock(spec=Request) mock_request.session = {} mock_db = MagicMock() mock_authentik = MagicMock() mock_authentik.authorize_access_token = AsyncMock(return_value={}) with patch("app.auth.oauth") as mock_oauth: mock_oauth.authentik = mock_authentik result = await oauth_callback(mock_request, db=mock_db) assert isinstance(result, RedirectResponse) assert "/login?error=Failed+to+retrieve+user+information" in result.headers["location"] @pytest.mark.asyncio async def test_oauth_callback_redirect_after_login(self): """Test OAuth callback redirects to saved URL.""" from app.auth import oauth_callback mock_request = MagicMock(spec=Request) mock_request.session = {"redirect_after_login": "/protected/page"} mock_db = MagicMock() userinfo = {"email": "test@example.com", "name": "Test User"} mock_authentik = MagicMock() mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) with ( patch("app.auth.oauth") as mock_oauth, patch("app.auth.settings") as mock_settings, patch("app.auth._ensure_user_profile"), ): mock_oauth.authentik = mock_authentik mock_settings.admin_group_name = "admin" result = await oauth_callback(mock_request, db=mock_db) assert isinstance(result, RedirectResponse) assert result.headers["location"] == "/protected/page" # redirect_after_login should be removed from session assert "redirect_after_login" not in mock_request.session @pytest.mark.asyncio async def test_oauth_callback_exception_handling(self): """Test OAuth callback handles exceptions gracefully.""" from app.auth import oauth_callback mock_request = MagicMock(spec=Request) mock_request.session = {} mock_db = MagicMock() mock_authentik = MagicMock() mock_authentik.authorize_access_token = AsyncMock(side_effect=Exception("OAuth error")) with patch("app.auth.oauth") as mock_oauth: mock_oauth.authentik = mock_authentik result = await oauth_callback(mock_request, db=mock_db) assert isinstance(result, RedirectResponse) assert "/login?error=Authentication+failed" in result.headers["location"] @pytest.mark.asyncio async def test_oauth_callback_creates_user_profile(self): """Test OAuth callback auto-creates a UserProfile for the authenticated user.""" from app.auth import oauth_callback mock_request = MagicMock(spec=Request) mock_request.session = {} mock_db = MagicMock() userinfo = { "sub": "oauth-sub-abc123", "email": "new@example.com", "name": "New User", "preferred_username": "newuser", } mock_authentik = MagicMock() mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) with ( patch("app.auth.oauth") as mock_oauth, patch("app.auth.settings") as mock_settings, patch("app.auth._ensure_user_profile") as mock_ensure, ): mock_oauth.authentik = mock_authentik mock_settings.admin_group_name = "admin" await oauth_callback(mock_request, db=mock_db) # _ensure_user_profile should be called with the db and user_data mock_ensure.assert_called_once() call_args = mock_ensure.call_args assert call_args[0][0] is mock_db assert call_args[0][1]["sub"] == "oauth-sub-abc123" @pytest.mark.unit class TestEnsureUserProfile: """Tests for _ensure_user_profile() helper.""" def test_creates_profile_for_new_user(self): """New user_id should insert a UserProfile row.""" from app.auth import _ensure_user_profile from app.models import UserProfile mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None user_data = { "sub": "sub-xyz", "email": "alice@example.com", "name": "Alice", "preferred_username": "alice", } _ensure_user_profile(mock_db, user_data) mock_db.add.assert_called_once() added_profile = mock_db.add.call_args[0][0] assert isinstance(added_profile, UserProfile) assert added_profile.user_id == "sub-xyz" assert added_profile.display_name == "Alice" mock_db.commit.assert_called_once() def test_skips_existing_profile(self): """Existing profile should not be overwritten.""" from app.auth import _ensure_user_profile from app.models import UserProfile mock_db = MagicMock() existing = UserProfile(user_id="sub-xyz", display_name="Old Name") mock_db.query.return_value.filter.return_value.first.return_value = existing user_data = {"sub": "sub-xyz", "name": "New Name"} _ensure_user_profile(mock_db, user_data) mock_db.add.assert_not_called() mock_db.commit.assert_not_called() def test_uses_preferred_username_fallback(self): """Falls back to preferred_username when sub is absent.""" from app.auth import _ensure_user_profile mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None user_data = {"preferred_username": "bob", "name": "Bob"} _ensure_user_profile(mock_db, user_data) added_profile = mock_db.add.call_args[0][0] assert added_profile.user_id == "bob" def test_uses_email_fallback(self): """Falls back to email when sub and preferred_username are absent.""" from app.auth import _ensure_user_profile mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None user_data = {"email": "carol@example.com", "name": "Carol"} _ensure_user_profile(mock_db, user_data) added_profile = mock_db.add.call_args[0][0] assert added_profile.user_id == "carol@example.com" def test_no_op_when_no_identifier(self): """Does nothing and logs a warning when no identifier is found.""" from app.auth import _ensure_user_profile mock_db = MagicMock() _ensure_user_profile(mock_db, {}) mock_db.add.assert_not_called() mock_db.commit.assert_not_called() def test_handles_db_exception_gracefully(self): """DB errors are caught; a rollback is issued and no exception propagates.""" from app.auth import _ensure_user_profile mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None mock_db.commit.side_effect = Exception("DB error") # Should not raise _ensure_user_profile(mock_db, {"sub": "sub-error-test"}) mock_db.rollback.assert_called_once() @pytest.mark.unit class TestAuthFunction: """Tests for auth() function (local authentication).""" def _make_mock_db(self): """Create a mock DB that returns None for LocalUser queries (no local users).""" mock_db = MagicMock() # query().filter().first() returns None → no LocalUser found mock_db.query.return_value.filter.return_value.first.return_value = None return mock_db @pytest.mark.asyncio async def test_auth_success(self): """Test successful local authentication (admin fallback).""" from app.auth import auth mock_request = MagicMock(spec=Request) form_data = {"username": "testadmin", "password": "testpass"} mock_request.form = AsyncMock(return_value=form_data) mock_request.session = {} with patch("app.auth.settings") as mock_settings: mock_settings.admin_username = "testadmin" mock_settings.admin_password = "testpass" result = await auth(mock_request, db=self._make_mock_db()) assert isinstance(result, RedirectResponse) assert result.status_code == 302 # User should be in session assert "user" in mock_request.session assert mock_request.session["user"]["is_admin"] is True assert mock_request.session["user"]["preferred_username"] == "testadmin" @pytest.mark.asyncio async def test_auth_wrong_password(self): """Test authentication with wrong password.""" from app.auth import auth mock_request = MagicMock(spec=Request) form_data = {"username": "testadmin", "password": "wrongpass"} mock_request.form = AsyncMock(return_value=form_data) mock_request.session = {} with patch("app.auth.settings") as mock_settings: mock_settings.admin_username = "testadmin" mock_settings.admin_password = "testpass" result = await auth(mock_request, db=self._make_mock_db()) assert isinstance(result, RedirectResponse) assert "/login?error=Invalid+username+or+password" in result.headers["location"] assert "user" not in mock_request.session @pytest.mark.asyncio async def test_auth_wrong_username(self): """Test authentication with wrong username.""" from app.auth import auth mock_request = MagicMock(spec=Request) form_data = {"username": "wronguser", "password": "testpass"} mock_request.form = AsyncMock(return_value=form_data) mock_request.session = {} with patch("app.auth.settings") as mock_settings: mock_settings.admin_username = "testadmin" mock_settings.admin_password = "testpass" result = await auth(mock_request, db=self._make_mock_db()) assert isinstance(result, RedirectResponse) assert "/login?error=Invalid+username+or+password" in result.headers["location"] @pytest.mark.asyncio async def test_auth_redirect_after_login(self): """Test authentication redirects to saved URL.""" from app.auth import auth mock_request = MagicMock(spec=Request) form_data = {"username": "testadmin", "password": "testpass"} mock_request.form = AsyncMock(return_value=form_data) mock_request.session = {"redirect_after_login": "/settings"} with patch("app.auth.settings") as mock_settings: mock_settings.admin_username = "testadmin" mock_settings.admin_password = "testpass" result = await auth(mock_request, db=self._make_mock_db()) assert isinstance(result, RedirectResponse) assert result.headers["location"] == "/settings" @pytest.mark.asyncio async def test_auth_none_credentials_not_configured_blocks_login(self): """Login must fail when admin_username and admin_password are None (not configured). Regression test: Python's ``None == None`` would previously evaluate to ``True``, allowing any request that omits the form fields to be authenticated as admin and creating a phantom "None@local.docuelevate" profile with full admin privileges. """ from app.auth import auth mock_request = MagicMock(spec=Request) # Form fields both absent → form_data.get() returns None form_data = {} mock_request.form = AsyncMock(return_value=form_data) mock_request.session = {} with patch("app.auth.settings") as mock_settings: mock_settings.admin_username = None mock_settings.admin_password = None mock_settings.multi_user_enabled = False result = await auth(mock_request, db=self._make_mock_db()) assert isinstance(result, RedirectResponse) assert "/login?error=Invalid+username+or+password" in result.headers["location"] assert "user" not in mock_request.session @pytest.mark.asyncio async def test_auth_empty_string_credentials_not_configured_blocks_login(self): """Login must fail when admin_username and admin_password are empty strings.""" from app.auth import auth mock_request = MagicMock(spec=Request) form_data = {"username": "", "password": ""} mock_request.form = AsyncMock(return_value=form_data) mock_request.session = {} with patch("app.auth.settings") as mock_settings: mock_settings.admin_username = "" mock_settings.admin_password = "" mock_settings.multi_user_enabled = False result = await auth(mock_request, db=self._make_mock_db()) assert isinstance(result, RedirectResponse) assert "/login?error=Invalid+username+or+password" in result.headers["location"] assert "user" not in mock_request.session @pytest.mark.asyncio async def test_auth_none_password_not_configured_blocks_login(self): """Login must fail when only admin_password is None (not configured).""" from app.auth import auth mock_request = MagicMock(spec=Request) form_data = {"username": "admin"} mock_request.form = AsyncMock(return_value=form_data) mock_request.session = {} with patch("app.auth.settings") as mock_settings: mock_settings.admin_username = "admin" mock_settings.admin_password = None mock_settings.multi_user_enabled = False result = await auth(mock_request, db=self._make_mock_db()) assert isinstance(result, RedirectResponse) assert "/login?error=Invalid+username+or+password" in result.headers["location"] assert "user" not in mock_request.session @pytest.mark.unit class TestLogoutFunction: """Tests for logout() function.""" @pytest.mark.asyncio async def test_logout_clears_session(self): """Test logout removes user from session.""" from app.auth import logout mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "123", "name": "Test"}} result = await logout(mock_request) assert isinstance(result, RedirectResponse) assert "/login?message=You+have+been+logged+out+successfully" in result.headers["location"] # User should be removed from session assert "user" not in mock_request.session @pytest.mark.asyncio async def test_logout_when_no_user(self): """Test logout when no user in session.""" from app.auth import logout mock_request = MagicMock(spec=Request) mock_request.session = {} result = await logout(mock_request) assert isinstance(result, RedirectResponse) assert result.status_code == 302 @pytest.mark.unit class TestOAuthConfiguration: """Tests for OAuth configuration logic.""" def test_oauth_configured_when_credentials_present(self): """Test OAUTH_CONFIGURED is True when credentials are present.""" with ( patch("app.auth.AUTH_ENABLED", True), patch("app.auth.settings") as mock_settings, ): mock_settings.authentik_client_id = "test-client-id" mock_settings.authentik_client_secret = "test-client-secret" # Re-import to trigger configuration logic import importlib import app.auth importlib.reload(app.auth) # OAUTH_CONFIGURED should be set based on credentials # This tests the module-level configuration logic def test_oauth_not_configured_when_credentials_missing(self): """Test OAUTH_CONFIGURED is False when credentials are missing.""" with ( patch("app.auth.AUTH_ENABLED", True), patch("app.auth.settings") as mock_settings, ): mock_settings.authentik_client_id = None mock_settings.authentik_client_secret = None # The configuration logic at module load time would set OAUTH_CONFIGURED=False