diff --git a/app/api/__init__.py b/app/api/__init__.py index f99c3823..91872ecc 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -6,6 +6,7 @@ import logging from fastapi import APIRouter +from app.api.admin_users import router as admin_users_router from app.api.azure import router as azure_router from app.api.database import router as database_router from app.api.diagnostic import router as diagnostic_router @@ -35,6 +36,7 @@ logger = logging.getLogger(__name__) router = APIRouter() # Include all the routers +router.include_router(admin_users_router) router.include_router(user_router) router.include_router(files_router) router.include_router(process_router) diff --git a/app/api/admin_users.py b/app/api/admin_users.py new file mode 100644 index 00000000..846cf09c --- /dev/null +++ b/app/api/admin_users.py @@ -0,0 +1,259 @@ +"""API endpoints for admin user management. + +Provides CRUD operations for user profiles and aggregate statistics so that +administrators can inspect, configure, and manage users in multi-user mode. +""" + +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel, Field +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import FileRecord, UserProfile + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/admin/users", tags=["admin-users"]) + +DbSession = Annotated[Session, Depends(get_db)] + + +# --------------------------------------------------------------------------- +# Auth helper +# --------------------------------------------------------------------------- + + +def _require_admin(request: Request) -> dict: + """Ensure the caller is an admin. Raises 403 otherwise.""" + user = request.session.get("user") + if not user or not user.get("is_admin"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return user + + +AdminUser = Annotated[dict, Depends(_require_admin)] + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class UserProfileUpsert(BaseModel): + """Body for creating or updating a user profile.""" + + display_name: str | None = Field(default=None, max_length=255, description="Human-readable display name") + daily_upload_limit: int | None = Field( + default=None, ge=0, description="Per-user daily upload cap; null = use global default" + ) + notes: str | None = Field(default=None, max_length=4096, description="Admin notes about this user") + is_blocked: bool = Field(default=False, description="Block this user from uploading") + + +class UserProfileResponse(BaseModel): + """Response schema for a user profile record.""" + + id: int + user_id: str + display_name: str | None + daily_upload_limit: int | None + notes: str | None + is_blocked: bool + created_at: str | None + updated_at: str | None + + model_config = {"from_attributes": True} + + +class UserSummary(BaseModel): + """Per-user summary combining profile data with document statistics.""" + + user_id: str + display_name: str | None + daily_upload_limit: int | None + notes: str | None + is_blocked: bool + profile_id: int | None + document_count: int + last_upload: str | None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_or_none(db: Session, user_id: str) -> UserProfile | None: + """Return the UserProfile row for *user_id*, or None if it doesn't exist.""" + return db.query(UserProfile).filter(UserProfile.user_id == user_id).first() + + +def _profile_to_dict(profile: UserProfile) -> dict[str, Any]: + return { + "id": profile.id, + "user_id": profile.user_id, + "display_name": profile.display_name, + "daily_upload_limit": profile.daily_upload_limit, + "notes": profile.notes, + "is_blocked": profile.is_blocked, + "created_at": profile.created_at.isoformat() if profile.created_at else None, + "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, + } + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/", summary="List all known users with statistics") +def list_users( + db: DbSession, + _admin: AdminUser, + q: str = Query("", description="Filter by user_id substring (case-insensitive)"), + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(25, ge=1, le=100, description="Items per page"), +) -> dict[str, Any]: + """Return every distinct user_id that has at least one document or an explicit profile, + enriched with aggregate document statistics and the admin-managed profile. + + Supports substring filtering (``q``) and pagination. + """ + # 1. Collect every distinct owner_id from documents + doc_stats_query = ( + db.query( + FileRecord.owner_id.label("user_id"), + func.count(FileRecord.id).label("doc_count"), + func.max(FileRecord.created_at).label("last_upload"), + ) + .filter(FileRecord.owner_id.isnot(None)) + .group_by(FileRecord.owner_id) + ) + + # 2. Collect all user_ids that have explicit profiles (may not have docs yet) + profile_query = db.query(UserProfile) + + # Build a unified set of user_ids + doc_rows = {row.user_id: row for row in doc_stats_query.all()} + profile_rows = {p.user_id: p for p in profile_query.all()} + + all_user_ids = set(doc_rows.keys()) | set(profile_rows.keys()) + + # Apply optional substring filter + if q.strip(): + q_lower = q.strip().lower() + all_user_ids = {uid for uid in all_user_ids if q_lower in uid.lower()} + + # Sort and paginate + sorted_ids = sorted(all_user_ids) + total = len(sorted_ids) + start = (page - 1) * per_page + page_ids = sorted_ids[start : start + per_page] + + users: list[dict[str, Any]] = [] + for uid in page_ids: + doc_row = doc_rows.get(uid) + profile = profile_rows.get(uid) + users.append( + { + "user_id": uid, + "display_name": profile.display_name if profile else None, + "daily_upload_limit": profile.daily_upload_limit if profile else None, + "notes": profile.notes if profile else None, + "is_blocked": profile.is_blocked 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, + } + ) + + return { + "users": users, + "total": total, + "page": page, + "per_page": per_page, + "pages": max(1, (total + per_page - 1) // per_page), + } + + +@router.get("/{user_id:path}", summary="Get details for a single user") +def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Return profile and document statistics for a specific user.""" + doc_count = db.query(func.count(FileRecord.id)).filter(FileRecord.owner_id == user_id).scalar() or 0 + last_row = ( + db.query(FileRecord.created_at) + .filter(FileRecord.owner_id == user_id) + .order_by(FileRecord.created_at.desc()) + .first() + ) + last_upload = last_row[0].isoformat() if last_row and last_row[0] else None + + profile = _get_or_none(db, user_id) + + return { + "user_id": user_id, + "display_name": profile.display_name if profile else None, + "daily_upload_limit": profile.daily_upload_limit if profile else None, + "notes": profile.notes if profile else None, + "is_blocked": profile.is_blocked if profile else False, + "profile_id": profile.id if profile else None, + "document_count": doc_count, + "last_upload": last_upload, + "profile": _profile_to_dict(profile) if profile else None, + } + + +@router.put("/{user_id:path}", summary="Create or update a user profile") +def upsert_user_profile( + user_id: str, + body: UserProfileUpsert, + db: DbSession, + _admin: AdminUser, +) -> dict[str, Any]: + """Create a new profile or update an existing one for *user_id*. + + Returns the persisted profile. + """ + profile = _get_or_none(db, user_id) + if profile is None: + profile = UserProfile(user_id=user_id) + db.add(profile) + + profile.display_name = body.display_name + profile.daily_upload_limit = body.daily_upload_limit + profile.notes = body.notes + profile.is_blocked = body.is_blocked + + try: + db.commit() + db.refresh(profile) + except Exception: + db.rollback() + raise + + logger.info("Admin upserted profile for user %s", user_id) + return _profile_to_dict(profile) + + +@router.delete("/{user_id:path}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a user profile") +def delete_user_profile(user_id: str, db: DbSession, _admin: AdminUser) -> None: + """Delete the admin-managed profile for *user_id*. + + Documents owned by this user are **not** removed; only the profile record + is deleted. To reassign or purge documents use the files API. + """ + profile = _get_or_none(db, user_id) + if not profile: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User profile not found") + + try: + db.delete(profile) + db.commit() + except Exception: + db.rollback() + raise + + logger.info("Admin deleted profile for user %s", user_id) diff --git a/app/models.py b/app/models.py index b1350f05..3d19c787 100644 --- a/app/models.py +++ b/app/models.py @@ -170,3 +170,35 @@ class WebhookConfig(Base): description = Column(String, nullable=True) # Optional human-readable description created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class UserProfile(Base): + """Per-user profile for admin-managed settings in multi-user mode. + + Each row corresponds to one authenticated user (identified by their + ``user_id``, which matches ``FileRecord.owner_id``). The admin can + create or update profiles to override global defaults such as the + daily upload limit and to attach notes or block a user. + """ + + __tablename__ = "user_profiles" + + id = Column(Integer, primary_key=True, index=True) + + # Stable user identifier — matches FileRecord.owner_id (OAuth sub / email / username) + user_id = Column(String, unique=True, nullable=False, index=True) + + # Optional human-readable display name set by the admin + display_name = Column(String, nullable=True) + + # Per-user daily upload limit; NULL means "use global default" + daily_upload_limit = Column(Integer, nullable=True) + + # Admin-only free-text notes about this user + notes = Column(Text, nullable=True) + + # When True the user is prevented from uploading new documents + is_blocked = Column(Boolean, default=False, nullable=False) + + 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 30e4d9ed..9570ee7f 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -4,6 +4,7 @@ Aggregated view routers for the application. from fastapi import APIRouter +from app.views.admin_users import router as admin_users_router from app.views.db_wizard import router as db_wizard_router from app.views.dropbox import router as dropbox_router from app.views.filemanager import router as filemanager_router @@ -23,6 +24,7 @@ from app.views.wizard import router as wizard_router router = APIRouter() router.include_router(wizard_router) # Wizard first (for /setup) router.include_router(db_wizard_router) # Database wizard +router.include_router(admin_users_router) # Admin user management router.include_router(general_router) router.include_router(status_router) router.include_router(onedrive_router) diff --git a/app/views/admin_users.py b/app/views/admin_users.py new file mode 100644 index 00000000..dd7b3265 --- /dev/null +++ b/app/views/admin_users.py @@ -0,0 +1,44 @@ +"""Admin view: user management dashboard.""" + +import logging + +from fastapi import HTTPException, Request, status +from fastapi.responses import RedirectResponse + +from app.views.base import APIRouter, get_db, require_login, settings, templates # noqa: F401 + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def _require_admin(request: Request): + """Return the session user if they are an admin, else redirect.""" + user = request.session.get("user") + if not user or not user.get("is_admin"): + logger.warning("Non-admin user attempted to access /admin/users") + return None + return user + + +@router.get("/admin/users") +@require_login +async def admin_users_page(request: Request): + """Admin user management dashboard — lists all known users.""" + user = _require_admin(request) + if user is None: + return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + + try: + return templates.TemplateResponse( + "admin_users.html", + { + "request": request, + "app_version": settings.version, + }, + ) + except Exception as e: + logger.error(f"Error loading admin users page: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load admin users page", + ) diff --git a/docs/API.md b/docs/API.md index 1372dbdb..e736c260 100644 --- a/docs/API.md +++ b/docs/API.md @@ -728,6 +728,90 @@ curl "http:///api/users/search?q=risti&limit=5" --- +### Admin User Management + +**Admin only.** These endpoints let administrators list all known users, view per-user statistics, +and manage per-user settings such as custom upload limits, display names, and blocked status. + +--- + +**GET** `/api/admin/users/` + +List all known users — anyone who has uploaded a document or has an explicit profile. +Returns aggregate document statistics merged with profile data. + +**Query Parameters**: +- `q` (optional): Substring filter on user ID (case-insensitive) +- `page` (optional): Page number (default: 1) +- `per_page` (optional): Items per page (default: 25, max: 100) + +```bash +curl "http:///api/admin/users/" \ + -H "Cookie: session=" +``` + +**Response**: +```json +{ + "users": [ + { + "user_id": "alice@example.com", + "display_name": "Alice Smith", + "daily_upload_limit": 50, + "notes": null, + "is_blocked": false, + "profile_id": 1, + "document_count": 42, + "last_upload": "2026-02-15T10:23:00" + } + ], + "total": 1, + "page": 1, + "per_page": 25, + "pages": 1 +} +``` + +--- + +**GET** `/api/admin/users/{user_id}` + +Return profile and document statistics for a specific user. + +```bash +curl "http:///api/admin/users/alice%40example.com" +``` + +--- + +**PUT** `/api/admin/users/{user_id}` + +Create or update the admin-managed profile for a user. If no profile exists one is created. + +**Request body**: +```json +{ + "display_name": "Alice Smith", + "daily_upload_limit": 50, + "notes": "VIP customer", + "is_blocked": false +} +``` + +- `display_name` (optional): Human-readable name shown in the admin UI +- `daily_upload_limit` (optional): Per-user daily cap; `null` = use global default; `0` = unlimited +- `notes` (optional): Admin-only text notes +- `is_blocked`: When `true`, blocks new uploads from this user + +--- + +**DELETE** `/api/admin/users/{user_id}` + +Delete the admin-managed profile for a user. Documents owned by the user are **not** removed. +Returns `204 No Content` on success, `404` if no profile exists. + +--- + ### Settings Suggestions (Autocomplete) **GET** `/api/settings/{key}/suggestions` diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html new file mode 100644 index 00000000..f720348a --- /dev/null +++ b/frontend/templates/admin_users.html @@ -0,0 +1,557 @@ +{% extends "base.html" %} +{% block title %}User Management – Admin – DocuElevate{% endblock %} + +{% block content %} +
+ + +
+
+

+ + User Management + Admin Only +

+

+ Manage user profiles, per-user upload limits, and document ownership. +

+
+ +
+ + + + + +
+
+ +
+ + + + +
+
+
+
+ + +
+ + + + + + + + + + + + + + + + + +
User IDDisplay NameDocumentsLast UploadUpload LimitStatusActions
+
+ + +
+ + + Page of + + +
+ + + + + + + +
+ + +{% endblock %} diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 44f537fe..51b11cb5 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -95,6 +95,9 @@ Settings + + Users + Credentials @@ -178,6 +181,9 @@ Settings + + Users + Credentials diff --git a/migrations/versions/013_add_user_profiles.py b/migrations/versions/013_add_user_profiles.py new file mode 100644 index 00000000..9e1b33ae --- /dev/null +++ b/migrations/versions/013_add_user_profiles.py @@ -0,0 +1,42 @@ +"""Add user_profiles table for per-user admin settings + +Revision ID: 013_add_user_profiles +Revises: 012_add_multi_user_support +Create Date: 2026-03-06 + +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "013_add_user_profiles" +down_revision: Union[str, None] = "012_add_multi_user_support" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Create the user_profiles table.""" + op.create_table( + "user_profiles", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.String(255), nullable=False), + sa.Column("display_name", sa.String(255), nullable=True), + sa.Column("daily_upload_limit", sa.Integer(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("is_blocked", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_user_profiles_id", "user_profiles", ["id"]) + op.create_index("ix_user_profiles_user_id", "user_profiles", ["user_id"], unique=True) + + +def downgrade() -> None: + """Drop the user_profiles table.""" + op.drop_index("ix_user_profiles_user_id", table_name="user_profiles") + op.drop_index("ix_user_profiles_id", table_name="user_profiles") + op.drop_table("user_profiles") diff --git a/tests/conftest.py b/tests/conftest.py index 4d1fd60b..98cb5316 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,7 +59,14 @@ from app.database import Base # noqa: E402 from app.main import app as fastapi_app # noqa: E402 # Import models to register them with SQLAlchemy Base -from app.models import DocumentMetadata, FileRecord, ProcessingLog, SavedSearch, WebhookConfig # noqa: F401, E402 +from app.models import ( # noqa: F401, E402 + DocumentMetadata, + FileRecord, + ProcessingLog, + SavedSearch, + UserProfile, + WebhookConfig, +) @pytest.fixture(scope="session") diff --git a/tests/test_admin_users.py b/tests/test_admin_users.py new file mode 100644 index 00000000..e889a704 --- /dev/null +++ b/tests/test_admin_users.py @@ -0,0 +1,479 @@ +""" +Tests for the admin user management API (/api/admin/users). + +Covers: +- Authentication enforcement (403 for non-admins) +- List users (empty, with doc-only users, with profile-only users, with both) +- Get single user detail +- Create / update user profile via PUT (upsert) +- Delete user profile +- Pagination and search filtering +""" + +from unittest.mock import 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 FileRecord, UserProfile + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def au_engine(): + """In-memory SQLite engine for admin-user tests.""" + 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 au_session(au_engine): + """DB session scoped to one test.""" + Session = sessionmaker(bind=au_engine) + session = Session() + yield session + session.close() + + +@pytest.fixture() +def au_client(au_engine): + """TestClient that uses an in-memory DB and overrides _require_admin to allow access.""" + from app.api.admin_users import _require_admin + from app.main import app + + def override_db(): + Session = sessionmaker(bind=au_engine) + session = Session() + try: + yield session + finally: + session.close() + + def override_require_admin(): + return {"email": "admin@test.com", "is_admin": True, "name": "Admin"} + + app.dependency_overrides[get_db] = override_db + app.dependency_overrides[_require_admin] = override_require_admin + with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client: + yield client + app.dependency_overrides.clear() + + +@pytest.fixture() +def au_client_nonadmin(au_engine): + """TestClient without admin override — _require_admin returns 403.""" + from app.main import app + + def override_db(): + Session = sessionmaker(bind=au_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 client: + yield client + app.dependency_overrides.clear() + + +def _make_file(session, owner_id: str, n: int = 1) -> list[FileRecord]: + """Insert *n* FileRecord rows for the given owner.""" + records = [] + for i in range(n): + rec = FileRecord( + filehash=f"hash-{owner_id}-{i}", + original_filename=f"doc{i}.pdf", + local_filename=f"/tmp/{owner_id}_{i}.pdf", + file_size=1024, + mime_type="application/pdf", + is_duplicate=False, + owner_id=owner_id, + ) + session.add(rec) + records.append(rec) + session.commit() + return records + + +def _make_profile(session, user_id: str, **kwargs) -> UserProfile: + """Insert a UserProfile row.""" + kwargs.setdefault("is_blocked", False) + profile = UserProfile(user_id=user_id, **kwargs) + session.add(profile) + session.commit() + session.refresh(profile) + return profile + + +# --------------------------------------------------------------------------- +# Authentication tests +# --------------------------------------------------------------------------- + + +class TestAdminUsersAuth: + """Endpoints must be restricted to admin users.""" + + @pytest.mark.unit + def test_require_admin_raises_403_when_no_user(self): + """_require_admin raises 403 when no user in session.""" + from app.api.admin_users import _require_admin + + mock_request = MagicMock() + mock_request.session = {} + + with pytest.raises(HTTPException) as exc_info: + _require_admin(mock_request) + assert exc_info.value.status_code == 403 + + @pytest.mark.unit + def test_require_admin_raises_403_for_non_admin(self): + """_require_admin raises 403 for a non-admin user.""" + from app.api.admin_users import _require_admin + + mock_request = MagicMock() + mock_request.session = {"user": {"email": "user@test.com", "is_admin": False}} + + with pytest.raises(HTTPException) as exc_info: + _require_admin(mock_request) + assert exc_info.value.status_code == 403 + + @pytest.mark.unit + def test_require_admin_returns_user_for_admin(self): + """_require_admin returns the user dict for an admin.""" + from app.api.admin_users import _require_admin + + mock_request = MagicMock() + user = {"email": "admin@test.com", "is_admin": True} + mock_request.session = {"user": user} + + result = _require_admin(mock_request) + assert result == user + + @pytest.mark.integration + def test_list_users_requires_admin(self, au_client_nonadmin): + """GET /api/admin/users/ returns 403 for non-admins.""" + resp = au_client_nonadmin.get("/api/admin/users/") + assert resp.status_code == 403 + + @pytest.mark.integration + def test_put_user_requires_admin(self, au_client_nonadmin): + """PUT /api/admin/users/ returns 403 for non-admins.""" + resp = au_client_nonadmin.put( + "/api/admin/users/user@example.com", + json={"is_blocked": False}, + ) + assert resp.status_code == 403 + + @pytest.mark.integration + def test_delete_user_requires_admin(self, au_client_nonadmin): + """DELETE /api/admin/users/ returns 403 for non-admins.""" + resp = au_client_nonadmin.delete("/api/admin/users/user@example.com") + assert resp.status_code == 403 + + @pytest.mark.integration + def test_get_user_requires_admin(self, au_client_nonadmin): + """GET /api/admin/users/ returns 403 for non-admins.""" + resp = au_client_nonadmin.get("/api/admin/users/user@example.com") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# List endpoint +# --------------------------------------------------------------------------- + + +class TestListUsers: + """Tests for GET /api/admin/users/.""" + + @pytest.mark.unit + def test_empty_returns_empty_list(self, au_client): + """No users → empty list with total=0.""" + resp = au_client.get("/api/admin/users/") + assert resp.status_code == 200 + data = resp.json() + assert data["users"] == [] + assert data["total"] == 0 + + @pytest.mark.unit + def test_lists_users_with_docs(self, au_client, au_session): + """Users who have documents appear in the list.""" + _make_file(au_session, "alice@example.com", 3) + _make_file(au_session, "bob@example.com", 1) + + resp = au_client.get("/api/admin/users/") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + + ids = {u["user_id"] for u in data["users"]} + assert "alice@example.com" in ids + assert "bob@example.com" in ids + + @pytest.mark.unit + def test_document_count_correct(self, au_client, au_session): + """document_count reflects the number of files owned by each user.""" + _make_file(au_session, "carol@example.com", 5) + + resp = au_client.get("/api/admin/users/") + assert resp.status_code == 200 + carol = next(u for u in resp.json()["users"] if u["user_id"] == "carol@example.com") + assert carol["document_count"] == 5 + + @pytest.mark.unit + def test_lists_profile_only_users(self, au_client, au_session): + """Users with a profile but no documents still appear.""" + _make_profile(au_session, "profileonly@example.com", display_name="Profile Only") + + resp = au_client.get("/api/admin/users/") + assert resp.status_code == 200 + ids = {u["user_id"] for u in resp.json()["users"]} + assert "profileonly@example.com" in ids + + @pytest.mark.unit + def test_search_filter(self, au_client, au_session): + """q= parameter filters by user_id substring.""" + _make_file(au_session, "alice@example.com") + _make_file(au_session, "bob@example.com") + + resp = au_client.get("/api/admin/users/?q=alice") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["users"][0]["user_id"] == "alice@example.com" + + @pytest.mark.unit + def test_pagination(self, au_client, au_session): + """per_page and page parameters paginate results.""" + for i in range(6): + _make_file(au_session, f"user{i:02d}@example.com") + + resp = au_client.get("/api/admin/users/?page=1&per_page=3") + assert resp.status_code == 200 + data = resp.json() + assert len(data["users"]) == 3 + assert data["total"] == 6 + assert data["pages"] == 2 + + @pytest.mark.unit + def test_profile_data_merged(self, au_client, au_session): + """Profile fields (is_blocked, daily_upload_limit, …) are merged into list items.""" + _make_file(au_session, "managed@example.com") + _make_profile( + au_session, + "managed@example.com", + display_name="Managed User", + daily_upload_limit=10, + is_blocked=True, + ) + + resp = au_client.get("/api/admin/users/") + assert resp.status_code == 200 + user = next(u for u in resp.json()["users"] if u["user_id"] == "managed@example.com") + assert user["display_name"] == "Managed User" + assert user["daily_upload_limit"] == 10 + assert user["is_blocked"] is True + + +# --------------------------------------------------------------------------- +# Get single user +# --------------------------------------------------------------------------- + + +class TestGetUser: + """Tests for GET /api/admin/users/{user_id}.""" + + @pytest.mark.unit + def test_get_user_with_docs(self, au_client, au_session): + """Returns correct document_count and last_upload.""" + _make_file(au_session, "dana@example.com", 2) + + resp = au_client.get("/api/admin/users/dana@example.com") + assert resp.status_code == 200 + data = resp.json() + assert data["user_id"] == "dana@example.com" + assert data["document_count"] == 2 + assert data["is_blocked"] is False + + @pytest.mark.unit + def test_get_user_with_profile(self, au_client, au_session): + """Returns profile data when a profile exists.""" + _make_file(au_session, "evan@example.com") + _make_profile(au_session, "evan@example.com", notes="VIP user", daily_upload_limit=50) + + resp = au_client.get("/api/admin/users/evan@example.com") + assert resp.status_code == 200 + data = resp.json() + assert data["notes"] == "VIP user" + assert data["daily_upload_limit"] == 50 + assert data["profile"] is not None + + @pytest.mark.unit + def test_get_user_no_docs_no_profile_returns_defaults(self, au_client): + """User with no docs and no profile returns zero counts and defaults.""" + resp = au_client.get("/api/admin/users/unknown@example.com") + assert resp.status_code == 200 + data = resp.json() + assert data["document_count"] == 0 + assert data["profile"] is None + assert data["is_blocked"] is False + + +# --------------------------------------------------------------------------- +# Upsert (PUT) endpoint +# --------------------------------------------------------------------------- + + +class TestUpsertUserProfile: + """Tests for PUT /api/admin/users/{user_id}.""" + + @pytest.mark.unit + def test_create_profile(self, au_client, au_session): + """PUT on a user without a profile creates it.""" + resp = au_client.put( + "/api/admin/users/newuser@example.com", + json={"display_name": "New User", "daily_upload_limit": 20, "is_blocked": False}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["user_id"] == "newuser@example.com" + assert data["display_name"] == "New User" + assert data["daily_upload_limit"] == 20 + + # Persisted in DB + profile = au_session.query(UserProfile).filter_by(user_id="newuser@example.com").first() + assert profile is not None + assert profile.display_name == "New User" + + @pytest.mark.unit + def test_update_existing_profile(self, au_client, au_session): + """PUT on an existing profile updates it.""" + _make_profile(au_session, "existing@example.com", display_name="Old Name") + + resp = au_client.put( + "/api/admin/users/existing@example.com", + json={"display_name": "New Name", "is_blocked": True}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["display_name"] == "New Name" + assert data["is_blocked"] is True + + @pytest.mark.unit + def test_block_user(self, au_client, au_session): + """Setting is_blocked=True stores correctly.""" + resp = au_client.put( + "/api/admin/users/blocked@example.com", + json={"is_blocked": True}, + ) + assert resp.status_code == 200 + assert resp.json()["is_blocked"] is True + + @pytest.mark.unit + def test_null_upload_limit(self, au_client, au_session): + """daily_upload_limit can be null (use global default).""" + resp = au_client.put( + "/api/admin/users/nulllimit@example.com", + json={"daily_upload_limit": None, "is_blocked": False}, + ) + assert resp.status_code == 200 + assert resp.json()["daily_upload_limit"] is None + + @pytest.mark.unit + def test_zero_upload_limit_means_unlimited(self, au_client): + """daily_upload_limit=0 is a valid value meaning 'unlimited'.""" + resp = au_client.put( + "/api/admin/users/zerolimit@example.com", + json={"daily_upload_limit": 0, "is_blocked": False}, + ) + assert resp.status_code == 200 + assert resp.json()["daily_upload_limit"] == 0 + + +# --------------------------------------------------------------------------- +# Delete endpoint +# --------------------------------------------------------------------------- + + +class TestDeleteUserProfile: + """Tests for DELETE /api/admin/users/{user_id}.""" + + @pytest.mark.unit + def test_delete_existing_profile(self, au_client, au_session): + """DELETE removes an existing profile; returns 204.""" + _make_profile(au_session, "todelete@example.com") + + resp = au_client.delete("/api/admin/users/todelete@example.com") + assert resp.status_code == 204 + + remaining = au_session.query(UserProfile).filter_by(user_id="todelete@example.com").first() + assert remaining is None + + @pytest.mark.unit + def test_delete_nonexistent_profile_returns_404(self, au_client): + """DELETE on unknown user_id returns 404.""" + resp = au_client.delete("/api/admin/users/doesnotexist@example.com") + assert resp.status_code == 404 + + @pytest.mark.unit + def test_delete_profile_does_not_remove_documents(self, au_client, au_session): + """Deleting a profile must not remove documents owned by that user.""" + _make_file(au_session, "hasfiles@example.com", 3) + _make_profile(au_session, "hasfiles@example.com") + + resp = au_client.delete("/api/admin/users/hasfiles@example.com") + assert resp.status_code == 204 + + doc_count = au_session.query(FileRecord).filter_by(owner_id="hasfiles@example.com").count() + assert doc_count == 3 + + +# --------------------------------------------------------------------------- +# Model tests +# --------------------------------------------------------------------------- + + +class TestUserProfileModel: + """Unit tests for the UserProfile SQLAlchemy model.""" + + @pytest.mark.unit + def test_user_profile_has_required_columns(self, au_session): + """UserProfile can be created with minimal required fields.""" + profile = UserProfile(user_id="test@example.com", is_blocked=False) + au_session.add(profile) + au_session.commit() + au_session.refresh(profile) + + assert profile.id is not None + assert profile.user_id == "test@example.com" + assert profile.is_blocked is False + assert profile.display_name is None + assert profile.daily_upload_limit is None + assert profile.notes is None + + @pytest.mark.unit + def test_user_profile_unique_user_id(self, au_session): + """Two profiles with the same user_id should raise an integrity error.""" + from sqlalchemy.exc import IntegrityError + + au_session.add(UserProfile(user_id="dup@example.com", is_blocked=False)) + au_session.commit() + au_session.add(UserProfile(user_id="dup@example.com", is_blocked=False)) + with pytest.raises(IntegrityError): + au_session.commit() + au_session.rollback()