e5feee5aae
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
1802 lines
69 KiB
Python
1802 lines
69 KiB
Python
"""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
|