feat(auth): auto-create admin user profiles with highest tier and complimentary flag
- Add `is_complimentary` column to UserProfile model (migration 019) - Update `_ensure_user_profile` to accept `is_admin` param; admins get highest subscription tier, is_complimentary=True, onboarding skipped - Call `_ensure_user_profile` from all login paths (OAuth, local user, admin creds) - Add `is_complimentary` to UserProfileUpsert schema, response helpers, list_users, get_user, upsert_user_profile in admin API - Add complimentary toggle to admin users UI with gift badge in table - Write 18 new tests covering complimentary plan and admin auto-creation - Update SubscriptionTiers.md documentation Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -59,6 +59,11 @@ class UserProfileUpsert(BaseModel):
|
||||
subscription_billing_cycle: str = Field(default="monthly", pattern="^(monthly|yearly)$")
|
||||
subscription_period_start: datetime | None = None
|
||||
allow_overage: bool = False
|
||||
is_complimentary: bool = Field(
|
||||
default=False,
|
||||
description="When True the user is on a complimentary (uncharged) plan — they keep all tier "
|
||||
"quota benefits but are never billed via Stripe.",
|
||||
)
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
@@ -74,6 +79,7 @@ class UserProfileResponse(BaseModel):
|
||||
subscription_billing_cycle: str
|
||||
subscription_period_start: str | None
|
||||
allow_overage: bool
|
||||
is_complimentary: bool
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
|
||||
@@ -92,6 +98,7 @@ class UserSummary(BaseModel):
|
||||
subscription_billing_cycle: str | None
|
||||
subscription_period_start: str | None
|
||||
allow_overage: bool
|
||||
is_complimentary: bool
|
||||
profile_id: int | None
|
||||
document_count: int
|
||||
last_upload: str | None
|
||||
@@ -121,6 +128,7 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
|
||||
if profile.subscription_period_start
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage),
|
||||
"is_complimentary": bool(profile.is_complimentary),
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
@@ -194,6 +202,7 @@ def list_users(
|
||||
if (profile and profile.subscription_period_start)
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage) if profile else False,
|
||||
"is_complimentary": bool(profile.is_complimentary) if profile else False,
|
||||
"profile_id": profile.id if profile else None,
|
||||
"document_count": doc_row.doc_count if doc_row else 0,
|
||||
"last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
|
||||
@@ -235,6 +244,7 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
if (profile and profile.subscription_period_start)
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage) if profile else False,
|
||||
"is_complimentary": bool(profile.is_complimentary) if profile else False,
|
||||
"profile_id": profile.id if profile else None,
|
||||
"document_count": doc_count,
|
||||
"last_upload": last_upload,
|
||||
@@ -265,6 +275,7 @@ def upsert_user_profile(
|
||||
profile.subscription_billing_cycle = body.subscription_billing_cycle
|
||||
profile.subscription_period_start = body.subscription_period_start
|
||||
profile.allow_overage = body.allow_overage
|
||||
profile.is_complimentary = body.is_complimentary
|
||||
if body.subscription_tier is not None:
|
||||
from app.utils.subscription import TIERS
|
||||
|
||||
|
||||
+58
-10
@@ -127,18 +127,35 @@ async def oauth_login(request: Request):
|
||||
return await oauth.authentik.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
def _ensure_user_profile(db: Session, user_data: dict) -> None:
|
||||
"""Create a UserProfile row for *user_data* if one does not yet exist.
|
||||
def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -> None:
|
||||
"""Create or update a UserProfile row for *user_data*.
|
||||
|
||||
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.
|
||||
For regular users, an existing profile is left unchanged so that
|
||||
admin-managed settings (tier, limits, etc.) are preserved across logins.
|
||||
|
||||
For admin users (*is_admin=True*) the following rules apply:
|
||||
- If no profile exists: one is created with the highest subscription tier,
|
||||
``is_complimentary=True``, and ``onboarding_completed=True`` so that
|
||||
admins skip the first-time setup wizard.
|
||||
- If a profile already exists: ``is_complimentary`` is set to ``True``
|
||||
and, when the current tier is ``"free"``, the tier is upgraded to the
|
||||
highest available plan. Other admin-managed settings are left intact.
|
||||
|
||||
Args:
|
||||
db: Active database session.
|
||||
user_data: Mapping of user attributes as returned by the OAuth provider
|
||||
or built by :func:`app.utils.local_auth.build_session_user`.
|
||||
is_admin: When ``True``, apply admin-specific defaults on first login
|
||||
and ensure the complimentary flag is always set.
|
||||
"""
|
||||
from app.models import UserProfile
|
||||
from app.utils.subscription import TIER_ORDER
|
||||
|
||||
highest_tier = TIER_ORDER[-1]
|
||||
|
||||
user_id = (
|
||||
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
|
||||
@@ -151,13 +168,41 @@ def _ensure_user_profile(db: Session, user_data: dict) -> None:
|
||||
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)
|
||||
profile = UserProfile(
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
subscription_tier=highest_tier if is_admin else "free",
|
||||
is_complimentary=is_admin,
|
||||
onboarding_completed=is_admin,
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
logger.info("Auto-created UserProfile for user_id=%s", user_id)
|
||||
logger.info(
|
||||
"Auto-created UserProfile for user_id=%s (admin=%s, tier=%s)",
|
||||
user_id,
|
||||
is_admin,
|
||||
highest_tier if is_admin else "free",
|
||||
)
|
||||
elif is_admin:
|
||||
# Ensure existing admin profiles always have complimentary flag set.
|
||||
# Also upgrade from free tier to highest if still on default.
|
||||
changed = False
|
||||
if not existing.is_complimentary:
|
||||
existing.is_complimentary = True
|
||||
changed = True
|
||||
if (existing.subscription_tier or "free") == "free":
|
||||
existing.subscription_tier = highest_tier
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Updated admin UserProfile for user_id=%s (complimentary=True, tier=%s)",
|
||||
user_id,
|
||||
existing.subscription_tier,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to auto-create UserProfile for user_id=%s", user_id)
|
||||
logger.exception("Failed to auto-create/update UserProfile for user_id=%s", user_id)
|
||||
|
||||
|
||||
async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
@@ -193,7 +238,7 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
request.session["user"] = user_data
|
||||
|
||||
# Auto-create or update UserProfile so the user appears in admin user management
|
||||
_ensure_user_profile(db, user_data)
|
||||
_ensure_user_profile(db, user_data, is_admin=is_admin)
|
||||
|
||||
# Log the successful authentication
|
||||
logger.info("[SECURITY] OAUTH_LOGIN_SUCCESS user=%s admin=%s", user_data.get("email", "unknown"), is_admin)
|
||||
@@ -251,6 +296,7 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
user_data = _build_session_user(local_user)
|
||||
request.session["user"] = user_data
|
||||
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email)
|
||||
_ensure_user_profile(db, user_data, is_admin=bool(local_user.is_admin))
|
||||
profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first()
|
||||
if profile and not profile.onboarding_completed:
|
||||
post_onboarding = request.session.pop("redirect_after_login", "/upload")
|
||||
@@ -261,7 +307,7 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
# --- Admin credentials (always available as a fallback / single-user mode) ---
|
||||
if username == settings.admin_username and password == settings.admin_password:
|
||||
request.session["user"] = {
|
||||
admin_user_data = {
|
||||
"id": "admin",
|
||||
"name": "Administrator",
|
||||
"email": f"{username}@local.docuelevate",
|
||||
@@ -269,7 +315,9 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
"picture": "/static/images/default-avatar.svg",
|
||||
"is_admin": True,
|
||||
}
|
||||
request.session["user"] = admin_user_data
|
||||
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username)
|
||||
_ensure_user_profile(db, admin_user_data, is_admin=True)
|
||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
else:
|
||||
|
||||
@@ -238,6 +238,10 @@ class UserProfile(Base):
|
||||
subscription_period_start = Column(DateTime(timezone=True), nullable=True)
|
||||
allow_overage = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
# When True, the user is on a complimentary (uncharged) plan — they keep all tier
|
||||
# quota benefits but are never billed via Stripe. Automatically set for admin users.
|
||||
is_complimentary = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
# Onboarding tracking (added in migration 017)
|
||||
onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
onboarding_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -55,6 +55,35 @@ When a user's `subscription_billing_cycle` is set to `yearly`:
|
||||
|
||||
Setting `UserProfile.allow_overage = True` bypasses monthly quota checks entirely for that user. Usage is still tracked so future billing integrations can charge retroactively. This field is not yet exposed in the admin UI.
|
||||
|
||||
## is_complimentary Flag (Complimentary Plans)
|
||||
|
||||
Setting `UserProfile.is_complimentary = True` marks a user as being on a **complimentary (uncharged) plan**. The user retains all quota benefits of their assigned subscription tier but is **never billed via Stripe**. This is useful for:
|
||||
|
||||
- **Admin accounts** — automatically set on every admin user profile at login time.
|
||||
- **Gifted access** — granting full plan benefits to partners, testers, or sponsored users.
|
||||
|
||||
### Admin Auto-Provisioning
|
||||
|
||||
When an admin user logs in for the first time (via OAuth, local account, or the built-in admin credentials), DocuElevate automatically:
|
||||
|
||||
1. Creates a `UserProfile` row if one does not already exist.
|
||||
2. Assigns the **highest available subscription tier** (currently `business`).
|
||||
3. Sets `is_complimentary = True` so the account is never billed.
|
||||
4. Sets `onboarding_completed = True` so admins skip the first-time setup wizard.
|
||||
|
||||
On subsequent logins for existing admin profiles:
|
||||
- `is_complimentary` is ensured to be `True`.
|
||||
- If the profile was still on the `free` tier it is upgraded to the highest tier.
|
||||
- All other admin-managed settings (custom limits, notes, etc.) are preserved.
|
||||
|
||||
### Managing via Admin UI
|
||||
|
||||
The **User Management** page (`/admin/users`) shows a green gift icon (🎁) next to the plan badge for any user with `is_complimentary = True`. The toggle is available in the user edit modal under **Billing**.
|
||||
|
||||
### API Field
|
||||
|
||||
`is_complimentary` is exposed in the `PUT /api/admin/users/{user_id}` body and in all user detail responses.
|
||||
|
||||
## Plan Designer
|
||||
|
||||
Navigate to `/admin/plans` (admin only) to:
|
||||
|
||||
@@ -140,6 +140,14 @@
|
||||
></i>
|
||||
<span x-text="user.subscription_tier || 'free'"></span>
|
||||
</span>
|
||||
<span
|
||||
x-show="user.is_complimentary"
|
||||
class="ml-1 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700"
|
||||
title="Complimentary — not billed"
|
||||
>
|
||||
<i class="fas fa-gift" aria-hidden="true"></i>
|
||||
<span class="sr-only">Complimentary plan</span>
|
||||
</span>
|
||||
</td>
|
||||
<!-- Upload limit -->
|
||||
<td class="px-4 py-3 text-sm text-center text-gray-700">
|
||||
@@ -375,6 +383,25 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Complimentary plan toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_complimentary"
|
||||
class="sr-only peer"
|
||||
id="modal-complimentary"
|
||||
role="switch"
|
||||
:aria-checked="form.is_complimentary"
|
||||
/>
|
||||
<div class="w-10 h-6 bg-gray-200 peer-focus:ring-2 peer-focus:ring-blue-400 rounded-full peer peer-checked:bg-green-500 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-4"></div>
|
||||
</label>
|
||||
<label for="modal-complimentary" class="text-sm font-medium text-gray-700">
|
||||
Complimentary plan
|
||||
<span class="text-xs text-gray-400 font-normal">(user keeps tier benefits but is never billed — set automatically for admin accounts)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -472,6 +499,7 @@ function adminUsersApp() {
|
||||
subscription_tier: 'free',
|
||||
subscription_billing_cycle: 'monthly',
|
||||
subscription_period_start: null,
|
||||
is_complimentary: false,
|
||||
},
|
||||
|
||||
// Delete modal
|
||||
@@ -524,7 +552,7 @@ function adminUsersApp() {
|
||||
openCreateModal() {
|
||||
this.isCreate = true;
|
||||
this.modalTitle = 'Add User Profile';
|
||||
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free', subscription_billing_cycle: 'monthly', subscription_period_start: null };
|
||||
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free', subscription_billing_cycle: 'monthly', subscription_period_start: null, is_complimentary: false };
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
@@ -541,6 +569,7 @@ function adminUsersApp() {
|
||||
subscription_tier: user.subscription_tier || 'free',
|
||||
subscription_billing_cycle: user.subscription_billing_cycle || 'monthly',
|
||||
subscription_period_start: user.subscription_period_start ? user.subscription_period_start.substring(0, 10) : null,
|
||||
is_complimentary: !!user.is_complimentary,
|
||||
};
|
||||
this.modalOpen = true;
|
||||
},
|
||||
@@ -555,6 +584,7 @@ function adminUsersApp() {
|
||||
notes: this.form.notes || null,
|
||||
is_blocked: !!this.form.is_blocked,
|
||||
subscription_tier: this.form.subscription_tier || 'free',
|
||||
is_complimentary: !!this.form.is_complimentary,
|
||||
};
|
||||
const uid = encodeURIComponent(this.form.user_id);
|
||||
const resp = await fetch(`/api/admin/users/${uid}`, {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Add is_complimentary column to user_profiles
|
||||
|
||||
Revision ID: 019_add_is_complimentary
|
||||
Revises: 018_add_local_users_and_billing
|
||||
Create Date: 2026-03-07
|
||||
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "019_add_is_complimentary"
|
||||
down_revision: Union[str, None] = "018_add_local_users_and_billing"
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add is_complimentary column to user_profiles."""
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("is_complimentary", sa.Boolean(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove is_complimentary column from user_profiles."""
|
||||
op.drop_column("user_profiles", "is_complimentary")
|
||||
@@ -477,3 +477,189 @@ class TestUserProfileModel:
|
||||
with pytest.raises(IntegrityError):
|
||||
au_session.commit()
|
||||
au_session.rollback()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Complimentary plan tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComplimentaryPlan:
|
||||
"""Tests for the is_complimentary field and admin auto-creation logic."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_profile_with_complimentary_flag(self, au_client, au_session):
|
||||
"""PUT can create a profile with is_complimentary=True."""
|
||||
resp = au_client.put(
|
||||
"/api/admin/users/comp@example.com",
|
||||
json={"subscription_tier": "business", "is_complimentary": True, "is_blocked": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_complimentary"] is True
|
||||
assert data["subscription_tier"] == "business"
|
||||
|
||||
profile = au_session.query(UserProfile).filter_by(user_id="comp@example.com").first()
|
||||
assert profile is not None
|
||||
assert profile.is_complimentary is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_profile_set_complimentary(self, au_client, au_session):
|
||||
"""PUT can toggle is_complimentary on an existing profile."""
|
||||
_make_profile(au_session, "toggle@example.com", is_complimentary=False)
|
||||
|
||||
resp = au_client.put(
|
||||
"/api/admin/users/toggle@example.com",
|
||||
json={"is_blocked": False, "is_complimentary": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_complimentary"] is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_users_includes_complimentary_field(self, au_client, au_session):
|
||||
"""GET /api/admin/users/ returns is_complimentary per user."""
|
||||
_make_profile(au_session, "complist@example.com", is_complimentary=True)
|
||||
|
||||
resp = au_client.get("/api/admin/users/")
|
||||
assert resp.status_code == 200
|
||||
users = {u["user_id"]: u for u in resp.json()["users"]}
|
||||
assert "complist@example.com" in users
|
||||
assert users["complist@example.com"]["is_complimentary"] is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_user_includes_complimentary_field(self, au_client, au_session):
|
||||
"""GET /api/admin/users/<id> returns is_complimentary in profile."""
|
||||
_make_profile(au_session, "getcomp@example.com", is_complimentary=True, subscription_tier="business")
|
||||
|
||||
resp = au_client.get("/api/admin/users/getcomp%40example.com")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_complimentary"] is True
|
||||
assert data["profile"]["is_complimentary"] is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_complimentary_defaults_to_false(self, au_client, au_session):
|
||||
"""Newly created profiles have is_complimentary=False by default."""
|
||||
resp = au_client.put(
|
||||
"/api/admin/users/nocomp@example.com",
|
||||
json={"is_blocked": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_complimentary"] is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_profile_model_complimentary_column(self, au_session):
|
||||
"""UserProfile model stores is_complimentary correctly."""
|
||||
profile = UserProfile(user_id="modelcomp@example.com", is_complimentary=True)
|
||||
au_session.add(profile)
|
||||
au_session.commit()
|
||||
au_session.refresh(profile)
|
||||
assert profile.is_complimentary is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ensure_user_profile admin auto-creation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnsureUserProfileAdmin:
|
||||
"""Tests for _ensure_user_profile admin-specific behaviour."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_admin_login_creates_highest_tier_profile(self, au_session):
|
||||
"""Admin first login creates a profile with the highest subscription tier."""
|
||||
from app.auth import _ensure_user_profile
|
||||
from app.utils.subscription import TIER_ORDER
|
||||
|
||||
user_data = {
|
||||
"preferred_username": "admin",
|
||||
"email": "admin@local.docuelevate",
|
||||
"name": "Administrator",
|
||||
"is_admin": True,
|
||||
}
|
||||
_ensure_user_profile(au_session, user_data, is_admin=True)
|
||||
|
||||
# user_id uses preferred_username (sub not provided)
|
||||
profile = au_session.query(UserProfile).filter_by(user_id="admin").first()
|
||||
assert profile is not None
|
||||
assert profile.subscription_tier == TIER_ORDER[-1]
|
||||
assert profile.is_complimentary is True
|
||||
assert profile.onboarding_completed is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_regular_user_login_creates_free_profile(self, au_session):
|
||||
"""Regular user login creates a profile with the free tier."""
|
||||
from app.auth import _ensure_user_profile
|
||||
|
||||
user_data = {
|
||||
"preferred_username": "regular",
|
||||
"email": "user@example.com",
|
||||
"name": "Regular User",
|
||||
}
|
||||
_ensure_user_profile(au_session, user_data, is_admin=False)
|
||||
|
||||
# user_id uses preferred_username (sub not provided)
|
||||
profile = au_session.query(UserProfile).filter_by(user_id="regular").first()
|
||||
assert profile is not None
|
||||
assert profile.subscription_tier == "free"
|
||||
assert profile.is_complimentary is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_admin_login_sets_complimentary_on_existing_profile(self, au_session):
|
||||
"""Existing admin profile gets is_complimentary=True on login."""
|
||||
existing = UserProfile(user_id="existadmin", is_complimentary=False, subscription_tier="starter")
|
||||
au_session.add(existing)
|
||||
au_session.commit()
|
||||
|
||||
from app.auth import _ensure_user_profile
|
||||
|
||||
user_data = {"preferred_username": "existadmin", "email": "ea@example.com"}
|
||||
_ensure_user_profile(au_session, user_data, is_admin=True)
|
||||
|
||||
au_session.refresh(existing)
|
||||
assert existing.is_complimentary is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_admin_login_does_not_downgrade_existing_tier(self, au_session):
|
||||
"""Existing admin profile with a paid tier keeps that tier on re-login."""
|
||||
from app.auth import _ensure_user_profile
|
||||
from app.utils.subscription import TIER_ORDER
|
||||
|
||||
highest = TIER_ORDER[-1]
|
||||
existing = UserProfile(user_id="toptieradmin", is_complimentary=False, subscription_tier=highest)
|
||||
au_session.add(existing)
|
||||
au_session.commit()
|
||||
|
||||
user_data = {"preferred_username": "toptieradmin", "email": "tt@example.com"}
|
||||
_ensure_user_profile(au_session, user_data, is_admin=True)
|
||||
|
||||
au_session.refresh(existing)
|
||||
assert existing.subscription_tier == highest
|
||||
assert existing.is_complimentary is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_admin_login_upgrades_free_tier_on_existing_profile(self, au_session):
|
||||
"""Existing admin profile on free tier gets upgraded to highest tier."""
|
||||
from app.auth import _ensure_user_profile
|
||||
from app.utils.subscription import TIER_ORDER
|
||||
|
||||
existing = UserProfile(user_id="freeadmin", is_complimentary=False, subscription_tier="free")
|
||||
au_session.add(existing)
|
||||
au_session.commit()
|
||||
|
||||
user_data = {"preferred_username": "freeadmin", "email": "fa@example.com"}
|
||||
_ensure_user_profile(au_session, user_data, is_admin=True)
|
||||
|
||||
au_session.refresh(existing)
|
||||
assert existing.subscription_tier == TIER_ORDER[-1]
|
||||
assert existing.is_complimentary is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ensure_user_profile_no_identifier_logs_warning(self, au_session):
|
||||
"""_ensure_user_profile logs a warning when no stable user id is present."""
|
||||
from app.auth import _ensure_user_profile
|
||||
|
||||
_ensure_user_profile(au_session, {}, is_admin=False)
|
||||
# No profile should have been created
|
||||
count = au_session.query(UserProfile).count()
|
||||
assert count == 0
|
||||
|
||||
@@ -278,8 +278,10 @@ class TestAuthEndpoint:
|
||||
mock_form_data = {"username": "admin", "password": "secret123"}
|
||||
mock_request.form = AsyncMock(return_value=mock_form_data)
|
||||
mock_request.session = {}
|
||||
mock_db = MagicMock()
|
||||
|
||||
result = await auth(mock_request)
|
||||
with patch("app.auth._ensure_user_profile"):
|
||||
result = await auth(mock_request, db=mock_db)
|
||||
|
||||
# Verify redirect to upload page
|
||||
assert isinstance(result, RedirectResponse)
|
||||
@@ -305,8 +307,9 @@ class TestAuthEndpoint:
|
||||
mock_form_data = {"username": "admin", "password": "wrong_password"}
|
||||
mock_request.form = AsyncMock(return_value=mock_form_data)
|
||||
mock_request.session = {}
|
||||
mock_db = MagicMock()
|
||||
|
||||
result = await auth(mock_request)
|
||||
result = await auth(mock_request, db=mock_db)
|
||||
|
||||
# Verify redirect to login with error
|
||||
assert isinstance(result, RedirectResponse)
|
||||
@@ -327,8 +330,10 @@ class TestAuthEndpoint:
|
||||
mock_form_data = {"username": "admin", "password": "secret123"}
|
||||
mock_request.form = AsyncMock(return_value=mock_form_data)
|
||||
mock_request.session = {"redirect_after_login": "/protected/page"}
|
||||
mock_db = MagicMock()
|
||||
|
||||
result = await auth(mock_request)
|
||||
with patch("app.auth._ensure_user_profile"):
|
||||
result = await auth(mock_request, db=mock_db)
|
||||
|
||||
# Verify redirect to saved URL
|
||||
assert isinstance(result, RedirectResponse)
|
||||
|
||||
Reference in New Issue
Block a user