diff --git a/app/api/__init__.py b/app/api/__init__.py index 28b9fbba..9936d7f4 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -32,6 +32,7 @@ from app.api.openai import router as openai_router from app.api.pipelines import router as pipelines_router from app.api.plans import router as plans_router from app.api.process import router as process_router +from app.api.profile import router as profile_router from app.api.queue import router as queue_router from app.api.saved_searches import router as saved_searches_router from app.api.scheduled_jobs import router as scheduled_jobs_router @@ -83,6 +84,7 @@ router.include_router(plans_router) router.include_router(onboarding_router) router.include_router(billing_router) router.include_router(pipelines_router) +router.include_router(profile_router) router.include_router(imap_accounts_router) router.include_router(imap_profiles_router) router.include_router(integrations_router) diff --git a/app/api/profile.py b/app/api/profile.py new file mode 100644 index 00000000..eb3b0e5b --- /dev/null +++ b/app/api/profile.py @@ -0,0 +1,312 @@ +"""User self-service profile API. + +Provides endpoints for the authenticated user to view and update their own +profile settings without requiring admin access. + +Routes: + GET /api/profile — read current user's profile + PATCH /api/profile — update display name, language, theme + POST /api/profile/avatar — upload a new profile picture (JPEG/PNG/GIF/WebP, max 2 MB) + DELETE /api/profile/avatar — remove custom avatar (reverts to Gravatar) + POST /api/profile/change-password — change password (local-auth users only) +""" + +from __future__ import annotations + +import base64 +import logging +from hashlib import md5 +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth import require_login +from app.database import get_db +from app.models import LocalUser, UserProfile +from app.utils.i18n import SUPPORTED_LANGUAGE_CODES + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/profile", tags=["profile"]) + +DbSession = Annotated[Session, Depends(get_db)] + +# Maximum avatar upload size: 2 MB +_MAX_AVATAR_BYTES = 2 * 1024 * 1024 + +# Allowed MIME types for avatar uploads +_ALLOWED_AVATAR_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} + +# Valid theme values +_VALID_THEMES = {"light", "dark", "system"} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_user_id(request: Request) -> str: + """Return the stable user identifier from the session. + + Raises HTTP 401 if no user is logged in. + """ + user = request.session.get("user") + if not user or not isinstance(user, dict): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + uid = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id") + if not uid: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Cannot determine user identity") + return uid + + +def _gravatar_url(email: str | None) -> str: + """Generate a Gravatar URL for *email*, falling back to identicon.""" + if not email: + return "https://www.gravatar.com/avatar/?d=identicon" + # MD5 used for Gravatar URL generation only — not for security + h = md5(email.strip().lower().encode(), usedforsecurity=False).hexdigest() + return f"https://www.gravatar.com/avatar/{h}?d=identicon" + + +def _get_or_create_profile(db: Session, user_id: str) -> UserProfile: + """Return the UserProfile for *user_id*, creating a stub if one doesn't exist.""" + profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first() + if profile is None: + profile = UserProfile(user_id=user_id) + db.add(profile) + try: + db.commit() + db.refresh(profile) + except Exception: + db.rollback() + raise + return profile + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class ProfileResponse(BaseModel): + """Response body for GET /api/profile.""" + + user_id: str + display_name: str | None + contact_email: str | None + preferred_language: str | None + preferred_theme: str | None + avatar_url: str + """Gravatar URL or ``data:`` URI for a custom uploaded avatar.""" + is_local_user: bool + """True when the account was created via local email/password sign-up.""" + + +class ProfileUpdateRequest(BaseModel): + """Request body for PATCH /api/profile.""" + + display_name: str | None = Field(default=None, max_length=255, description="Human-readable display name") + contact_email: str | None = Field(default=None, max_length=255, description="Contact / notification e-mail") + preferred_language: str | None = Field(default=None, description="ISO 639-1 language code, e.g. 'en', 'de'") + preferred_theme: str | None = Field(default=None, description="Colour scheme: 'light', 'dark', or 'system'") + + +class ChangePasswordRequest(BaseModel): + """Request body for POST /api/profile/change-password.""" + + current_password: str = Field(..., min_length=1, max_length=128) + new_password: str = Field(..., min_length=8, max_length=128) + new_password_confirm: str = Field(..., min_length=8, max_length=128) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("", response_model=ProfileResponse) +@require_login +async def get_profile(request: Request, db: DbSession) -> ProfileResponse: + """Return the current user's profile settings.""" + user_id = _get_user_id(request) + profile = _get_or_create_profile(db, user_id) + + session_user = request.session.get("user", {}) + email = session_user.get("email") if isinstance(session_user, dict) else None + + # Determine avatar: prefer stored data, fall back to Gravatar + avatar_url = profile.avatar_data if profile.avatar_data else _gravatar_url(email) # type: ignore[attr-defined] + + # Check whether this is a local (email/password) account + is_local = db.query(LocalUser).filter(LocalUser.username == user_id).first() is not None + + return ProfileResponse( + user_id=user_id, + display_name=profile.display_name, # type: ignore[arg-type] + contact_email=profile.contact_email, # type: ignore[arg-type] + preferred_language=profile.preferred_language, # type: ignore[arg-type] + preferred_theme=profile.preferred_theme, # type: ignore[arg-type] + avatar_url=avatar_url, + is_local_user=is_local, + ) + + +@router.patch("", response_model=ProfileResponse) +@require_login +async def update_profile(body: ProfileUpdateRequest, request: Request, db: DbSession) -> ProfileResponse: + """Update the current user's editable profile settings.""" + user_id = _get_user_id(request) + profile = _get_or_create_profile(db, user_id) + + # Validate language code + if body.preferred_language is not None: + lang = body.preferred_language.lower().strip() + if lang and lang not in SUPPORTED_LANGUAGE_CODES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unsupported language code: {lang}", + ) + profile.preferred_language = lang or None # type: ignore[assignment] + + # Validate theme + if body.preferred_theme is not None: + theme = body.preferred_theme.lower().strip() + if theme and theme not in _VALID_THEMES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid theme: {theme}. Must be one of: {', '.join(sorted(_VALID_THEMES))}", + ) + profile.preferred_theme = theme or None # type: ignore[assignment] + + if body.display_name is not None: + profile.display_name = body.display_name.strip() or None # type: ignore[assignment] + + if body.contact_email is not None: + profile.contact_email = body.contact_email.strip() or None # type: ignore[assignment] + + try: + db.commit() + db.refresh(profile) + except Exception: + db.rollback() + raise + + session_user = request.session.get("user", {}) + email = session_user.get("email") if isinstance(session_user, dict) else None + avatar_url = profile.avatar_data if profile.avatar_data else _gravatar_url(email) # type: ignore[attr-defined] + is_local = db.query(LocalUser).filter(LocalUser.username == user_id).first() is not None + + return ProfileResponse( + user_id=user_id, + display_name=profile.display_name, # type: ignore[arg-type] + contact_email=profile.contact_email, # type: ignore[arg-type] + preferred_language=profile.preferred_language, # type: ignore[arg-type] + preferred_theme=profile.preferred_theme, # type: ignore[arg-type] + avatar_url=avatar_url, + is_local_user=is_local, + ) + + +@router.post("/avatar", status_code=status.HTTP_200_OK) +@require_login +async def upload_avatar( + request: Request, + db: DbSession, + file: UploadFile = File(..., description="Profile picture (JPEG, PNG, GIF or WebP; max 2 MB)"), +) -> dict: + """Upload a new profile picture. + + The image is stored as a base64-encoded data URL in ``UserProfile.avatar_data``. + Accepts JPEG, PNG, GIF, or WebP files up to 2 MB. + """ + user_id = _get_user_id(request) + + content_type = (file.content_type or "").lower() + if content_type not in _ALLOWED_AVATAR_TYPES: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail=f"Unsupported image type '{content_type}'. Allowed: JPEG, PNG, GIF, WebP.", + ) + + raw = await file.read(_MAX_AVATAR_BYTES + 1) + if len(raw) > _MAX_AVATAR_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="Avatar image must be 2 MB or smaller.", + ) + + b64 = base64.b64encode(raw).decode("ascii") + data_url = f"data:{content_type};base64,{b64}" + + profile = _get_or_create_profile(db, user_id) + profile.avatar_data = data_url # type: ignore[assignment] + try: + db.commit() + except Exception: + db.rollback() + raise + + return {"avatar_url": data_url} + + +@router.delete("/avatar", status_code=status.HTTP_200_OK) +@require_login +async def delete_avatar(request: Request, db: DbSession) -> dict: + """Remove the custom avatar and revert to the Gravatar fallback.""" + user_id = _get_user_id(request) + profile = _get_or_create_profile(db, user_id) + profile.avatar_data = None # type: ignore[assignment] + try: + db.commit() + except Exception: + db.rollback() + raise + + session_user = request.session.get("user", {}) + email = session_user.get("email") if isinstance(session_user, dict) else None + return {"avatar_url": _gravatar_url(email)} + + +@router.post("/change-password", status_code=status.HTTP_200_OK) +@require_login +async def change_password(body: ChangePasswordRequest, request: Request, db: DbSession) -> dict: + """Change the password for local (email/password) accounts. + + Raises 403 if the account is not a local account or the current password is wrong. + Raises 422 if the new passwords do not match. + """ + from app.utils.local_auth import hash_password, verify_password + + user_id = _get_user_id(request) + + local_user = db.query(LocalUser).filter(LocalUser.username == user_id).first() + if local_user is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Password change is only available for local accounts.", + ) + + if not verify_password(body.current_password, local_user.hashed_password): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Current password is incorrect.", + ) + + if body.new_password != body.new_password_confirm: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="New passwords do not match.", + ) + + local_user.hashed_password = hash_password(body.new_password) + try: + db.commit() + except Exception: + db.rollback() + raise + + logger.info("Password changed for local user: %s", user_id) + return {"detail": "Password changed successfully."} diff --git a/app/api/user.py b/app/api/user.py index 4fa0ce36..2f8cd926 100644 --- a/app/api/user.py +++ b/app/api/user.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from app.auth import require_login from app.database import get_db -from app.models import FileRecord +from app.models import FileRecord, UserProfile # Set up logging logger = logging.getLogger(__name__) @@ -22,7 +22,7 @@ router = APIRouter() DbSession = Annotated[Session, Depends(get_db)] -async def whoami_handler(request: Request): +async def whoami_handler(request: Request, db: Session): """ Returns user info if logged in, else 401. """ @@ -41,20 +41,33 @@ async def whoami_handler(request: Request): # Add the gravatar URL to the user object instead of creating a new response user_response = user.copy() # Create a copy to avoid modifying the session - user_response["picture"] = gravatar_url + + # Check if the user has a custom avatar stored in their profile + user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id") + if user_id: + try: + profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first() + if profile and profile.avatar_data: + user_response["picture"] = profile.avatar_data + else: + user_response["picture"] = gravatar_url + except Exception: + user_response["picture"] = gravatar_url + else: + user_response["picture"] = gravatar_url return user_response # Register the same handler under two different paths @router.get("/whoami") -async def whoami(request: Request): - return await whoami_handler(request) +async def whoami(request: Request, db: DbSession): + return await whoami_handler(request, db) @router.get("/auth/whoami") -async def auth_whoami(request: Request): - return await whoami_handler(request) +async def auth_whoami(request: Request, db: DbSession): + return await whoami_handler(request, db) @router.get("/users/search") diff --git a/app/models.py b/app/models.py index 5ba06e81..bb1ee985 100644 --- a/app/models.py +++ b/app/models.py @@ -281,6 +281,13 @@ class UserProfile(Base): # NULL means "auto-detect from browser Accept-Language header" preferred_language = Column(String(10), nullable=True) + # UI colour scheme preference: "light" | "dark" | "system" (NULL = "system") + preferred_theme = Column(String(10), nullable=True) + + # Custom profile avatar stored as a base64 data-URL (e.g. "data:image/png;base64,...") + # NULL means use the Gravatar fallback derived from the user's e-mail address. + avatar_data = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/app/views/__init__.py b/app/views/__init__.py index b2814ff5..b25a3be8 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -25,6 +25,7 @@ from app.views.onboarding import router as onboarding_router from app.views.onedrive import router as onedrive_router from app.views.pipelines import router as pipelines_router # Processing pipelines from app.views.plans import router as plans_router # Admin Plan Designer +from app.views.profile import router as profile_router # User self-service profile from app.views.queue import router as queue_router from app.views.scheduled_jobs import router as scheduled_jobs_router # Scheduled batch jobs from app.views.search import router as search_router @@ -58,6 +59,7 @@ router.include_router(subscriptions_router) # Pricing + subscription pages router.include_router(plans_router) # Admin Plan Designer router.include_router(onboarding_router) # User onboarding wizard router.include_router(pipelines_router) # Processing pipelines +router.include_router(profile_router) # User self-service profile settings router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts router.include_router(integrations_router) # Unified integrations dashboard router.include_router(notifications_router) # User notification dashboard diff --git a/app/views/profile.py b/app/views/profile.py new file mode 100644 index 00000000..d6885cda --- /dev/null +++ b/app/views/profile.py @@ -0,0 +1,40 @@ +"""View route for the user self-service profile settings page. + +Route: + GET /profile — renders the profile settings HTML page (requires login) +""" + +from __future__ import annotations + +import logging + +from fastapi import Depends, Request +from sqlalchemy.orm import Session + +from app.models import UserProfile +from app.utils.i18n import SUPPORTED_LANGUAGES +from app.views.base import APIRouter, get_db, require_login, templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/profile", include_in_schema=False) +@require_login +async def profile_page(request: Request, db: Session = Depends(get_db)): + """Serve the user profile settings page.""" + user = request.session.get("user") or {} + user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id") + + profile = None + if user_id: + profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first() + + return templates.TemplateResponse( + "profile.html", + { + "request": request, + "profile": profile, + "supported_languages": SUPPORTED_LANGUAGES, + }, + ) diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index 573cc098..b09beb42 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -206,6 +206,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') { // Links section const linksDiv = document.createElement('div'); linksDiv.className = 'py-1'; + linksDiv.appendChild( + _makeMenuLink('/profile', 'fas fa-user-circle text-blue-400', 'Profile Settings', 'text-gray-700') + ); linksDiv.appendChild( _makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700') ); @@ -272,6 +275,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') { userRow.appendChild(mUserInfo); mobileAuthSection.appendChild(userRow); + // Profile Settings link + const profileLink = document.createElement('a'); + profileLink.href = '/profile'; + profileLink.className = + 'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50'; + const profileIcon = document.createElement('i'); + profileIcon.className = 'fas fa-user-circle mr-2 text-blue-400'; + profileIcon.setAttribute('aria-hidden', 'true'); + profileLink.appendChild(profileIcon); + profileLink.appendChild(document.createTextNode('Profile Settings')); + mobileAuthSection.appendChild(profileLink); + // Subscription link const subLink = document.createElement('a'); subLink.href = '/subscription'; diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html new file mode 100644 index 00000000..75c04d39 --- /dev/null +++ b/frontend/templates/profile.html @@ -0,0 +1,517 @@ +{% extends "base.html" %} +{% block title %}Profile Settings – DocuElevate{% endblock %} + +{% block content %} +
+ + +
+

+ + Profile Settings +

+

+ Manage your display name, avatar, language, and theme preferences. +

+
+ + + + + +
+

+ Profile Picture +

+ +
+ +
+ Your profile picture + +
+ + +
+

+ Upload a JPEG, PNG, GIF, or WebP image up to 2 MB. + If no custom picture is set, your Gravatar is shown. +

+ +

+
+
+
+ + +
+

+ General Information +

+ +
+ +
+ + +

+ Leave blank to use your account username or email. +

+
+ + +
+ + +

+ Used for system notifications. This does not change your login e-mail. +

+
+
+
+ + +
+

+ Preferences +

+ +
+ +
+ + +

+ Choose your preferred interface language. "Auto-detect" follows your browser settings. +

+
+ + +
+ + Colour Scheme + +
+ + + +
+

+ "System Default" follows your device's dark-mode setting. +

+
+
+
+ + +
+ +
+ + + + +
+ + +{% endblock %} diff --git a/migrations/versions/034_add_user_profile_settings.py b/migrations/versions/034_add_user_profile_settings.py new file mode 100644 index 00000000..104bf814 --- /dev/null +++ b/migrations/versions/034_add_user_profile_settings.py @@ -0,0 +1,47 @@ +"""Add preferred_theme and avatar_data columns to user_profiles. + +Revision ID: 034_add_user_profile_settings +Revises: 033_add_imap_ingestion_profiles +Create Date: 2026-03-12 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "034_add_user_profile_settings" +down_revision: Union[str, None] = "033_add_imap_ingestion_profiles" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Add preferred_theme and avatar_data columns to user_profiles table.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + if "user_profiles" not in inspector.get_table_names(): + return + existing_columns = {col["name"] for col in inspector.get_columns("user_profiles")} + if "preferred_theme" not in existing_columns: + op.add_column( + "user_profiles", + sa.Column("preferred_theme", sa.String(10), nullable=True, server_default=None), + ) + if "avatar_data" not in existing_columns: + op.add_column( + "user_profiles", + sa.Column("avatar_data", sa.Text, nullable=True, server_default=None), + ) + + +def downgrade() -> None: + """Remove preferred_theme and avatar_data columns from user_profiles table.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + if "user_profiles" not in inspector.get_table_names(): + return + existing_columns = {col["name"] for col in inspector.get_columns("user_profiles")} + if "avatar_data" in existing_columns: + op.drop_column("user_profiles", "avatar_data") + if "preferred_theme" in existing_columns: + op.drop_column("user_profiles", "preferred_theme") diff --git a/tests/test_api_profile.py b/tests/test_api_profile.py new file mode 100644 index 00000000..8cc069c3 --- /dev/null +++ b/tests/test_api_profile.py @@ -0,0 +1,500 @@ +"""Tests for app/api/profile.py — user self-service profile API. + +Unit tests call handler functions directly with mock request objects. +Integration tests use a dedicated TestClient with DB override. +""" + +from __future__ import annotations + +import base64 +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, get_db +from app.models import LocalUser, UserProfile + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def prof_engine(): + """In-memory SQLite engine scoped to one test.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + yield engine + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def prof_session(prof_engine): + """DB session for one profile test.""" + Session = sessionmaker(bind=prof_engine) + session = Session() + yield session + session.close() + + +@pytest.fixture() +def prof_client(prof_engine): + """TestClient with the in-memory DB injected.""" + from app.main import app + + def override_db(): + Session = sessionmaker(bind=prof_engine) + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = override_db + with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c: + yield c + app.dependency_overrides.pop(get_db, None) + + +# --------------------------------------------------------------------------- +# Unit tests — helper functions +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGravatarUrl: + """Tests for the _gravatar_url helper.""" + + def test_returns_gravatar_for_valid_email(self): + from app.api.profile import _gravatar_url + + url = _gravatar_url("Test@Example.COM") + assert "gravatar.com/avatar/" in url + assert url.endswith("?d=identicon") + + def test_fallback_for_none_email(self): + from app.api.profile import _gravatar_url + + url = _gravatar_url(None) + assert "gravatar.com/avatar/" in url + assert "?d=identicon" in url + + +@pytest.mark.unit +class TestGetUserId: + """Tests for the _get_user_id helper.""" + + def test_extracts_sub(self): + from app.api.profile import _get_user_id + + req = MagicMock() + req.session = {"user": {"sub": "sub-123", "email": "a@b.com"}} + assert _get_user_id(req) == "sub-123" + + def test_extracts_preferred_username_fallback(self): + from app.api.profile import _get_user_id + + req = MagicMock() + req.session = {"user": {"preferred_username": "alice", "email": "a@b.com"}} + assert _get_user_id(req) == "alice" + + def test_extracts_email_fallback(self): + from app.api.profile import _get_user_id + + req = MagicMock() + req.session = {"user": {"email": "a@b.com"}} + assert _get_user_id(req) == "a@b.com" + + def test_raises_401_when_no_session_user(self): + from app.api.profile import _get_user_id + + req = MagicMock() + req.session = {} + with pytest.raises(HTTPException) as exc: + _get_user_id(req) + assert exc.value.status_code == 401 + + def test_raises_401_when_no_identifier(self): + from app.api.profile import _get_user_id + + req = MagicMock() + req.session = {"user": {"name": "Someone"}} + with pytest.raises(HTTPException) as exc: + _get_user_id(req) + assert exc.value.status_code == 401 + + +# --------------------------------------------------------------------------- +# Unit tests — GET /api/profile handler +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetProfileHandler: + """Unit tests for the get_profile endpoint handler.""" + + @pytest.mark.asyncio + async def test_returns_profile_from_db(self, prof_session): + """get_profile reads from DB and returns correct data.""" + from app.api.profile import get_profile + + profile = UserProfile( + user_id="alice", + display_name="Alice", + preferred_language="fr", + preferred_theme="dark", + ) + prof_session.add(profile) + prof_session.commit() + + req = MagicMock() + req.session = {"user": {"preferred_username": "alice", "email": "alice@example.com"}} + + result = await get_profile(req, prof_session) + assert result.user_id == "alice" + assert result.display_name == "Alice" + assert result.preferred_language == "fr" + assert result.preferred_theme == "dark" + assert "gravatar.com" in result.avatar_url + + @pytest.mark.asyncio + async def test_returns_custom_avatar_when_stored(self, prof_session): + """get_profile returns the data: URI when avatar_data is set.""" + from app.api.profile import get_profile + + profile = UserProfile( + user_id="bob", + avatar_data="data:image/png;base64,abc", + ) + prof_session.add(profile) + prof_session.commit() + + req = MagicMock() + req.session = {"user": {"preferred_username": "bob", "email": "bob@example.com"}} + + result = await get_profile(req, prof_session) + assert result.avatar_url == "data:image/png;base64,abc" + + @pytest.mark.asyncio + async def test_creates_profile_if_missing(self, prof_session): + """get_profile creates a stub profile row when none exists.""" + from app.api.profile import get_profile + + req = MagicMock() + req.session = {"user": {"preferred_username": "newbie", "email": "newbie@example.com"}} + + result = await get_profile(req, prof_session) + assert result.user_id == "newbie" + row = prof_session.query(UserProfile).filter_by(user_id="newbie").first() + assert row is not None + + +# --------------------------------------------------------------------------- +# Unit tests — PATCH /api/profile handler +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUpdateProfileHandler: + """Unit tests for the update_profile endpoint handler.""" + + @pytest.mark.asyncio + async def test_updates_display_name(self, prof_session): + """update_profile updates display_name.""" + from app.api.profile import ProfileUpdateRequest, update_profile + + req = MagicMock() + req.session = {"user": {"preferred_username": "carol", "email": "carol@example.com"}} + + body = ProfileUpdateRequest(display_name="Carol Smith") + result = await update_profile(body, req, prof_session) + assert result.display_name == "Carol Smith" + + @pytest.mark.asyncio + async def test_updates_language(self, prof_session): + """update_profile updates preferred_language.""" + from app.api.profile import ProfileUpdateRequest, update_profile + + req = MagicMock() + req.session = {"user": {"preferred_username": "dave", "email": "dave@example.com"}} + + body = ProfileUpdateRequest(preferred_language="de") + result = await update_profile(body, req, prof_session) + assert result.preferred_language == "de" + + @pytest.mark.asyncio + async def test_updates_theme(self, prof_session): + """update_profile updates preferred_theme.""" + from app.api.profile import ProfileUpdateRequest, update_profile + + req = MagicMock() + req.session = {"user": {"preferred_username": "eve", "email": "eve@example.com"}} + + body = ProfileUpdateRequest(preferred_theme="light") + result = await update_profile(body, req, prof_session) + assert result.preferred_theme == "light" + + @pytest.mark.asyncio + async def test_rejects_invalid_language(self, prof_session): + """update_profile raises 422 for unsupported language code.""" + from app.api.profile import ProfileUpdateRequest, update_profile + + req = MagicMock() + req.session = {"user": {"preferred_username": "frank", "email": "frank@example.com"}} + + body = ProfileUpdateRequest(preferred_language="xx") + with pytest.raises(HTTPException) as exc: + await update_profile(body, req, prof_session) + assert exc.value.status_code == 422 + + @pytest.mark.asyncio + async def test_rejects_invalid_theme(self, prof_session): + """update_profile raises 422 for invalid theme value.""" + from app.api.profile import ProfileUpdateRequest, update_profile + + req = MagicMock() + req.session = {"user": {"preferred_username": "grace", "email": "grace@example.com"}} + + body = ProfileUpdateRequest(preferred_theme="rainbow") + with pytest.raises(HTTPException) as exc: + await update_profile(body, req, prof_session) + assert exc.value.status_code == 422 + + +# --------------------------------------------------------------------------- +# Unit tests — POST /api/profile/avatar handler +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUploadAvatarHandler: + """Unit tests for the upload_avatar endpoint handler.""" + + @pytest.mark.asyncio + async def test_stores_base64_data_url(self, prof_session): + """upload_avatar stores the image as a data: URI.""" + from app.api.profile import upload_avatar + + png_bytes = base64.b64decode( + b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/Z+hHgAHggJ/PchI6QAAAABJRU5ErkJggg==" + ) + + upload = MagicMock() + upload.content_type = "image/png" + upload.read = AsyncMock(return_value=png_bytes) + + req = MagicMock() + req.session = {"user": {"preferred_username": "avataruser", "email": "av@example.com"}} + + result = await upload_avatar(req, prof_session, upload) + assert result["avatar_url"].startswith("data:image/png;base64,") + + @pytest.mark.asyncio + async def test_rejects_unsupported_mime(self, prof_session): + """upload_avatar raises 415 for non-image content types.""" + from app.api.profile import upload_avatar + + upload = MagicMock() + upload.content_type = "application/pdf" + upload.read = AsyncMock(return_value=b"%PDF") + + req = MagicMock() + req.session = {"user": {"preferred_username": "pdfuser", "email": "pdf@example.com"}} + + with pytest.raises(HTTPException) as exc: + await upload_avatar(req, prof_session, upload) + assert exc.value.status_code == 415 + + @pytest.mark.asyncio + async def test_rejects_oversized_image(self, prof_session): + """upload_avatar raises 413 when image exceeds 2 MB.""" + from app.api.profile import upload_avatar + + big_data = b"x" * (2 * 1024 * 1024 + 1) + + upload = MagicMock() + upload.content_type = "image/png" + upload.read = AsyncMock(return_value=big_data) + + req = MagicMock() + req.session = {"user": {"preferred_username": "biguser", "email": "big@example.com"}} + + with pytest.raises(HTTPException) as exc: + await upload_avatar(req, prof_session, upload) + assert exc.value.status_code == 413 + + +# --------------------------------------------------------------------------- +# Unit tests — DELETE /api/profile/avatar handler +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDeleteAvatarHandler: + """Unit tests for the delete_avatar endpoint handler.""" + + @pytest.mark.asyncio + async def test_clears_avatar_data(self, prof_session): + """delete_avatar removes avatar_data and returns a Gravatar URL.""" + from app.api.profile import delete_avatar + + profile = UserProfile( + user_id="delavatar", + avatar_data="data:image/png;base64,abc", + ) + prof_session.add(profile) + prof_session.commit() + + req = MagicMock() + req.session = {"user": {"preferred_username": "delavatar", "email": "del@example.com"}} + + result = await delete_avatar(req, prof_session) + assert "gravatar.com" in result["avatar_url"] + + row = prof_session.query(UserProfile).filter_by(user_id="delavatar").first() + assert row.avatar_data is None + + +# --------------------------------------------------------------------------- +# Unit tests — POST /api/profile/change-password handler +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestChangePasswordHandler: + """Unit tests for the change_password endpoint handler.""" + + @pytest.mark.asyncio + async def test_rejects_non_local_user(self, prof_session): + """change_password raises 403 for OAuth-only accounts.""" + from app.api.profile import ChangePasswordRequest, change_password + + req = MagicMock() + req.session = {"user": {"preferred_username": "oauthonly", "email": "oauth@example.com"}} + + body = ChangePasswordRequest( + current_password="old", + new_password="newpassword1", + new_password_confirm="newpassword1", + ) + with pytest.raises(HTTPException) as exc: + await change_password(body, req, prof_session) + assert exc.value.status_code == 403 + + @pytest.mark.asyncio + async def test_rejects_wrong_current_password(self, prof_session): + """change_password raises 403 when current password is wrong.""" + from app.api.profile import ChangePasswordRequest, change_password + from app.utils.local_auth import hash_password + + local_user = LocalUser( + email="local@example.com", + username="localwrong", + hashed_password=hash_password("correctpassword"), + is_active=True, + ) + prof_session.add(local_user) + prof_session.commit() + + req = MagicMock() + req.session = {"user": {"preferred_username": "localwrong", "email": "local@example.com"}} + + body = ChangePasswordRequest( + current_password="wrongpassword", + new_password="newpassword1", + new_password_confirm="newpassword1", + ) + with pytest.raises(HTTPException) as exc: + await change_password(body, req, prof_session) + assert exc.value.status_code == 403 + + @pytest.mark.asyncio + async def test_rejects_password_mismatch(self, prof_session): + """change_password raises 422 when new passwords do not match.""" + from app.api.profile import ChangePasswordRequest, change_password + from app.utils.local_auth import hash_password + + local_user = LocalUser( + email="mismatch@example.com", + username="mismatchpw", + hashed_password=hash_password("currentpw"), + is_active=True, + ) + prof_session.add(local_user) + prof_session.commit() + + req = MagicMock() + req.session = {"user": {"preferred_username": "mismatchpw", "email": "mismatch@example.com"}} + + body = ChangePasswordRequest( + current_password="currentpw", + new_password="newpassword1", + new_password_confirm="differentpassword", + ) + with pytest.raises(HTTPException) as exc: + await change_password(body, req, prof_session) + assert exc.value.status_code == 422 + + @pytest.mark.asyncio + async def test_changes_password_successfully(self, prof_session): + """change_password updates hashed_password for correct input.""" + from app.api.profile import ChangePasswordRequest, change_password + from app.utils.local_auth import hash_password, verify_password + + local_user = LocalUser( + email="success@example.com", + username="successpw", + hashed_password=hash_password("oldpassword"), + is_active=True, + ) + prof_session.add(local_user) + prof_session.commit() + + req = MagicMock() + req.session = {"user": {"preferred_username": "successpw", "email": "success@example.com"}} + + body = ChangePasswordRequest( + current_password="oldpassword", + new_password="newpassword1", + new_password_confirm="newpassword1", + ) + result = await change_password(body, req, prof_session) + assert "successfully" in result["detail"].lower() + + updated_user = prof_session.query(LocalUser).filter_by(username="successpw").first() + assert verify_password("newpassword1", updated_user.hashed_password) + + +# --------------------------------------------------------------------------- +# Integration tests — HTTP endpoint registration +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestProfileEndpoints: + """Verify profile endpoints are registered and reachable.""" + + def test_get_profile_without_session_returns_401(self, prof_client): + """GET /api/profile returns 401 when no user in session.""" + response = prof_client.get("/api/profile") + assert response.status_code == 401 + + def test_patch_profile_without_session_returns_401(self, prof_client): + """PATCH /api/profile returns 401 when no user in session.""" + response = prof_client.patch("/api/profile", json={"display_name": "Test"}) + assert response.status_code == 401 + + def test_profile_page_accessible(self, prof_client): + """GET /profile page renders successfully (auth disabled in tests).""" + response = prof_client.get("/profile", follow_redirects=False) + # AUTH_ENABLED=False in tests so no redirect; page should render + assert response.status_code in (200, 302) diff --git a/tests/test_api_user.py b/tests/test_api_user.py index 9332bf33..2b5b16a3 100644 --- a/tests/test_api_user.py +++ b/tests/test_api_user.py @@ -14,18 +14,38 @@ class TestWhoamiHandler: @pytest.mark.asyncio async def test_returns_user_with_gravatar(self): - """Test that handler returns user data with gravatar URL.""" + """Test that handler returns user data with gravatar URL when no custom avatar.""" mock_request = MagicMock() email = "test@example.com" mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}} - result = await whoami_handler(mock_request) + # Mock DB: no UserProfile found (no custom avatar) + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + result = await whoami_handler(mock_request, mock_db) assert result["id"] == "1" assert result["name"] == "Test" - # Should have gravatar URL + # Should have gravatar URL since no custom avatar expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest() assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon" + @pytest.mark.asyncio + async def test_returns_custom_avatar_when_set(self): + """Test that handler returns custom avatar URL when profile has avatar_data.""" + mock_request = MagicMock() + email = "test@example.com" + mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}} + + # Mock DB: UserProfile with avatar_data + mock_profile = MagicMock() + mock_profile.avatar_data = "data:image/png;base64,abc123" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_profile + + result = await whoami_handler(mock_request, mock_db) + assert result["picture"] == "data:image/png;base64,abc123" + @pytest.mark.asyncio async def test_raises_401_when_no_user(self): """Test that 401 is raised when no user in session.""" @@ -33,9 +53,10 @@ class TestWhoamiHandler: mock_request = MagicMock() mock_request.session = {} + mock_db = MagicMock() with pytest.raises(HTTPException) as exc_info: - await whoami_handler(mock_request) + await whoami_handler(mock_request, mock_db) assert exc_info.value.status_code == 401 @pytest.mark.asyncio @@ -45,11 +66,26 @@ class TestWhoamiHandler: mock_request = MagicMock() mock_request.session = {"user": {"id": "1", "name": "Test"}} + mock_db = MagicMock() with pytest.raises(HTTPException) as exc_info: - await whoami_handler(mock_request) + await whoami_handler(mock_request, mock_db) assert exc_info.value.status_code == 400 + @pytest.mark.asyncio + async def test_falls_back_to_gravatar_on_db_error(self): + """Test that gravatar is used when DB lookup raises an exception.""" + mock_request = MagicMock() + email = "test@example.com" + mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}} + + mock_db = MagicMock() + mock_db.query.side_effect = Exception("DB error") + + result = await whoami_handler(mock_request, mock_db) + expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest() + assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon" + @pytest.mark.integration class TestWhoamiEndpoints: