fix: pricing page toggle and user auto-creation on OAuth login
- Fix monthly/annual price toggle by moving x-data scope to outer div - Auto-create UserProfile in DB on first Authentik OAuth login - Update and expand tests for oauth_callback and _ensure_user_profile Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+40
-2
@@ -5,11 +5,13 @@ import pathlib
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from authlib.integrations.starlette_client import OAuth
|
from authlib.integrations.starlette_client import OAuth
|
||||||
from fastapi import APIRouter, Request, status
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
oauth = OAuth()
|
oauth = OAuth()
|
||||||
|
|
||||||
@@ -94,7 +96,40 @@ async def oauth_login(request: Request):
|
|||||||
return await oauth.authentik.authorize_redirect(request, redirect_uri)
|
return await oauth.authentik.authorize_redirect(request, redirect_uri)
|
||||||
|
|
||||||
|
|
||||||
async def oauth_callback(request: Request):
|
def _ensure_user_profile(db: Session, user_data: dict) -> None:
|
||||||
|
"""Create a UserProfile row for *user_data* if one does not yet exist.
|
||||||
|
|
||||||
|
Uses the same identifier priority as ``get_current_owner_id`` (sub →
|
||||||
|
preferred_username → email → id) so that the profile's ``user_id`` matches
|
||||||
|
``FileRecord.owner_id`` for every document the user uploads.
|
||||||
|
|
||||||
|
If a profile already exists it is left unchanged; only missing profiles
|
||||||
|
are created so that admin-managed settings (tier, limits, etc.) are
|
||||||
|
preserved across logins.
|
||||||
|
"""
|
||||||
|
from app.models import UserProfile
|
||||||
|
|
||||||
|
user_id = (
|
||||||
|
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
|
||||||
|
)
|
||||||
|
if not user_id:
|
||||||
|
logger.warning("Cannot create UserProfile: no stable user identifier in OAuth userinfo")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
|
||||||
|
if existing is None:
|
||||||
|
display_name = user_data.get("name") or user_data.get("preferred_username") or user_data.get("email")
|
||||||
|
profile = UserProfile(user_id=user_id, display_name=display_name)
|
||||||
|
db.add(profile)
|
||||||
|
db.commit()
|
||||||
|
logger.info("Auto-created UserProfile for user_id=%s", user_id)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to auto-create UserProfile for user_id=%s", user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Handle OAuth callback from provider"""
|
"""Handle OAuth callback from provider"""
|
||||||
try:
|
try:
|
||||||
token = await oauth.authentik.authorize_access_token(request)
|
token = await oauth.authentik.authorize_access_token(request)
|
||||||
@@ -126,6 +161,9 @@ async def oauth_callback(request: Request):
|
|||||||
|
|
||||||
request.session["user"] = user_data
|
request.session["user"] = user_data
|
||||||
|
|
||||||
|
# Auto-create or update UserProfile so the user appears in admin user management
|
||||||
|
_ensure_user_profile(db, user_data)
|
||||||
|
|
||||||
# Log the successful authentication
|
# Log the successful authentication
|
||||||
logger.info(f"[SECURITY] OAUTH_LOGIN_SUCCESS user={user_data.get('email', 'unknown')} admin={is_admin}")
|
logger.info(f"[SECURITY] OAUTH_LOGIN_SUCCESS user={user_data.get('email', 'unknown')} admin={is_admin}")
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block title %}Pricing & Plans – DocuElevate{% endblock %}
|
{% block title %}Pricing & Plans – DocuElevate{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="bg-gray-50 min-h-screen">
|
<div class="bg-gray-50 min-h-screen" x-data="{ annual: false }">
|
||||||
|
|
||||||
<!-- ── Hero ──────────────────────────────────────────────────────────────── -->
|
<!-- ── Hero ──────────────────────────────────────────────────────────────── -->
|
||||||
<div class="bg-gradient-to-br from-blue-700 via-indigo-700 to-purple-700 text-white py-20 px-4">
|
<div class="bg-gradient-to-br from-blue-700 via-indigo-700 to-purple-700 text-white py-20 px-4">
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Annual / Monthly toggle (cosmetic — actual billing handled separately) -->
|
<!-- Annual / Monthly toggle (cosmetic — actual billing handled separately) -->
|
||||||
<div class="mt-8 inline-flex items-center bg-white/10 rounded-full p-1 gap-1" x-data="{ annual: false }">
|
<div class="mt-8 inline-flex items-center bg-white/10 rounded-full p-1 gap-1">
|
||||||
<button
|
<button
|
||||||
@click="annual = false"
|
@click="annual = false"
|
||||||
:class="!annual ? 'bg-white text-indigo-700 shadow' : 'text-white'"
|
:class="!annual ? 'bg-white text-indigo-700 shadow' : 'text-white'"
|
||||||
@@ -36,8 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Tier cards ────────────────────────────────────────────────────────── -->
|
<!-- ── Tier cards ────────────────────────────────────────────────────────── -->
|
||||||
<div class="max-w-7xl mx-auto px-4 -mt-10 pb-20" x-data="{ annual: false }">
|
<div class="max-w-7xl mx-auto px-4 -mt-10 pb-20">
|
||||||
<!-- Recreate the toggle state here so cards react to the hero toggle too -->
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
|
||||||
|
|
||||||
{% for tier in tiers %}
|
{% for tier in tiers %}
|
||||||
|
|||||||
+154
-8
@@ -375,6 +375,7 @@ class TestOAuthCallback:
|
|||||||
|
|
||||||
mock_request = MagicMock(spec=Request)
|
mock_request = MagicMock(spec=Request)
|
||||||
mock_request.session = {}
|
mock_request.session = {}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
userinfo = {
|
userinfo = {
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
@@ -388,11 +389,12 @@ class TestOAuthCallback:
|
|||||||
with (
|
with (
|
||||||
patch("app.auth.oauth") as mock_oauth,
|
patch("app.auth.oauth") as mock_oauth,
|
||||||
patch("app.auth.settings") as mock_settings,
|
patch("app.auth.settings") as mock_settings,
|
||||||
|
patch("app.auth._ensure_user_profile"),
|
||||||
):
|
):
|
||||||
mock_oauth.authentik = mock_authentik
|
mock_oauth.authentik = mock_authentik
|
||||||
mock_settings.admin_group_name = "admin"
|
mock_settings.admin_group_name = "admin"
|
||||||
|
|
||||||
result = await oauth_callback(mock_request)
|
result = await oauth_callback(mock_request, db=mock_db)
|
||||||
|
|
||||||
assert isinstance(result, RedirectResponse)
|
assert isinstance(result, RedirectResponse)
|
||||||
assert result.status_code == status.HTTP_302_FOUND
|
assert result.status_code == status.HTTP_302_FOUND
|
||||||
@@ -407,6 +409,7 @@ class TestOAuthCallback:
|
|||||||
|
|
||||||
mock_request = MagicMock(spec=Request)
|
mock_request = MagicMock(spec=Request)
|
||||||
mock_request.session = {}
|
mock_request.session = {}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
userinfo = {"email": "test@example.com", "name": "Test User"}
|
userinfo = {"email": "test@example.com", "name": "Test User"}
|
||||||
|
|
||||||
@@ -416,11 +419,12 @@ class TestOAuthCallback:
|
|||||||
with (
|
with (
|
||||||
patch("app.auth.oauth") as mock_oauth,
|
patch("app.auth.oauth") as mock_oauth,
|
||||||
patch("app.auth.settings") as mock_settings,
|
patch("app.auth.settings") as mock_settings,
|
||||||
|
patch("app.auth._ensure_user_profile"),
|
||||||
):
|
):
|
||||||
mock_oauth.authentik = mock_authentik
|
mock_oauth.authentik = mock_authentik
|
||||||
mock_settings.admin_group_name = "admin"
|
mock_settings.admin_group_name = "admin"
|
||||||
|
|
||||||
result = await oauth_callback(mock_request)
|
result = await oauth_callback(mock_request, db=mock_db)
|
||||||
|
|
||||||
# Gravatar should be added
|
# Gravatar should be added
|
||||||
assert "picture" in mock_request.session["user"]
|
assert "picture" in mock_request.session["user"]
|
||||||
@@ -433,6 +437,7 @@ class TestOAuthCallback:
|
|||||||
|
|
||||||
mock_request = MagicMock(spec=Request)
|
mock_request = MagicMock(spec=Request)
|
||||||
mock_request.session = {}
|
mock_request.session = {}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
userinfo = {
|
userinfo = {
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
@@ -446,11 +451,12 @@ class TestOAuthCallback:
|
|||||||
with (
|
with (
|
||||||
patch("app.auth.oauth") as mock_oauth,
|
patch("app.auth.oauth") as mock_oauth,
|
||||||
patch("app.auth.settings") as mock_settings,
|
patch("app.auth.settings") as mock_settings,
|
||||||
|
patch("app.auth._ensure_user_profile"),
|
||||||
):
|
):
|
||||||
mock_oauth.authentik = mock_authentik
|
mock_oauth.authentik = mock_authentik
|
||||||
mock_settings.admin_group_name = "admin"
|
mock_settings.admin_group_name = "admin"
|
||||||
|
|
||||||
result = await oauth_callback(mock_request)
|
result = await oauth_callback(mock_request, db=mock_db)
|
||||||
|
|
||||||
# Custom picture should be preserved, not replaced with Gravatar
|
# Custom picture should be preserved, not replaced with Gravatar
|
||||||
assert mock_request.session["user"]["picture"] == "https://example.com/custom-pic.jpg"
|
assert mock_request.session["user"]["picture"] == "https://example.com/custom-pic.jpg"
|
||||||
@@ -462,6 +468,7 @@ class TestOAuthCallback:
|
|||||||
|
|
||||||
mock_request = MagicMock(spec=Request)
|
mock_request = MagicMock(spec=Request)
|
||||||
mock_request.session = {}
|
mock_request.session = {}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
userinfo = {
|
userinfo = {
|
||||||
"email": "admin@example.com",
|
"email": "admin@example.com",
|
||||||
@@ -475,11 +482,12 @@ class TestOAuthCallback:
|
|||||||
with (
|
with (
|
||||||
patch("app.auth.oauth") as mock_oauth,
|
patch("app.auth.oauth") as mock_oauth,
|
||||||
patch("app.auth.settings") as mock_settings,
|
patch("app.auth.settings") as mock_settings,
|
||||||
|
patch("app.auth._ensure_user_profile"),
|
||||||
):
|
):
|
||||||
mock_oauth.authentik = mock_authentik
|
mock_oauth.authentik = mock_authentik
|
||||||
mock_settings.admin_group_name = "admin"
|
mock_settings.admin_group_name = "admin"
|
||||||
|
|
||||||
result = await oauth_callback(mock_request)
|
result = await oauth_callback(mock_request, db=mock_db)
|
||||||
|
|
||||||
# User should be marked as admin
|
# User should be marked as admin
|
||||||
assert mock_request.session["user"]["is_admin"] is True
|
assert mock_request.session["user"]["is_admin"] is True
|
||||||
@@ -491,6 +499,7 @@ class TestOAuthCallback:
|
|||||||
|
|
||||||
mock_request = MagicMock(spec=Request)
|
mock_request = MagicMock(spec=Request)
|
||||||
mock_request.session = {}
|
mock_request.session = {}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
userinfo = {
|
userinfo = {
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
@@ -504,11 +513,12 @@ class TestOAuthCallback:
|
|||||||
with (
|
with (
|
||||||
patch("app.auth.oauth") as mock_oauth,
|
patch("app.auth.oauth") as mock_oauth,
|
||||||
patch("app.auth.settings") as mock_settings,
|
patch("app.auth.settings") as mock_settings,
|
||||||
|
patch("app.auth._ensure_user_profile"),
|
||||||
):
|
):
|
||||||
mock_oauth.authentik = mock_authentik
|
mock_oauth.authentik = mock_authentik
|
||||||
mock_settings.admin_group_name = "admin"
|
mock_settings.admin_group_name = "admin"
|
||||||
|
|
||||||
result = await oauth_callback(mock_request)
|
result = await oauth_callback(mock_request, db=mock_db)
|
||||||
|
|
||||||
# User should not be marked as admin
|
# User should not be marked as admin
|
||||||
assert mock_request.session["user"]["is_admin"] is False
|
assert mock_request.session["user"]["is_admin"] is False
|
||||||
@@ -520,6 +530,7 @@ class TestOAuthCallback:
|
|||||||
|
|
||||||
mock_request = MagicMock(spec=Request)
|
mock_request = MagicMock(spec=Request)
|
||||||
mock_request.session = {}
|
mock_request.session = {}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
mock_authentik = MagicMock()
|
mock_authentik = MagicMock()
|
||||||
mock_authentik.authorize_access_token = AsyncMock(return_value={})
|
mock_authentik.authorize_access_token = AsyncMock(return_value={})
|
||||||
@@ -527,7 +538,7 @@ class TestOAuthCallback:
|
|||||||
with patch("app.auth.oauth") as mock_oauth:
|
with patch("app.auth.oauth") as mock_oauth:
|
||||||
mock_oauth.authentik = mock_authentik
|
mock_oauth.authentik = mock_authentik
|
||||||
|
|
||||||
result = await oauth_callback(mock_request)
|
result = await oauth_callback(mock_request, db=mock_db)
|
||||||
|
|
||||||
assert isinstance(result, RedirectResponse)
|
assert isinstance(result, RedirectResponse)
|
||||||
assert "/login?error=Failed+to+retrieve+user+information" in result.headers["location"]
|
assert "/login?error=Failed+to+retrieve+user+information" in result.headers["location"]
|
||||||
@@ -539,6 +550,7 @@ class TestOAuthCallback:
|
|||||||
|
|
||||||
mock_request = MagicMock(spec=Request)
|
mock_request = MagicMock(spec=Request)
|
||||||
mock_request.session = {"redirect_after_login": "/protected/page"}
|
mock_request.session = {"redirect_after_login": "/protected/page"}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
userinfo = {"email": "test@example.com", "name": "Test User"}
|
userinfo = {"email": "test@example.com", "name": "Test User"}
|
||||||
|
|
||||||
@@ -548,11 +560,12 @@ class TestOAuthCallback:
|
|||||||
with (
|
with (
|
||||||
patch("app.auth.oauth") as mock_oauth,
|
patch("app.auth.oauth") as mock_oauth,
|
||||||
patch("app.auth.settings") as mock_settings,
|
patch("app.auth.settings") as mock_settings,
|
||||||
|
patch("app.auth._ensure_user_profile"),
|
||||||
):
|
):
|
||||||
mock_oauth.authentik = mock_authentik
|
mock_oauth.authentik = mock_authentik
|
||||||
mock_settings.admin_group_name = "admin"
|
mock_settings.admin_group_name = "admin"
|
||||||
|
|
||||||
result = await oauth_callback(mock_request)
|
result = await oauth_callback(mock_request, db=mock_db)
|
||||||
|
|
||||||
assert isinstance(result, RedirectResponse)
|
assert isinstance(result, RedirectResponse)
|
||||||
assert result.headers["location"] == "/protected/page"
|
assert result.headers["location"] == "/protected/page"
|
||||||
@@ -566,6 +579,7 @@ class TestOAuthCallback:
|
|||||||
|
|
||||||
mock_request = MagicMock(spec=Request)
|
mock_request = MagicMock(spec=Request)
|
||||||
mock_request.session = {}
|
mock_request.session = {}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
mock_authentik = MagicMock()
|
mock_authentik = MagicMock()
|
||||||
mock_authentik.authorize_access_token = AsyncMock(side_effect=Exception("OAuth error"))
|
mock_authentik.authorize_access_token = AsyncMock(side_effect=Exception("OAuth error"))
|
||||||
@@ -573,11 +587,143 @@ class TestOAuthCallback:
|
|||||||
with patch("app.auth.oauth") as mock_oauth:
|
with patch("app.auth.oauth") as mock_oauth:
|
||||||
mock_oauth.authentik = mock_authentik
|
mock_oauth.authentik = mock_authentik
|
||||||
|
|
||||||
result = await oauth_callback(mock_request)
|
result = await oauth_callback(mock_request, db=mock_db)
|
||||||
|
|
||||||
assert isinstance(result, RedirectResponse)
|
assert isinstance(result, RedirectResponse)
|
||||||
assert "/login?error=Authentication+failed" in result.headers["location"]
|
assert "/login?error=Authentication+failed" in result.headers["location"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oauth_callback_creates_user_profile(self):
|
||||||
|
"""Test OAuth callback auto-creates a UserProfile for the authenticated user."""
|
||||||
|
from app.auth import oauth_callback
|
||||||
|
|
||||||
|
mock_request = MagicMock(spec=Request)
|
||||||
|
mock_request.session = {}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
|
userinfo = {
|
||||||
|
"sub": "oauth-sub-abc123",
|
||||||
|
"email": "new@example.com",
|
||||||
|
"name": "New User",
|
||||||
|
"preferred_username": "newuser",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_authentik = MagicMock()
|
||||||
|
mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.auth.oauth") as mock_oauth,
|
||||||
|
patch("app.auth.settings") as mock_settings,
|
||||||
|
patch("app.auth._ensure_user_profile") as mock_ensure,
|
||||||
|
):
|
||||||
|
mock_oauth.authentik = mock_authentik
|
||||||
|
mock_settings.admin_group_name = "admin"
|
||||||
|
|
||||||
|
await oauth_callback(mock_request, db=mock_db)
|
||||||
|
|
||||||
|
# _ensure_user_profile should be called with the db and user_data
|
||||||
|
mock_ensure.assert_called_once()
|
||||||
|
call_args = mock_ensure.call_args
|
||||||
|
assert call_args[0][0] is mock_db
|
||||||
|
assert call_args[0][1]["sub"] == "oauth-sub-abc123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestEnsureUserProfile:
|
||||||
|
"""Tests for _ensure_user_profile() helper."""
|
||||||
|
|
||||||
|
def test_creates_profile_for_new_user(self):
|
||||||
|
"""New user_id should insert a UserProfile row."""
|
||||||
|
from app.auth import _ensure_user_profile
|
||||||
|
from app.models import UserProfile
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
user_data = {
|
||||||
|
"sub": "sub-xyz",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"name": "Alice",
|
||||||
|
"preferred_username": "alice",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensure_user_profile(mock_db, user_data)
|
||||||
|
|
||||||
|
mock_db.add.assert_called_once()
|
||||||
|
added_profile = mock_db.add.call_args[0][0]
|
||||||
|
assert isinstance(added_profile, UserProfile)
|
||||||
|
assert added_profile.user_id == "sub-xyz"
|
||||||
|
assert added_profile.display_name == "Alice"
|
||||||
|
mock_db.commit.assert_called_once()
|
||||||
|
|
||||||
|
def test_skips_existing_profile(self):
|
||||||
|
"""Existing profile should not be overwritten."""
|
||||||
|
from app.auth import _ensure_user_profile
|
||||||
|
from app.models import UserProfile
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
existing = UserProfile(user_id="sub-xyz", display_name="Old Name")
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = existing
|
||||||
|
|
||||||
|
user_data = {"sub": "sub-xyz", "name": "New Name"}
|
||||||
|
|
||||||
|
_ensure_user_profile(mock_db, user_data)
|
||||||
|
|
||||||
|
mock_db.add.assert_not_called()
|
||||||
|
mock_db.commit.assert_not_called()
|
||||||
|
|
||||||
|
def test_uses_preferred_username_fallback(self):
|
||||||
|
"""Falls back to preferred_username when sub is absent."""
|
||||||
|
from app.auth import _ensure_user_profile
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
user_data = {"preferred_username": "bob", "name": "Bob"}
|
||||||
|
|
||||||
|
_ensure_user_profile(mock_db, user_data)
|
||||||
|
|
||||||
|
added_profile = mock_db.add.call_args[0][0]
|
||||||
|
assert added_profile.user_id == "bob"
|
||||||
|
|
||||||
|
def test_uses_email_fallback(self):
|
||||||
|
"""Falls back to email when sub and preferred_username are absent."""
|
||||||
|
from app.auth import _ensure_user_profile
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
user_data = {"email": "carol@example.com", "name": "Carol"}
|
||||||
|
|
||||||
|
_ensure_user_profile(mock_db, user_data)
|
||||||
|
|
||||||
|
added_profile = mock_db.add.call_args[0][0]
|
||||||
|
assert added_profile.user_id == "carol@example.com"
|
||||||
|
|
||||||
|
def test_no_op_when_no_identifier(self):
|
||||||
|
"""Does nothing and logs a warning when no identifier is found."""
|
||||||
|
from app.auth import _ensure_user_profile
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
|
_ensure_user_profile(mock_db, {})
|
||||||
|
|
||||||
|
mock_db.add.assert_not_called()
|
||||||
|
mock_db.commit.assert_not_called()
|
||||||
|
|
||||||
|
def test_handles_db_exception_gracefully(self):
|
||||||
|
"""DB errors are caught; a rollback is issued and no exception propagates."""
|
||||||
|
from app.auth import _ensure_user_profile
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
mock_db.commit.side_effect = Exception("DB error")
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
_ensure_user_profile(mock_db, {"sub": "sub-error-test"})
|
||||||
|
|
||||||
|
mock_db.rollback.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestAuthFunction:
|
class TestAuthFunction:
|
||||||
|
|||||||
Reference in New Issue
Block a user