From 9241b9df5c379d33237329c6de8b63b85d66bd15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 17:15:15 +0000 Subject: [PATCH 1/2] Initial plan From e5feee5aae5c0a0ae8e30e8c24a92a29dec4223c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 17:47:46 +0000 Subject: [PATCH 2/2] feat(tests): improve app/auth.py coverage from 70% to 97.74% Add tests/test_auth_extended.py with 76 new unit tests covering previously uncovered paths in app/auth.py: - get_current_user server-side session validation (valid/invalid/exception) - _resolve_bearer_user: expiry, IP tracking, fallback paths, exceptions - get_current_user_id: all fallback paths - require_login: Bearer token on /api/ endpoints (async, sync, db exception) - login(): mobile redirect with docuelevate://, exp://, invalid schemes - social_login(): unknown provider, missing OAuth client, success - _normalize_social_userinfo(): Dropbox, Google/OIDC, edge cases - social_callback(): unknown provider, no email, userinfo from endpoint, session token exception, mobile redirect, onboarding redirect - _ensure_user_profile(): admin profile complimentary/tier upgrades - oauth_callback(): session token, mobile redirect, onboarding redirect - _record_login_event(): exception swallowing, success/failure events - _create_mobile_redirect(): no URI, no owner, success, commit exception - auth() local user: inactive, wrong pw, success, mobile, session exception - auth() admin: session exception, mobile redirect - logout(): session revocation, invalid token, revoke exception Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/e3d34af2-17b3-4285-b68d-3b8949764864 --- tests/test_auth_extended.py | 1801 +++++++++++++++++++++++++++++++++++ 1 file changed, 1801 insertions(+) create mode 100644 tests/test_auth_extended.py diff --git a/tests/test_auth_extended.py b/tests/test_auth_extended.py new file mode 100644 index 00000000..765b6672 --- /dev/null +++ b/tests/test_auth_extended.py @@ -0,0 +1,1801 @@ +"""Extended tests for app/auth.py to improve coverage. + +Covers: +- get_current_user with server-side session validation +- _resolve_bearer_user function +- get_current_user_id +- require_login Bearer token paths +- login() mobile redirect handling +- social_login() function +- _normalize_social_userinfo() function +- social_callback() function +- _ensure_user_profile admin update logic +- oauth_callback session token + mobile/onboarding paths +- _record_login_event exception handling +- _create_mobile_redirect() function +- auth() local user paths and multi-user mode +- auth() admin mobile redirect +- logout() session token revocation +""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import Request +from starlette.responses import RedirectResponse + +# --------------------------------------------------------------------------- +# get_current_user — server-side session validation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetCurrentUserSessionValidation: + """Tests for get_current_user with server-side session tokens.""" + + def test_valid_server_session_returns_user(self): + """Valid _session_token should keep the user in session and return them.""" + from app.auth import get_current_user + + mock_request = MagicMock(spec=Request) + user = {"id": "u1", "preferred_username": "alice"} + mock_request.session = {"user": user, "_session_token": "valid-token"} + + mock_session_obj = MagicMock() # truthy — session is valid + + with ( + patch("app.database.SessionLocal") as mock_session_local, + patch("app.utils.session_manager.validate_session", return_value=mock_session_obj), + ): + mock_db = MagicMock() + mock_session_local.return_value = mock_db + + result = get_current_user(mock_request) + + assert result == user + + def test_invalid_server_session_clears_user(self): + """When validate_session returns None the session is cleared and None is returned.""" + from app.auth import get_current_user + + mock_request = MagicMock(spec=Request) + user = {"id": "u1", "preferred_username": "alice"} + mock_request.session = {"user": user, "_session_token": "expired-token"} + + with ( + patch("app.database.SessionLocal") as mock_session_local, + patch("app.utils.session_manager.validate_session", return_value=None), + ): + mock_db = MagicMock() + mock_session_local.return_value = mock_db + + result = get_current_user(mock_request) + + assert result is None + assert "user" not in mock_request.session + assert "_session_token" not in mock_request.session + + def test_session_validation_exception_returns_user(self): + """If validate_session raises, the error is swallowed and the user is returned.""" + from app.auth import get_current_user + + mock_request = MagicMock(spec=Request) + user = {"id": "u1", "preferred_username": "alice"} + mock_request.session = {"user": user, "_session_token": "token"} + + with ( + patch("app.database.SessionLocal") as mock_session_local, + patch("app.utils.session_manager.validate_session", side_effect=RuntimeError("db down")), + ): + mock_db = MagicMock() + mock_session_local.return_value = mock_db + + result = get_current_user(mock_request) + + # Exception must be swallowed; the user is still returned + assert result == user + + +# --------------------------------------------------------------------------- +# _resolve_bearer_user +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestResolveBearerUser: + """Tests for _resolve_bearer_user().""" + + def _make_db(self, token_obj=None): + """Return a mock db whose query chain yields *token_obj*.""" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = token_obj + return mock_db + + def _make_request(self, auth_header: str = "", client_ip: str = "1.2.3.4"): + """Return a mock request with the given Authorization header.""" + mock_request = MagicMock(spec=Request) + mock_request.headers = {"authorization": auth_header, "x-forwarded-for": client_ip} + mock_request.client = MagicMock() + mock_request.client.host = client_ip + return mock_request + + def test_no_bearer_header_returns_none(self): + """Missing Authorization header returns None.""" + from app.auth import _resolve_bearer_user + + result = _resolve_bearer_user(self._make_request(), self._make_db()) + assert result is None + + def test_wrong_scheme_returns_none(self): + """Authorization header not starting with 'Bearer ' returns None.""" + from app.auth import _resolve_bearer_user + + result = _resolve_bearer_user(self._make_request("Basic abc123"), self._make_db()) + assert result is None + + def test_empty_token_after_prefix_returns_none(self): + """'Bearer ' with empty token returns None.""" + from app.auth import _resolve_bearer_user + + result = _resolve_bearer_user(self._make_request("Bearer "), self._make_db()) + assert result is None + + def test_no_matching_token_returns_none(self): + """Token hash not found in DB returns None.""" + from app.auth import _resolve_bearer_user + + with patch("app.api.api_tokens.hash_token", return_value="deadbeef"): + result = _resolve_bearer_user(self._make_request("Bearer mytoken"), self._make_db(None)) + + assert result is None + + def test_valid_token_returns_user_dict(self): + """Valid active token returns synthetic user dict.""" + from app.auth import _resolve_bearer_user + + mock_token = MagicMock() + mock_token.id = 1 + mock_token.owner_id = "alice@example.com" + mock_token.expires_at = None + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(self._make_request("Bearer plaintext"), mock_db) + + assert result is not None + assert result["id"] == "alice@example.com" + assert result["email"] == "alice@example.com" + assert result["is_admin"] is False + assert result["_api_token_id"] == 1 + + def test_expired_token_returns_none(self): + """Token past its expiry datetime is rejected.""" + from app.auth import _resolve_bearer_user + + expired = datetime(2020, 1, 1, tzinfo=timezone.utc) + mock_token = MagicMock() + mock_token.id = 2 + mock_token.owner_id = "bob@example.com" + mock_token.expires_at = expired + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(self._make_request("Bearer plaintext"), mock_db) + + assert result is None + + def test_expired_token_naive_datetime_returns_none(self): + """Token with timezone-naive expires_at in the past is rejected.""" + from app.auth import _resolve_bearer_user + + # naive datetime far in the past + expired_naive = datetime(2020, 1, 1) # no tzinfo + mock_token = MagicMock() + mock_token.id = 3 + mock_token.owner_id = "carol@example.com" + mock_token.expires_at = expired_naive + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(self._make_request("Bearer plaintext"), mock_db) + + assert result is None + + def test_future_expiry_token_accepted(self): + """Token with future expires_at is accepted.""" + from app.auth import _resolve_bearer_user + + future = datetime(2099, 1, 1, tzinfo=timezone.utc) + mock_token = MagicMock() + mock_token.id = 4 + mock_token.owner_id = "dave@example.com" + mock_token.expires_at = future + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(self._make_request("Bearer plaintext"), mock_db) + + assert result is not None + assert result["id"] == "dave@example.com" + + def test_update_tracking_exception_still_returns_user(self): + """If db.commit() fails during usage tracking, the token user is still returned.""" + from app.auth import _resolve_bearer_user + + mock_token = MagicMock() + mock_token.id = 5 + mock_token.owner_id = "eve@example.com" + mock_token.expires_at = None + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + mock_db.commit.side_effect = Exception("DB write error") + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(self._make_request("Bearer plaintext"), mock_db) + + assert result is not None + assert result["id"] == "eve@example.com" + mock_db.rollback.assert_called_once() + + def test_x_forwarded_for_header_used_for_ip(self): + """IP is extracted from X-Forwarded-For when present.""" + from app.auth import _resolve_bearer_user + + mock_token = MagicMock() + mock_token.id = 6 + mock_token.owner_id = "frank@example.com" + mock_token.expires_at = None + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + mock_request = MagicMock(spec=Request) + mock_request.headers = { + "authorization": "Bearer plaintoken", + "x-forwarded-for": "10.0.0.1, 192.168.1.1", + } + mock_request.client = MagicMock() + mock_request.client.host = "127.0.0.1" + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(mock_request, mock_db) + + assert result is not None + assert mock_token.last_used_ip == "10.0.0.1" + + def test_no_client_and_no_forwarded_for(self): + """Falls back to None IP when no forwarded-for and no client.""" + from app.auth import _resolve_bearer_user + + mock_token = MagicMock() + mock_token.id = 7 + mock_token.owner_id = "grace@example.com" + mock_token.expires_at = None + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + mock_request = MagicMock(spec=Request) + mock_request.headers = {"authorization": "Bearer plaintoken", "x-forwarded-for": ""} + mock_request.client = None # no client + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(mock_request, mock_db) + + assert result is not None + assert mock_token.last_used_ip is None + + def test_no_forwarded_for_but_client_exists(self): + """Falls back to request.client.host when x-forwarded-for is absent/empty.""" + from app.auth import _resolve_bearer_user + + mock_token = MagicMock() + mock_token.id = 8 + mock_token.owner_id = "holly@example.com" + mock_token.expires_at = None + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + mock_request = MagicMock(spec=Request) + mock_request.headers = {"authorization": "Bearer plaintoken", "x-forwarded-for": ""} + mock_client = MagicMock() + mock_client.host = "10.10.10.1" + mock_request.client = mock_client # client exists + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(mock_request, mock_db) + + assert result is not None + assert mock_token.last_used_ip == "10.10.10.1" + + +# --------------------------------------------------------------------------- +# get_current_user_id +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetCurrentUserId: + """Tests for get_current_user_id().""" + + def test_returns_anonymous_when_no_user(self): + """Returns 'anonymous' when there is no session user.""" + from app.auth import get_current_user_id + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.state = MagicMock(spec=[]) + + result = get_current_user_id(mock_request) + assert result == "anonymous" + + def test_returns_preferred_username(self): + """Returns preferred_username when available.""" + from app.auth import get_current_user_id + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"preferred_username": "alice", "email": "a@e.com", "id": "1"}} + + result = get_current_user_id(mock_request) + assert result == "alice" + + def test_falls_back_to_email(self): + """Falls back to email when preferred_username is absent.""" + from app.auth import get_current_user_id + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"email": "bob@example.com", "id": "2"}} + + result = get_current_user_id(mock_request) + assert result == "bob@example.com" + + def test_falls_back_to_id(self): + """Falls back to id when preferred_username and email are absent.""" + from app.auth import get_current_user_id + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"id": "user-123"}} + + result = get_current_user_id(mock_request) + assert result == "user-123" + + def test_returns_anonymous_when_all_fields_missing(self): + """Returns 'anonymous' when user dict has no identifier fields.""" + from app.auth import get_current_user_id + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"name": "No Fields"}} + + result = get_current_user_id(mock_request) + assert result == "anonymous" + + +# --------------------------------------------------------------------------- +# require_login — Bearer token paths +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestRequireLoginBearer: + """Tests for require_login Bearer token authentication on /api/ paths.""" + + @pytest.mark.asyncio + async def test_bearer_token_auth_allows_async_endpoint(self): + """Valid Bearer token authenticates an async /api/ endpoint.""" + with patch("app.auth.AUTH_ENABLED", True): + from app.auth import require_login + + @require_login + async def api_endpoint(request: Request): + return {"user": request.state.api_token_user} + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.url = MagicMock() + mock_request.url.__str__ = MagicMock(return_value="http://test.com/api/files") + + api_user = {"id": "tok_user", "email": "tok_user"} + + with ( + patch("app.database.SessionLocal") as mock_sl, + patch("app.auth._resolve_bearer_user", return_value=api_user), + ): + mock_db = MagicMock() + mock_sl.return_value = mock_db + + result = await api_endpoint(mock_request) + + assert result["user"] == api_user + assert mock_request.state.api_token_user == api_user + + @pytest.mark.asyncio + async def test_bearer_token_auth_allows_sync_endpoint(self): + """Valid Bearer token authenticates a sync /api/ endpoint.""" + with patch("app.auth.AUTH_ENABLED", True): + from app.auth import require_login + + @require_login + def api_endpoint(request: Request): + return {"user": request.state.api_token_user} + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.url = MagicMock() + mock_request.url.__str__ = MagicMock(return_value="http://test.com/api/upload") + + api_user = {"id": "tok_user_sync", "email": "tok_user_sync"} + + with ( + patch("app.database.SessionLocal") as mock_sl, + patch("app.auth._resolve_bearer_user", return_value=api_user), + ): + mock_db = MagicMock() + mock_sl.return_value = mock_db + + result = await api_endpoint(mock_request) + + assert result["user"] == api_user + + @pytest.mark.asyncio + async def test_bearer_db_exception_returns_401(self): + """Exception during db setup falls back to 401 for /api/ paths.""" + 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 {"ok": True} + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.url = MagicMock() + mock_request.url.__str__ = MagicMock(return_value="http://test.com/api/files") + + with patch("app.database.SessionLocal", side_effect=Exception("db error")): + result = await api_endpoint(mock_request) + + assert isinstance(result, JSONResponse) + assert result.status_code == 401 + + +# --------------------------------------------------------------------------- +# login() — mobile redirect handling +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLoginMobileRedirect: + """Tests for the mobile redirect handling inside login().""" + + @pytest.mark.asyncio + async def test_mobile_flag_with_docuelevate_scheme_stores_uri(self): + """mobile=1 with a docuelevate:// redirect_uri stores it in the session.""" + from app.auth import login + + mock_request = MagicMock(spec=Request) + mock_request.query_params = {"mobile": "1", "redirect_uri": "docuelevate://callback"} + mock_request.session = {} + + with patch("app.auth.templates") as mock_tpl: + mock_tpl.TemplateResponse.return_value = "page" + await login(mock_request) + + assert mock_request.session.get("mobile_redirect_uri") == "docuelevate://callback" + + @pytest.mark.asyncio + async def test_mobile_flag_with_exp_scheme_stores_uri(self): + """mobile=1 with an exp:// redirect_uri (Expo Go) stores it in the session.""" + from app.auth import login + + mock_request = MagicMock(spec=Request) + mock_request.query_params = {"mobile": "1", "redirect_uri": "exp://192.168.1.1:19000/--/auth"} + mock_request.session = {} + + with patch("app.auth.templates") as mock_tpl: + mock_tpl.TemplateResponse.return_value = "page" + await login(mock_request) + + assert mock_request.session.get("mobile_redirect_uri") == "exp://192.168.1.1:19000/--/auth" + + @pytest.mark.asyncio + async def test_mobile_flag_with_http_scheme_is_rejected(self): + """mobile=1 with an http:// redirect_uri (open-redirect risk) is not stored.""" + from app.auth import login + + mock_request = MagicMock(spec=Request) + mock_request.query_params = {"mobile": "1", "redirect_uri": "http://evil.example.com/phish"} + mock_request.session = {} + + with patch("app.auth.templates") as mock_tpl: + mock_tpl.TemplateResponse.return_value = "page" + await login(mock_request) + + assert "mobile_redirect_uri" not in mock_request.session + + @pytest.mark.asyncio + async def test_no_mobile_flag_does_not_set_uri(self): + """Standard browser login (no mobile=1) does not store a mobile redirect URI.""" + from app.auth import login + + mock_request = MagicMock(spec=Request) + mock_request.query_params = {} + mock_request.session = {} + + with patch("app.auth.templates") as mock_tpl: + mock_tpl.TemplateResponse.return_value = "page" + await login(mock_request) + + assert "mobile_redirect_uri" not in mock_request.session + + @pytest.mark.asyncio + async def test_mobile_flag_with_empty_redirect_uri_is_rejected(self): + """mobile=1 with an empty redirect_uri is silently ignored.""" + from app.auth import login + + mock_request = MagicMock(spec=Request) + mock_request.query_params = {"mobile": "1", "redirect_uri": ""} + mock_request.session = {} + + with patch("app.auth.templates") as mock_tpl: + mock_tpl.TemplateResponse.return_value = "page" + await login(mock_request) + + assert "mobile_redirect_uri" not in mock_request.session + + +# --------------------------------------------------------------------------- +# social_login() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSocialLogin: + """Tests for social_login().""" + + @pytest.mark.asyncio + async def test_unknown_provider_redirects(self): + """Unknown social provider returns redirect to /login?error=...""" + from app.auth import social_login + + mock_request = MagicMock(spec=Request) + + with patch("app.auth.SOCIAL_PROVIDERS", {}): + result = await social_login(mock_request, provider="unknown") + + assert isinstance(result, RedirectResponse) + assert "Unknown+social+provider" in result.headers["location"] + + @pytest.mark.asyncio + async def test_registered_provider_oauth_client_missing_redirects(self): + """Provider is registered but OAuth client not initialised → redirect.""" + from app.auth import social_login + + mock_request = MagicMock(spec=Request) + mock_request.url_for = MagicMock(return_value="http://localhost/social-callback/google") + + fake_providers = {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}} + + mock_oauth = MagicMock(spec=[]) # no attributes + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_login(mock_request, provider="google") + + assert isinstance(result, RedirectResponse) + assert "Provider+not+configured" in result.headers["location"] + + @pytest.mark.asyncio + async def test_registered_provider_initiates_oauth(self): + """Registered provider with OAuth client initiates the OAuth redirect.""" + from app.auth import social_login + + mock_request = MagicMock(spec=Request) + mock_request.url_for = MagicMock(return_value="http://localhost/social-callback/google") + + fake_providers = {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}} + + mock_google_client = MagicMock() + mock_google_client.authorize_redirect = AsyncMock(return_value="oauth_redirect") + + mock_oauth = MagicMock() + mock_oauth.google = mock_google_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_login(mock_request, provider="google") + + assert result == "oauth_redirect" + mock_google_client.authorize_redirect.assert_called_once() + + +# --------------------------------------------------------------------------- +# _normalize_social_userinfo() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestNormalizeSocialUserinfo: + """Tests for _normalize_social_userinfo().""" + + def test_dropbox_provider_normalisation(self): + """Dropbox userinfo is mapped to the common format.""" + from app.auth import _normalize_social_userinfo + + raw = { + "account_id": "dbid:ABC123", + "email": "dan@example.com", + "name": {"display_name": "Dan Dropbox"}, + "profile_photo_url": "https://cdn.dropbox.com/photo.jpg", + } + + result = _normalize_social_userinfo("dropbox", {}, raw) + + assert result["sub"] == "dbid:ABC123" + assert result["email"] == "dan@example.com" + assert result["name"] == "Dan Dropbox" + assert result["preferred_username"] == "dan@example.com" + assert result["picture"] == "https://cdn.dropbox.com/photo.jpg" + + def test_dropbox_string_name_field(self): + """Dropbox userinfo with a string (not dict) name field is handled.""" + from app.auth import _normalize_social_userinfo + + raw = { + "account_id": "dbid:XYZ", + "email": "eve@example.com", + "name": "Eve String", # string instead of dict + } + + result = _normalize_social_userinfo("dropbox", {}, raw) + assert result["name"] == "Eve String" + + def test_dropbox_no_account_id_falls_back_to_email(self): + """Dropbox sub falls back to email when account_id is absent.""" + from app.auth import _normalize_social_userinfo + + raw = {"email": "frank@example.com"} + result = _normalize_social_userinfo("dropbox", {}, raw) + assert result["sub"] == "frank@example.com" + + def test_google_provider_normalisation(self): + """Standard OIDC (Google) userinfo is mapped to the common format.""" + from app.auth import _normalize_social_userinfo + + raw = { + "sub": "google-sub-123", + "email": "grace@gmail.com", + "name": "Grace Google", + "picture": "https://lh3.googleusercontent.com/photo.jpg", + } + + result = _normalize_social_userinfo("google", {}, raw) + + assert result["sub"] == "google-sub-123" + assert result["email"] == "grace@gmail.com" + assert result["preferred_username"] == "grace@gmail.com" + assert result["picture"] == "https://lh3.googleusercontent.com/photo.jpg" + + def test_standard_provider_empty_userinfo(self): + """Standard OIDC provider with empty userinfo returns empty strings.""" + from app.auth import _normalize_social_userinfo + + result = _normalize_social_userinfo("microsoft", {}, {}) + + assert result["sub"] == "" + assert result["email"] == "" + assert result["preferred_username"] == "" + + def test_none_raw_userinfo_treated_as_empty(self): + """raw_userinfo=None is treated as an empty dict.""" + from app.auth import _normalize_social_userinfo + + result = _normalize_social_userinfo("google", {}, None) + + assert result["sub"] == "" + assert result["email"] == "" + + +# --------------------------------------------------------------------------- +# social_callback() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSocialCallback: + """Tests for social_callback().""" + + @pytest.mark.asyncio + async def test_unknown_provider_redirects(self): + """Unknown provider returns immediate redirect.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_db = MagicMock() + + with patch("app.auth.SOCIAL_PROVIDERS", {}): + result = await social_callback(mock_request, provider="unknown", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "Unknown+social+provider" in result.headers["location"] + + @pytest.mark.asyncio + async def test_provider_not_configured_redirects(self): + """Registered provider with no OAuth client returns redirect.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_db = MagicMock() + + fake_providers = {"google": {}} + mock_oauth = MagicMock(spec=[]) + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "Provider+not+configured" in result.headers["location"] + + @pytest.mark.asyncio + async def test_success_stores_user_in_session(self): + """Successful callback stores user in session and redirects.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {"user-agent": "TestBrowser/1.0"} + mock_db = MagicMock() + + token = {"userinfo": {"sub": "g-sub", "email": "g@gmail.com", "name": "Google User"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session") as mock_create_session, + ): + mock_user_session = MagicMock() + mock_user_session.session_token = "tok-abc" + mock_create_session.return_value = mock_user_session + + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "user" in mock_request.session + assert mock_request.session["user"]["email"] == "g@gmail.com" + + @pytest.mark.asyncio + async def test_no_email_redirects(self): + """Callback with user data missing email returns error redirect.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + token = {"userinfo": {"sub": "g-sub", "name": "No Email"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "Could+not+retrieve+email" in result.headers["location"] + + @pytest.mark.asyncio + async def test_exception_in_callback_returns_error_redirect(self): + """Unhandled exception during callback returns error redirect.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(side_effect=Exception("provider error")) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "Social+login+failed" in result.headers["location"] + + @pytest.mark.asyncio + async def test_userinfo_fetched_from_endpoint_when_not_in_token(self): + """When userinfo is not embedded in the token, it's fetched from the endpoint.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {"user-agent": "TestBrowser/1.0"} + mock_db = MagicMock() + + # Token without embedded userinfo + token = {} + userinfo_resp = {"sub": "g-sub", "email": "fetch@gmail.com", "name": "Fetched User"} + + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + mock_client.userinfo = AsyncMock(return_value=userinfo_resp) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session"), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert mock_request.session["user"]["email"] == "fetch@gmail.com" + + @pytest.mark.asyncio + async def test_userinfo_endpoint_exception_falls_back_to_empty(self): + """If the userinfo endpoint raises, an empty dict is used and missing email → redirect.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + token = {} # no userinfo embedded + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + mock_client.userinfo = AsyncMock(side_effect=Exception("endpoint error")) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + # no email → redirect with error + assert isinstance(result, RedirectResponse) + assert "Could+not+retrieve+email" in result.headers["location"] + + @pytest.mark.asyncio + async def test_social_callback_adds_gravatar_when_no_picture(self): + """social_callback adds a Gravatar URL when the provider returns no picture.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + token = {"userinfo": {"sub": "g-sub", "email": "nopic@gmail.com", "name": "No Pic"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session"), + ): + await social_callback(mock_request, provider="google", db=mock_db) + + assert mock_request.session["user"].get("picture", "").startswith("https://www.gravatar.com/") + + @pytest.mark.asyncio + async def test_social_callback_session_token_exception(self): + """Exception during server-side session creation is swallowed.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + token = {"userinfo": {"sub": "g-sub", "email": "exc@gmail.com"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session", side_effect=Exception("session error")), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + # Should not crash; user still in session + assert "user" in mock_request.session + + @pytest.mark.asyncio + async def test_social_callback_mobile_redirect(self): + """When mobile redirect URI is in session, the mobile redirect is returned.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + token = {"userinfo": {"sub": "g-sub", "email": "mob@gmail.com"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + mobile_resp = RedirectResponse(url="docuelevate://callback?token=abc", status_code=302) + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=mobile_resp), + patch("app.utils.session_manager.create_session"), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert result is mobile_resp + + @pytest.mark.asyncio + async def test_social_callback_onboarding_redirect(self): + """First-time users who haven't completed onboarding are sent to /onboarding.""" + from app.auth import social_callback + from app.models import UserProfile + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + # Simulate incomplete onboarding + profile = UserProfile(user_id="g-sub", onboarding_completed=False) + mock_db.query.return_value.filter.return_value.first.return_value = profile + + token = {"userinfo": {"sub": "g-sub", "email": "new@gmail.com"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session"), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert result.headers["location"] == "/onboarding" + + +# --------------------------------------------------------------------------- +# _ensure_user_profile — admin update logic +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestEnsureUserProfileAdminUpdate: + """Tests for _ensure_user_profile() admin-specific update logic.""" + + def test_admin_sets_complimentary_and_upgrades_free_tier(self): + """Existing admin profile with is_complimentary=False and free tier gets upgraded.""" + from app.auth import _ensure_user_profile + from app.models import UserProfile + + mock_db = MagicMock() + existing = UserProfile(user_id="admin-u", subscription_tier="free", is_complimentary=False) + mock_db.query.return_value.filter.return_value.first.return_value = existing + + with patch("app.utils.subscription.TIER_ORDER", ["free", "pro", "enterprise"]): + _ensure_user_profile(mock_db, {"sub": "admin-u"}, is_admin=True) + + assert existing.is_complimentary is True + assert existing.subscription_tier == "enterprise" + mock_db.commit.assert_called_once() + + def test_admin_already_complimentary_no_free_tier_no_commit(self): + """Existing admin profile that is already complimentary on a paid tier is left alone.""" + from app.auth import _ensure_user_profile + from app.models import UserProfile + + mock_db = MagicMock() + existing = UserProfile(user_id="admin-u2", subscription_tier="enterprise", is_complimentary=True) + mock_db.query.return_value.filter.return_value.first.return_value = existing + + with patch("app.utils.subscription.TIER_ORDER", ["free", "pro", "enterprise"]): + _ensure_user_profile(mock_db, {"sub": "admin-u2"}, is_admin=True) + + mock_db.commit.assert_not_called() + + def test_admin_complimentary_but_on_free_tier_upgrades(self): + """Admin profile that is complimentary but still on 'free' tier gets upgraded.""" + from app.auth import _ensure_user_profile + from app.models import UserProfile + + mock_db = MagicMock() + existing = UserProfile(user_id="admin-u3", subscription_tier="free", is_complimentary=True) + mock_db.query.return_value.filter.return_value.first.return_value = existing + + with patch("app.utils.subscription.TIER_ORDER", ["free", "pro", "enterprise"]): + _ensure_user_profile(mock_db, {"sub": "admin-u3"}, is_admin=True) + + assert existing.subscription_tier == "enterprise" + mock_db.commit.assert_called_once() + + +# --------------------------------------------------------------------------- +# oauth_callback — session token + mobile/onboarding +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestOAuthCallbackExtended: + """Additional tests for oauth_callback().""" + + @pytest.mark.asyncio + async def test_session_token_stored_when_sub_present(self): + """oauth_callback stores server-side session token in session dict.""" + from app.auth import oauth_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {"user-agent": "Test/1.0"} + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None # no profile + + userinfo = {"sub": "oidc-sub", "email": "oidc@example.com"} + mock_authentik = MagicMock() + mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) + + mock_session_obj = MagicMock() + mock_session_obj.session_token = "server-side-tok" + + with ( + patch("app.auth.oauth") as mock_oauth, + patch("app.auth.settings") as mock_settings, + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.auth._record_login_event"), + patch("app.utils.session_manager.create_session", return_value=mock_session_obj), + ): + mock_oauth.authentik = mock_authentik + mock_settings.admin_group_name = "admin" + + await oauth_callback(mock_request, db=mock_db) + + assert mock_request.session.get("_session_token") == "server-side-tok" + + @pytest.mark.asyncio + async def test_session_token_exception_is_swallowed(self): + """Exception during server-side session creation is logged and swallowed.""" + from app.auth import oauth_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + userinfo = {"sub": "oidc-sub2", "email": "oidc2@example.com"} + 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"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.auth._record_login_event"), + patch("app.utils.session_manager.create_session", side_effect=Exception("session create failed")), + ): + mock_oauth.authentik = mock_authentik + mock_settings.admin_group_name = "admin" + + result = await oauth_callback(mock_request, db=mock_db) + + # Should complete without crashing and redirect + assert isinstance(result, RedirectResponse) + + @pytest.mark.asyncio + async def test_oauth_callback_mobile_redirect_returned(self): + """oauth_callback returns mobile redirect when mobile_redirect_uri is in session.""" + from app.auth import oauth_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + userinfo = {"sub": "mob-sub", "email": "mob@example.com"} + mock_authentik = MagicMock() + mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) + + mobile_resp = RedirectResponse(url="docuelevate://callback?token=xyz", status_code=302) + + with ( + patch("app.auth.oauth") as mock_oauth, + patch("app.auth.settings") as mock_settings, + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=mobile_resp), + patch("app.auth._record_login_event"), + patch("app.utils.session_manager.create_session"), + ): + mock_oauth.authentik = mock_authentik + mock_settings.admin_group_name = "admin" + + result = await oauth_callback(mock_request, db=mock_db) + + assert result is mobile_resp + + @pytest.mark.asyncio + async def test_oauth_callback_onboarding_redirect(self): + """oauth_callback sends first-time users to /onboarding.""" + from app.auth import oauth_callback + from app.models import UserProfile + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + profile = UserProfile(user_id="new-sub", onboarding_completed=False) + mock_db.query.return_value.filter.return_value.first.return_value = profile + + userinfo = {"sub": "new-sub", "email": "new@example.com"} + 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"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.auth._record_login_event"), + patch("app.utils.session_manager.create_session"), + ): + 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"] == "/onboarding" + + +# --------------------------------------------------------------------------- +# _record_login_event — exception handling +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestRecordLoginEvent: + """Tests for _record_login_event().""" + + def test_exception_in_record_event_is_swallowed(self): + """If record_event raises, _record_login_event swallows the error.""" + from app.auth import _record_login_event + + mock_db = MagicMock() + mock_request = MagicMock(spec=Request) + mock_request.headers = {} + mock_request.client = None + + with patch("app.utils.audit_service.record_event", side_effect=Exception("audit DB down")): + # Must not raise + _record_login_event(mock_db, mock_request, "alice", success=True) + + def test_records_successful_login(self): + """Successful login emits a 'login' event with method and no reason.""" + from app.auth import _record_login_event + + mock_db = MagicMock() + mock_request = MagicMock(spec=Request) + mock_request.headers = {"x-forwarded-for": ""} + mock_request.client = None + + with patch("app.utils.audit_service.record_event") as mock_record: + _record_login_event(mock_db, mock_request, "bob", success=True, method="local") + + mock_record.assert_called_once() + call_kwargs = mock_record.call_args[1] + assert call_kwargs["action"] == "login" + assert call_kwargs["severity"] == "info" + + def test_records_failed_login_with_detail(self): + """Failed login emits a 'login.failure' event with the detail reason.""" + from app.auth import _record_login_event + + mock_db = MagicMock() + mock_request = MagicMock(spec=Request) + mock_request.headers = {"x-forwarded-for": ""} + mock_request.client = None + + with patch("app.utils.audit_service.record_event") as mock_record: + _record_login_event(mock_db, mock_request, "carol", success=False, detail="wrong_password") + + mock_record.assert_called_once() + call_kwargs = mock_record.call_args[1] + assert call_kwargs["action"] == "login.failure" + assert call_kwargs["severity"] == "warning" + assert call_kwargs["details"]["reason"] == "wrong_password" + + +# --------------------------------------------------------------------------- +# _create_mobile_redirect() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCreateMobileRedirect: + """Tests for _create_mobile_redirect().""" + + def test_returns_none_when_no_redirect_uri(self): + """Returns None when no mobile_redirect_uri is in session.""" + from app.auth import _create_mobile_redirect + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + result = _create_mobile_redirect(mock_request, mock_db) + assert result is None + + def test_returns_none_when_no_owner_id(self): + """Returns None when user in session has no usable owner identifier.""" + from app.auth import _create_mobile_redirect + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "mobile_redirect_uri": "docuelevate://callback", + "user": {}, # no sub/preferred_username/email/id + } + mock_db = MagicMock() + + result = _create_mobile_redirect(mock_request, mock_db) + assert result is None + + def test_success_returns_redirect_with_token(self): + """Successful mobile redirect creates token and returns RedirectResponse.""" + from app.auth import _create_mobile_redirect + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "mobile_redirect_uri": "docuelevate://callback", + "user": {"sub": "mob-user"}, + } + mock_request.headers = {} + mock_db = MagicMock() + + mock_token_obj = MagicMock() + mock_token_obj.id = 99 + mock_db.add = MagicMock() + mock_db.commit = MagicMock() + + with ( + patch("app.api.api_tokens.generate_api_token", return_value="plaintexttoken12345"), + patch("app.api.api_tokens.hash_token", return_value="hashvalue"), + ): + result = _create_mobile_redirect(mock_request, mock_db) + + assert isinstance(result, RedirectResponse) + location = result.headers["location"] + assert "docuelevate://callback" in location + assert "token=" in location + + def test_db_commit_exception_returns_none(self): + """If db.commit() raises when creating the mobile token, returns None.""" + from app.auth import _create_mobile_redirect + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "mobile_redirect_uri": "docuelevate://callback", + "user": {"email": "mob@example.com"}, + } + mock_db = MagicMock() + mock_db.commit.side_effect = Exception("commit failed") + + with ( + patch("app.api.api_tokens.generate_api_token", return_value="plaintext12345"), + patch("app.api.api_tokens.hash_token", return_value="hashvalue"), + ): + result = _create_mobile_redirect(mock_request, mock_db) + + assert result is None + mock_db.rollback.assert_called_once() + + def test_existing_query_params_preserved(self): + """Token is appended correctly when the redirect URI already has query params.""" + from app.auth import _create_mobile_redirect + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "mobile_redirect_uri": "docuelevate://callback?existing=1", + "user": {"sub": "user1"}, + } + mock_request.headers = {} + mock_db = MagicMock() + + with ( + patch("app.api.api_tokens.generate_api_token", return_value="plaintoken"), + patch("app.api.api_tokens.hash_token", return_value="hashval"), + ): + result = _create_mobile_redirect(mock_request, mock_db) + + location = result.headers["location"] + # Should use "&" separator since "?" already present + assert "&token=" in location + + +# --------------------------------------------------------------------------- +# auth() — multi-user and local user paths +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestAuthLocalUserPaths: + """Tests for auth() covering LocalUser authentication and multi-user mode.""" + + def _make_local_user( + self, + *, + email: str = "alice@example.com", + username: str = "alice", + is_active: bool = True, + is_admin: bool = False, + hashed_password: str = "hashed", # noqa: S107 + ): + """Build a mock LocalUser.""" + user = MagicMock() + user.email = email + user.username = username + user.is_active = is_active + user.is_admin = is_admin + user.hashed_password = hashed_password + return user + + def _make_db(self, local_user=None): + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = local_user + return mock_db + + @pytest.mark.asyncio + async def test_empty_username_in_multi_user_mode_redirects(self): + """Empty username in multi-user mode returns error redirect.""" + from app.auth import auth + + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={}) # no username + mock_request.session = {} + + with patch("app.auth.settings") as mock_settings: + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=self._make_db(None)) + + assert isinstance(result, RedirectResponse) + assert "Invalid+username+or+password" in result.headers["location"] + + @pytest.mark.asyncio + async def test_inactive_local_user_redirects_with_verify_email_message(self): + """Inactive (unverified) local user gets an error asking for email verification.""" + from app.auth import auth + + local_user = self._make_local_user(is_active=False) + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "pw"}) + mock_request.session = {} + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._record_login_event"), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=self._make_db(local_user)) + + assert isinstance(result, RedirectResponse) + assert "verify+your+email" in result.headers["location"] + + @pytest.mark.asyncio + async def test_wrong_password_for_local_user_redirects(self): + """Wrong password for a valid local user returns error redirect.""" + from app.auth import auth + + local_user = self._make_local_user() + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "wrongpw"}) + mock_request.session = {} + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._verify_password", return_value=False), + patch("app.auth._record_login_event"), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=self._make_db(local_user)) + + assert isinstance(result, RedirectResponse) + assert "Invalid+username+or+password" in result.headers["location"] + + @pytest.mark.asyncio + async def test_successful_local_user_login(self): + """Correct password for a local user sets session and redirects.""" + from app.auth import auth + + local_user = self._make_local_user() + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "correctpw"}) + mock_request.session = {} + mock_request.headers = {} + + mock_session_obj = MagicMock() + mock_session_obj.session_token = "local-session-tok" + profile = MagicMock() + profile.onboarding_completed = True + + def db_query_side_effect(model): + """Return appropriate mock based on model being queried.""" + mock_q = MagicMock() + mock_q.filter.return_value.first.return_value = profile + return mock_q + + mock_db = MagicMock() + mock_db.query.side_effect = db_query_side_effect + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._verify_password", return_value=True), + patch("app.auth._build_session_user", return_value={"id": "alice", "email": "alice@example.com"}), + patch("app.auth._record_login_event"), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session", return_value=mock_session_obj), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "user" in mock_request.session + assert mock_request.session.get("_session_token") == "local-session-tok" + + @pytest.mark.asyncio + async def test_local_user_mobile_redirect(self): + """Successful local user login returns mobile redirect when URI is in session.""" + from app.auth import auth + + local_user = self._make_local_user() + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "pw"}) + mock_request.session = {} + mock_request.headers = {} + + mobile_resp = RedirectResponse(url="docuelevate://callback?token=tok", status_code=302) + + mock_db = MagicMock() + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._verify_password", return_value=True), + patch("app.auth._build_session_user", return_value={"id": "alice"}), + patch("app.auth._record_login_event"), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=mobile_resp), + patch("app.utils.session_manager.create_session"), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + mock_db.query.return_value.filter.return_value.first.return_value = local_user + + result = await auth(mock_request, db=mock_db) + + assert result is mobile_resp + + @pytest.mark.asyncio + async def test_local_user_session_creation_exception_is_swallowed(self): + """Exception during session creation for local user is swallowed; login still succeeds.""" + from app.auth import auth + + local_user = self._make_local_user() + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "pw"}) + mock_request.session = {} + mock_request.headers = {} + + mock_db = MagicMock() + profile = MagicMock() + profile.onboarding_completed = True + mock_db.query.return_value.filter.return_value.first.side_effect = [local_user, profile] + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._verify_password", return_value=True), + patch("app.auth._build_session_user", return_value={"id": "alice"}), + patch("app.auth._record_login_event"), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session", side_effect=Exception("session fail")), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=mock_db) + + # Should still redirect successfully despite the session creation failure + assert isinstance(result, RedirectResponse) + assert "user" in mock_request.session + + @pytest.mark.asyncio + async def test_local_user_onboarding_redirect(self): + """Local user who hasn't completed onboarding is sent to /onboarding.""" + from app.auth import auth + from app.models import UserProfile + + local_user = self._make_local_user() + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "pw"}) + mock_request.session = {} + mock_request.headers = {} + + profile = UserProfile(user_id="alice@example.com", onboarding_completed=False) + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.side_effect = [local_user, profile] + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._verify_password", return_value=True), + patch("app.auth._build_session_user", return_value={"id": "alice"}), + patch("app.auth._record_login_event"), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session"), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=mock_db) + + assert isinstance(result, RedirectResponse) + assert result.headers["location"] == "/onboarding" + + +# --------------------------------------------------------------------------- +# auth() — admin credentials paths +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestAuthAdminExtended: + """Additional coverage for admin auth paths.""" + + def _make_db(self, local_user=None): + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = local_user + return mock_db + + @pytest.mark.asyncio + async def test_admin_login_session_token_exception_is_swallowed(self): + """Exception during admin session token creation is swallowed; redirect is still returned.""" + from app.auth import auth + + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "adminuser", "password": "adminpass"}) + mock_request.session = {} + mock_request.headers = {} + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._ensure_user_profile"), + patch("app.auth._record_login_event"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session", side_effect=Exception("session fail")), + ): + mock_settings.admin_username = "adminuser" + mock_settings.admin_password = "adminpass" + mock_settings.multi_user_enabled = False + + result = await auth(mock_request, db=self._make_db(None)) + + assert isinstance(result, RedirectResponse) + # Session should still have user even if token creation failed + assert "user" in mock_request.session + + @pytest.mark.asyncio + async def test_admin_login_mobile_redirect(self): + """Admin login returns mobile redirect when mobile_redirect_uri is in session.""" + from app.auth import auth + + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "adminuser", "password": "adminpass"}) + mock_request.session = {} + mock_request.headers = {} + + mobile_resp = RedirectResponse(url="docuelevate://callback?token=admin-tok", status_code=302) + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._ensure_user_profile"), + patch("app.auth._record_login_event"), + patch("app.auth._create_mobile_redirect", return_value=mobile_resp), + patch("app.utils.session_manager.create_session"), + ): + mock_settings.admin_username = "adminuser" + mock_settings.admin_password = "adminpass" + mock_settings.multi_user_enabled = False + + result = await auth(mock_request, db=self._make_db(None)) + + assert result is mobile_resp + + +# --------------------------------------------------------------------------- +# logout() — session token revocation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLogoutSessionRevocation: + """Tests for logout() session token revocation.""" + + @pytest.mark.asyncio + async def test_logout_revokes_server_side_session(self): + """logout() sets is_revoked=True on the UserSession and commits.""" + from app.auth import logout + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "user": {"preferred_username": "alice"}, + "_session_token": "active-session-token", + } + + mock_user_session = MagicMock() + mock_user_session.is_revoked = False + + mock_db = MagicMock() + + with ( + patch("app.utils.audit_service.record_event"), + patch("app.utils.session_manager.validate_session", return_value=mock_user_session), + ): + result = await logout(mock_request, db=mock_db) + + assert mock_user_session.is_revoked is True + mock_db.commit.assert_called_once() + assert isinstance(result, RedirectResponse) + assert "logged+out" in result.headers["location"] + + @pytest.mark.asyncio + async def test_logout_when_session_token_already_invalid(self): + """logout() handles validate_session returning None gracefully.""" + from app.auth import logout + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "user": {"preferred_username": "alice"}, + "_session_token": "orphan-token", + } + + mock_db = MagicMock() + + with ( + patch("app.utils.audit_service.record_event"), + patch("app.utils.session_manager.validate_session", return_value=None), + ): + result = await logout(mock_request, db=mock_db) + + # No commit since session was already gone + mock_db.commit.assert_not_called() + assert isinstance(result, RedirectResponse) + + @pytest.mark.asyncio + async def test_logout_session_revoke_exception_is_swallowed(self): + """Exception during session revocation is swallowed and logout still completes.""" + from app.auth import logout + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "user": {"email": "bob@example.com"}, + "_session_token": "some-token", + } + + mock_db = MagicMock() + + with ( + patch("app.utils.audit_service.record_event"), + patch("app.utils.session_manager.validate_session", side_effect=Exception("db error")), + ): + result = await logout(mock_request, db=mock_db) + + # Should still redirect successfully + assert isinstance(result, RedirectResponse) + assert "logged+out" in result.headers["location"] + + @pytest.mark.asyncio + async def test_logout_without_session_token_still_completes(self): + """Logout without any _session_token in session completes normally.""" + from app.auth import logout + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"preferred_username": "charlie"}} + + mock_db = MagicMock() + + with patch("app.utils.audit_service.record_event"): + result = await logout(mock_request, db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "logged+out" in result.headers["location"] + assert "user" not in mock_request.session