diff --git a/app/alembic/versions/00157630946a_add_suspended_at_to_user.py b/app/alembic/versions/00157630946a_add_suspended_at_to_user.py new file mode 100644 index 0000000..1e841ca --- /dev/null +++ b/app/alembic/versions/00157630946a_add_suspended_at_to_user.py @@ -0,0 +1,34 @@ +"""add suspended_at to user + +Revision ID: 00157630946a +Revises: 0bb6cf5b4577 +Create Date: 2026-04-20 08:59:51.663895 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "00157630946a" +down_revision: str | Sequence[str] | None = "0bb6cf5b4577" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "user", sa.Column("suspended_at", sa.DateTime(timezone=True), nullable=True) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user", "suspended_at") + # ### end Alembic commands ### diff --git a/app/alembic/versions/1d2f7f8c1513_add_trigram_indexes_for_admin_user_.py b/app/alembic/versions/1d2f7f8c1513_add_trigram_indexes_for_admin_user_.py new file mode 100644 index 0000000..d3e5c06 --- /dev/null +++ b/app/alembic/versions/1d2f7f8c1513_add_trigram_indexes_for_admin_user_.py @@ -0,0 +1,74 @@ +"""add trigram indexes for admin user search + +Revision ID: 1d2f7f8c1513 +Revises: 00157630946a +Create Date: 2026-04-20 15:47:24.611040 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "1d2f7f8c1513" +down_revision: str | Sequence[str] | None = "00157630946a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # gin_trgm_ops requires the pg_trgm extension; create it idempotently + # before the indexes so fresh databases can apply this migration. + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + # ### commands auto generated by Alembic - please adjust! ### + op.create_index( + "ix_user_email_trgm", + "user", + ["email"], + unique=False, + postgresql_using="gin", + postgresql_ops={"email": "gin_trgm_ops"}, + ) + op.create_index( + "ix_user_first_name_trgm", + "user", + ["first_name"], + unique=False, + postgresql_using="gin", + postgresql_ops={"first_name": "gin_trgm_ops"}, + ) + op.create_index( + "ix_user_last_name_trgm", + "user", + ["last_name"], + unique=False, + postgresql_using="gin", + postgresql_ops={"last_name": "gin_trgm_ops"}, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "ix_user_last_name_trgm", + table_name="user", + postgresql_using="gin", + postgresql_ops={"last_name": "gin_trgm_ops"}, + ) + op.drop_index( + "ix_user_first_name_trgm", + table_name="user", + postgresql_using="gin", + postgresql_ops={"first_name": "gin_trgm_ops"}, + ) + op.drop_index( + "ix_user_email_trgm", + table_name="user", + postgresql_using="gin", + postgresql_ops={"email": "gin_trgm_ops"}, + ) + # ### end Alembic commands ### diff --git a/app/api/decorators.py b/app/api/decorators.py index b76b997..2e5742f 100644 --- a/app/api/decorators.py +++ b/app/api/decorators.py @@ -16,7 +16,7 @@ from app.models.user import User from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType -from app.services.user_activity_service import log_activity +from app.use_cases.log_activity import log_activity _UNKNOWN_USER_ID = uuid.UUID(int=0) """Placeholder used when an unexpected failure fires before the caller is known. diff --git a/app/api/deps.py b/app/api/deps.py index 92d6cf7..dbf332e 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -76,7 +76,6 @@ async def get_current_user( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ErrorMessages.USER_NOT_FOUND ) - return user diff --git a/app/api/main.py b/app/api/main.py index 472d4d5..a2597b7 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.routes import auth, health, users +from app.api.routes import admin, auth, health, users api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(admin.router, prefix="/admin", tags=["admin"]) diff --git a/app/api/routes/admin/__init__.py b/app/api/routes/admin/__init__.py new file mode 100644 index 0000000..a74724a --- /dev/null +++ b/app/api/routes/admin/__init__.py @@ -0,0 +1,14 @@ +"""Admin route aggregator. + +Each resource lives in its own module (``users``, ``activities``) and is mounted +here so ``api/main.py`` only needs to import a single ``router``. +""" + +from fastapi import APIRouter + +from app.api.routes.admin import activities, stats, users + +router = APIRouter() +router.include_router(users.router, prefix="/users") +router.include_router(activities.router) +router.include_router(stats.router) diff --git a/app/api/routes/admin/activities.py b/app/api/routes/admin/activities.py new file mode 100644 index 0000000..c20e531 --- /dev/null +++ b/app/api/routes/admin/activities.py @@ -0,0 +1,59 @@ +import uuid +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Query + +from app.api.deps import CurrentSuperUser, SessionDep +from app.schemas.admin import AdminActivityListResponse +from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType +from app.services.admin.activity_service import ( + list_activities_admin_service, + list_user_activities_admin_service, +) + +router = APIRouter() + + +@router.get("/users/{user_id}/activities", response_model=AdminActivityListResponse) +async def list_user_activities( + _admin: CurrentSuperUser, + session: SessionDep, + user_id: uuid.UUID, + skip: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=200)] = 50, +) -> AdminActivityListResponse: + """Return the activity log for a specific user.""" + return await list_user_activities_admin_service( + session=session, + user_id=user_id, + skip=skip, + limit=limit, + ) + + +@router.get("/activities", response_model=AdminActivityListResponse) +async def list_activities( + _admin: CurrentSuperUser, + session: SessionDep, + skip: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + user_id: uuid.UUID | None = None, + activity_type: ActivityType | None = None, + resource_type: ResourceType | None = None, + status_filter: Annotated[ActivityStatus | None, Query(alias="status")] = None, + date_from: datetime | None = None, + date_to: datetime | None = None, +) -> AdminActivityListResponse: + """Return the global activity log with filters and pagination.""" + return await list_activities_admin_service( + session=session, + skip=skip, + limit=limit, + user_id=user_id, + activity_type=activity_type, + resource_type=resource_type, + status_filter=status_filter, + date_from=date_from, + date_to=date_to, + ) diff --git a/app/api/routes/admin/stats.py b/app/api/routes/admin/stats.py new file mode 100644 index 0000000..f325db2 --- /dev/null +++ b/app/api/routes/admin/stats.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.api.deps import CurrentSuperUser, SessionDep +from app.schemas.admin import AdminStats +from app.services.admin.stats_service import get_admin_stats_service + +router = APIRouter() + + +@router.get("/stats", response_model=AdminStats) +async def get_stats( + _admin: CurrentSuperUser, + session: SessionDep, +) -> AdminStats: + """Return aggregate dashboard counts in a single round-trip.""" + return await get_admin_stats_service(session=session) diff --git a/app/api/routes/admin/users.py b/app/api/routes/admin/users.py new file mode 100644 index 0000000..901fc39 --- /dev/null +++ b/app/api/routes/admin/users.py @@ -0,0 +1,179 @@ +import uuid +from typing import Annotated + +from fastapi import APIRouter, Query, Request + +from app.api.decorators import audit_unexpected_failure +from app.api.deps import CurrentSuperUser, SessionDep +from app.core.messages.success_message import SuccessMessages +from app.core.rate_limit import rate_limit_strict +from app.schemas.admin import ( + AdminUserListItem, + AdminUserListResponse, + AdminUserUpdate, + AdminUserUpdateResponse, +) +from app.schemas.msg import Message +from app.schemas.user import Language, SystemRole +from app.schemas.user_activity import ActivityType, ResourceType +from app.services.admin.user_service import ( + delete_user_admin_service, + get_user_admin_service, + list_users_admin_service, + reset_password_admin_service, + suspend_user_admin_service, + unsuspend_user_admin_service, + update_user_admin_service, +) + +router = APIRouter() + + +@router.get("", response_model=AdminUserListResponse) +async def list_users( + _admin: CurrentSuperUser, + session: SessionDep, + skip: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + search: Annotated[str | None, Query(max_length=255)] = None, + role: SystemRole | None = None, + is_active: bool | None = None, + is_verified: bool | None = None, +) -> AdminUserListResponse: + """List users with admin-only filters, search, and pagination.""" + return await list_users_admin_service( + session=session, + skip=skip, + limit=limit, + search=search, + role=role, + is_active=is_active, + is_verified=is_verified, + ) + + +@router.get("/{user_id}", response_model=AdminUserListItem) +async def get_user( + _admin: CurrentSuperUser, + session: SessionDep, + user_id: uuid.UUID, +) -> AdminUserListItem: + """Return the full admin view of a single user.""" + return await get_user_admin_service(session=session, user_id=user_id) + + +@router.patch("/{user_id}", response_model=AdminUserUpdateResponse) +@audit_unexpected_failure( + activity_type=ActivityType.UPDATE, + resource_type=ResourceType.USER, + endpoint="/admin/users/{user_id}", +) +async def update_user( + request: Request, + current_user: CurrentSuperUser, + session: SessionDep, + user_id: uuid.UUID, + payload: AdminUserUpdate, +) -> AdminUserUpdateResponse: + """Admin-authored update of a user's profile, role, or status.""" + user = await update_user_admin_service( + request=request, + session=session, + current_user=current_user, + user_id=user_id, + payload=payload, + ) + return AdminUserUpdateResponse( + user=user, message=SuccessMessages.ADMIN_USER_UPDATED + ) + + +@router.post("/{user_id}/suspend", response_model=Message) +@audit_unexpected_failure( + activity_type=ActivityType.UPDATE, + resource_type=ResourceType.USER, + endpoint="/admin/users/{user_id}/suspend", +) +async def suspend_user( + request: Request, + current_user: CurrentSuperUser, + session: SessionDep, + user_id: uuid.UUID, +) -> Message: + """Permanently suspend a user. Only an admin can later unsuspend them.""" + return await suspend_user_admin_service( + request=request, + session=session, + current_user=current_user, + user_id=user_id, + ) + + +@router.post("/{user_id}/unsuspend", response_model=Message) +@audit_unexpected_failure( + activity_type=ActivityType.UPDATE, + resource_type=ResourceType.USER, + endpoint="/admin/users/{user_id}/unsuspend", +) +async def unsuspend_user( + request: Request, + current_user: CurrentSuperUser, + session: SessionDep, + user_id: uuid.UUID, +) -> Message: + """Lift an existing admin suspension and re-enable the account.""" + return await unsuspend_user_admin_service( + request=request, + session=session, + current_user=current_user, + user_id=user_id, + ) + + +@router.delete("/{user_id}", response_model=Message) +@audit_unexpected_failure( + activity_type=ActivityType.DELETE, + resource_type=ResourceType.USER, + endpoint="/admin/users/{user_id}", +) +async def delete_user( + request: Request, + current_user: CurrentSuperUser, + session: SessionDep, + user_id: uuid.UUID, +) -> Message: + """Hard-delete a user. Guards self-delete and last-admin removal.""" + return await delete_user_admin_service( + request=request, + session=session, + current_user=current_user, + user_id=user_id, + ) + + +@router.post("/{user_id}/reset-password", response_model=Message) +@rate_limit_strict("5/minute") +@audit_unexpected_failure( + activity_type=ActivityType.UPDATE, + resource_type=ResourceType.AUTH, + endpoint="/admin/users/{user_id}/reset-password", +) +async def reset_user_password( + request: Request, + current_user: CurrentSuperUser, + session: SessionDep, + user_id: uuid.UUID, + lang: Language = Language.EN, +) -> Message: + """Send a password-reset email to the target user. + + The admin never sees or sets the password — the user completes the reset + via the standard email-link flow. + """ + return await reset_password_admin_service( + request=request, + session=session, + current_user=current_user, + user_id=user_id, + lang=lang, + ) diff --git a/app/core/messages/error_message.py b/app/core/messages/error_message.py index ca2d3a4..b22b8be 100644 --- a/app/core/messages/error_message.py +++ b/app/core/messages/error_message.py @@ -26,3 +26,14 @@ class ErrorMessages: ACCOUNT_ALREADY_DEACTIVATED = "error.account.already_deactivated" ACCOUNT_NOT_DEACTIVATED = "error.account.not_deactivated" ACCOUNT_DELETION_EXPIRED = "error.account.deletion_expired" + + # Account suspension (admin-initiated, permanent) + ACCOUNT_SUSPENDED = "error.account.suspended" + ACCOUNT_ALREADY_SUSPENDED = "error.account.already_suspended" + ACCOUNT_NOT_SUSPENDED = "error.account.not_suspended" + + # Admin + ADMIN_CANNOT_MODIFY_SELF = "error.admin.cannot_modify_self" + ADMIN_CANNOT_DELETE_SELF = "error.admin.cannot_delete_self" + ADMIN_CANNOT_DEMOTE_LAST_ADMIN = "error.admin.cannot_demote_last_admin" + ADMIN_CANNOT_DELETE_LAST_ADMIN = "error.admin.cannot_delete_last_admin" diff --git a/app/core/messages/success_message.py b/app/core/messages/success_message.py index 3674947..08910b8 100644 --- a/app/core/messages/success_message.py +++ b/app/core/messages/success_message.py @@ -16,3 +16,10 @@ class SuccessMessages: # Account deactivation / grace-period deletion ACCOUNT_DEACTIVATED = "success.account.deactivated" ACCOUNT_REACTIVATED = "success.account.reactivated" + + # Admin + ADMIN_USER_UPDATED = "success.admin.user_updated" + ADMIN_USER_SUSPENDED = "success.admin.user_suspended" + ADMIN_USER_UNSUSPENDED = "success.admin.user_unsuspended" + ADMIN_USER_DELETED = "success.admin.user_deleted" + ADMIN_PASSWORD_RESET_SENT = "success.admin.password_reset_sent" diff --git a/app/models/user.py b/app/models/user.py index de5be44..35fdd6f 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -23,6 +23,26 @@ class User(Base): "deletion_scheduled_at", postgresql_where="is_active = false AND deletion_scheduled_at IS NOT NULL", ), + # pg_trgm GIN indexes powering the admin user search. Without these, + # ``col ILIKE '%foo%'`` degrades to a sequential scan on every query. + Index( + "ix_user_email_trgm", + "email", + postgresql_using="gin", + postgresql_ops={"email": "gin_trgm_ops"}, + ), + Index( + "ix_user_first_name_trgm", + "first_name", + postgresql_using="gin", + postgresql_ops={"first_name": "gin_trgm_ops"}, + ), + Index( + "ix_user_last_name_trgm", + "last_name", + postgresql_using="gin", + postgresql_ops={"last_name": "gin_trgm_ops"}, + ), ) id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) @@ -46,6 +66,12 @@ class User(Base): deletion_scheduled_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), default=None ) + # Admin-initiated permanent suspension. Distinct from user self-deactivation + # (which sets deactivated_at + deletion_scheduled_at). Suspended rows are + # never scheduled for deletion, so the deletion worker ignores them. + suspended_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), default=None + ) # passive_deletes=True lets Postgres handle the cascade via the FK's # ON DELETE CASCADE — a single DELETE statement instead of one per row. diff --git a/app/repositories/admin/__init__.py b/app/repositories/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/repositories/admin/activity.py b/app/repositories/admin/activity.py new file mode 100644 index 0000000..9db7aaa --- /dev/null +++ b/app/repositories/admin/activity.py @@ -0,0 +1,71 @@ +import uuid +from collections.abc import Sequence +from datetime import datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import Select + +from app.models.user_activity import UserActivity +from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType + + +def _filtered_activities_stmt( + *, + user_id: uuid.UUID | None, + activity_type: ActivityType | None, + resource_type: ResourceType | None, + status: ActivityStatus | None, + date_from: datetime | None, + date_to: datetime | None, +) -> Select: + """Build the filtered base statement shared by count and list queries.""" + stmt = select(UserActivity) + if user_id is not None: + stmt = stmt.where(UserActivity.user_id == user_id) + if activity_type is not None: + stmt = stmt.where(UserActivity.activity_type == activity_type.value) + if resource_type is not None: + stmt = stmt.where(UserActivity.resource_type == resource_type.value) + if status is not None: + stmt = stmt.where(UserActivity.status == status.value) + if date_from is not None: + stmt = stmt.where(UserActivity.created_at >= date_from) + if date_to is not None: + stmt = stmt.where(UserActivity.created_at <= date_to) + return stmt + + +async def list_activities_admin( + session: AsyncSession, + *, + skip: int = 0, + limit: int = 50, + user_id: uuid.UUID | None = None, + activity_type: ActivityType | None = None, + resource_type: ResourceType | None = None, + status: ActivityStatus | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, +) -> tuple[Sequence[UserActivity], int]: + """Return a filtered, paginated activity page plus the matching total count.""" + base_stmt = _filtered_activities_stmt( + user_id=user_id, + activity_type=activity_type, + resource_type=resource_type, + status=status, + date_from=date_from, + date_to=date_to, + ) + + count_stmt = base_stmt.with_only_columns( + func.count(), maintain_column_froms=True + ).order_by(None) + total = (await session.execute(count_stmt)).scalar_one() + + rows_stmt = ( + base_stmt.order_by(UserActivity.created_at.desc()).offset(skip).limit(limit) + ) + activities = (await session.execute(rows_stmt)).scalars().all() + + return activities, total diff --git a/app/repositories/admin/stats.py b/app/repositories/admin/stats.py new file mode 100644 index 0000000..5bf0992 --- /dev/null +++ b/app/repositories/admin/stats.py @@ -0,0 +1,29 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.models.user_activity import UserActivity +from app.schemas.user import SystemRole + + +async def get_admin_stats(session: AsyncSession) -> dict[str, int]: + """Return aggregate counts used by the admin dashboard in a single round-trip.""" + stmt = select( + func.count(User.id).label("users_total"), + func.count().filter(User.is_active.is_(True)).label("users_active"), + func.count().filter(User.is_verified.is_(True)).label("users_verified"), + func.count().filter(User.role == SystemRole.ADMIN.value).label("users_admins"), + ) + row = (await session.execute(stmt)).one() + + activities_total = ( + await session.execute(select(func.count()).select_from(UserActivity)) + ).scalar_one() + + return { + "users_total": row.users_total, + "users_active": row.users_active, + "users_verified": row.users_verified, + "users_admins": row.users_admins, + "activities_total": activities_total, + } diff --git a/app/repositories/admin/user.py b/app/repositories/admin/user.py new file mode 100644 index 0000000..ff7bd4c --- /dev/null +++ b/app/repositories/admin/user.py @@ -0,0 +1,80 @@ +import uuid +from collections.abc import Sequence + +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import Select + +from app.models.user import User +from app.schemas.user import SystemRole + + +def _filtered_users_stmt( + *, + search: str | None, + role: SystemRole | None, + is_active: bool | None, + is_verified: bool | None, +) -> Select: + """Build the filtered base statement shared by count and list queries.""" + stmt = select(User) + if search: + # ``ILIKE`` on the raw columns so the ``pg_trgm`` GIN indexes on + # email/first_name/last_name can actually serve the query. Wrapping + # with ``func.lower(...)`` would defeat the index. + like = f"%{search}%" + stmt = stmt.where( + or_( + User.email.ilike(like), + User.first_name.ilike(like), + User.last_name.ilike(like), + ) + ) + if role is not None: + stmt = stmt.where(User.role == role.value) + if is_active is not None: + stmt = stmt.where(User.is_active == is_active) + if is_verified is not None: + stmt = stmt.where(User.is_verified == is_verified) + return stmt + + +async def list_users_admin( + session: AsyncSession, + *, + skip: int = 0, + limit: int = 50, + search: str | None = None, + role: SystemRole | None = None, + is_active: bool | None = None, + is_verified: bool | None = None, +) -> tuple[Sequence[User], int]: + """Return a filtered, paginated user page plus the matching total count.""" + base_stmt = _filtered_users_stmt( + search=search, role=role, is_active=is_active, is_verified=is_verified + ) + + count_stmt = base_stmt.with_only_columns( + func.count(), maintain_column_froms=True + ).order_by(None) + total = (await session.execute(count_stmt)).scalar_one() + + rows_stmt = base_stmt.order_by(User.created_at.desc()).offset(skip).limit(limit) + users = (await session.execute(rows_stmt)).scalars().all() + + return users, total + + +async def is_last_active_admin(session: AsyncSession, user_id: uuid.UUID) -> bool: + """Return True if ``user_id`` is the only remaining active admin.""" + stmt = ( + select(func.count()) + .select_from(User) + .where( + User.role == SystemRole.ADMIN.value, + User.is_active.is_(True), + User.id != user_id, + ) + ) + other_admins = (await session.execute(stmt)).scalar_one() + return other_admins == 0 diff --git a/app/repositories/user.py b/app/repositories/user.py index 399dfbb..06cec87 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -91,6 +91,41 @@ async def reactivate_user(session: AsyncSession, user: User) -> User: return db_user +async def suspend_user(session: AsyncSession, user: User) -> User: + """Admin-initiated permanent suspension. + + Sets ``is_active=False`` and stamps ``suspended_at``. Deliberately does NOT + set ``deletion_scheduled_at`` — suspended accounts are never auto-deleted + by the cleanup worker and the target user cannot self-reactivate. + """ + locked = await session.execute( + select(User).where(User.id == user.id).with_for_update() + ) + db_user = locked.scalars().one() + + db_user.is_active = False + db_user.suspended_at = utc_now() + session.add(db_user) + await session.commit() + await session.refresh(db_user) + return db_user + + +async def unsuspend_user(session: AsyncSession, user: User) -> User: + """Lift an admin suspension and re-enable the account.""" + locked = await session.execute( + select(User).where(User.id == user.id).with_for_update() + ) + db_user = locked.scalars().one() + + db_user.is_active = True + db_user.suspended_at = None + session.add(db_user) + await session.commit() + await session.refresh(db_user) + return db_user + + async def get_users_due_for_deletion( session: AsyncSession, now: datetime, limit: int ) -> Sequence[User]: @@ -105,6 +140,9 @@ async def get_users_due_for_deletion( User.is_active.is_(False), User.deletion_scheduled_at.is_not(None), User.deletion_scheduled_at <= now, + # Admin-suspended rows must never be auto-deleted, even if a stale + # deletion_scheduled_at is ever present alongside suspended_at. + User.suspended_at.is_(None), ) .order_by(User.deletion_scheduled_at) .with_for_update(skip_locked=True) diff --git a/app/schemas/admin.py b/app/schemas/admin.py new file mode 100644 index 0000000..62cd573 --- /dev/null +++ b/app/schemas/admin.py @@ -0,0 +1,99 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from app.schemas.common import ActivityDetails +from app.schemas.user import SystemRole +from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType + + +class AdminUserUpdate(BaseModel): + """Fields an admin may change on another user's account. + + Email is intentionally NOT in this schema: an admin must never be able to + rewrite a user's identity (login + recovery channel). With ``extra=forbid`` + a stray ``email`` key in the request body returns 422 — defence in depth + on top of the FE form which doesn't expose the field at all. + """ + + model_config = ConfigDict(extra="forbid") + + first_name: str | None = Field(default=None, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + title: str | None = Field(default=None, max_length=100) + role: SystemRole | None = None + is_active: bool | None = None + is_verified: bool | None = None + + +class AdminUserListItem(BaseModel): + """Row shape returned by the admin user listing endpoint.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: EmailStr + first_name: str | None = None + last_name: str | None = None + title: str | None = None + role: SystemRole + is_active: bool + is_verified: bool + created_at: datetime + updated_at: datetime + deactivated_at: datetime | None = None + deletion_scheduled_at: datetime | None = None + suspended_at: datetime | None = None + + +class AdminUserListResponse(BaseModel): + """Paginated admin user listing payload.""" + + data: list[AdminUserListItem] + total: int + skip: int + limit: int + + +class AdminUserUpdateResponse(BaseModel): + """Standard response returned after mutating a user via the admin API.""" + + user: AdminUserListItem + message: str + + +class AdminActivityItem(BaseModel): + """Row shape returned by the admin activity log endpoint.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + user_id: uuid.UUID + activity_type: ActivityType + resource_type: ResourceType + resource_id: uuid.UUID | None = None + details: ActivityDetails + status: ActivityStatus + ip_address: str | None = None + user_agent: str | None = None + created_at: datetime + + +class AdminActivityListResponse(BaseModel): + """Paginated activity log payload.""" + + data: list[AdminActivityItem] + total: int + skip: int + limit: int + + +class AdminStats(BaseModel): + """Aggregate counts powering the admin dashboard overview.""" + + users_total: int + users_active: int + users_verified: int + users_admins: int + activities_total: int diff --git a/app/schemas/user.py b/app/schemas/user.py index 4eedf02..4d370e5 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -91,6 +91,7 @@ class UserPublic(UserBase): updated_at: datetime deactivated_at: datetime | None = None deletion_scheduled_at: datetime | None = None + suspended_at: datetime | None = None class UserUpdateResponse(BaseModel): diff --git a/app/services/admin/__init__.py b/app/services/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/admin/activity_service.py b/app/services/admin/activity_service.py new file mode 100644 index 0000000..a0d51cd --- /dev/null +++ b/app/services/admin/activity_service.py @@ -0,0 +1,69 @@ +import uuid +from datetime import datetime + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.messages.error_message import ErrorMessages +from app.repositories.admin.activity import list_activities_admin +from app.repositories.user import get_user_by_id +from app.schemas.admin import AdminActivityItem, AdminActivityListResponse +from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType + + +async def list_activities_admin_service( + session: AsyncSession, + *, + skip: int, + limit: int, + user_id: uuid.UUID | None, + activity_type: ActivityType | None, + resource_type: ResourceType | None, + status_filter: ActivityStatus | None, + date_from: datetime | None, + date_to: datetime | None, +) -> AdminActivityListResponse: + """Return a filtered, paginated activity log view for the admin panel.""" + activities, total = await list_activities_admin( + session, + skip=skip, + limit=limit, + user_id=user_id, + activity_type=activity_type, + resource_type=resource_type, + status=status_filter, + date_from=date_from, + date_to=date_to, + ) + return AdminActivityListResponse( + data=[AdminActivityItem.model_validate(a) for a in activities], + total=total, + skip=skip, + limit=limit, + ) + + +async def list_user_activities_admin_service( + session: AsyncSession, + *, + user_id: uuid.UUID, + skip: int, + limit: int, +) -> AdminActivityListResponse: + """Return the activity log for a single user (admin drill-down view).""" + target = await get_user_by_id(session, user_id) + if not target: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ErrorMessages.USER_NOT_FOUND, + ) + + activities, total = await list_activities_admin( + session, skip=skip, limit=limit, user_id=user_id + ) + return AdminActivityListResponse( + data=[AdminActivityItem.model_validate(a) for a in activities], + total=total, + skip=skip, + limit=limit, + ) diff --git a/app/services/admin/stats_service.py b/app/services/admin/stats_service.py new file mode 100644 index 0000000..4c7c695 --- /dev/null +++ b/app/services/admin/stats_service.py @@ -0,0 +1,9 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.repositories.admin.stats import get_admin_stats +from app.schemas.admin import AdminStats + + +async def get_admin_stats_service(session: AsyncSession) -> AdminStats: + """Return the aggregate dashboard counts as a validated response model.""" + return AdminStats(**await get_admin_stats(session)) diff --git a/app/services/admin/user_service.py b/app/services/admin/user_service.py new file mode 100644 index 0000000..b0c1ec4 --- /dev/null +++ b/app/services/admin/user_service.py @@ -0,0 +1,301 @@ +import uuid + +from fastapi import HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.email import send_email +from app.core.messages.error_message import ErrorMessages +from app.core.messages.success_message import SuccessMessages +from app.core.security import create_password_reset_token +from app.models.user import User +from app.repositories.admin.user import ( + is_last_active_admin, + list_users_admin, +) +from app.repositories.user import ( + delete_user, + get_user_by_id, + suspend_user, + unsuspend_user, + update_user, +) +from app.schemas.admin import ( + AdminUserListItem, + AdminUserListResponse, + AdminUserUpdate, +) +from app.schemas.msg import Message +from app.schemas.user import Language, SystemRole +from app.schemas.user_activity import ActivityType, ResourceType +from app.use_cases.log_activity import log_activity +from app.utils.email_templates import generate_password_reset_email + + +async def list_users_admin_service( + session: AsyncSession, + *, + skip: int, + limit: int, + search: str | None, + role: SystemRole | None, + is_active: bool | None, + is_verified: bool | None, +) -> AdminUserListResponse: + """Return the filtered, paginated admin user list.""" + users, total = await list_users_admin( + session, + skip=skip, + limit=limit, + search=search, + role=role, + is_active=is_active, + is_verified=is_verified, + ) + return AdminUserListResponse( + data=[AdminUserListItem.model_validate(u) for u in users], + total=total, + skip=skip, + limit=limit, + ) + + +async def get_user_admin_service( + session: AsyncSession, user_id: uuid.UUID +) -> AdminUserListItem: + """Return a single user's full admin view or raise 404.""" + user = await get_user_by_id(session, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ErrorMessages.USER_NOT_FOUND, + ) + return AdminUserListItem.model_validate(user) + + +async def _load_target(session: AsyncSession, user_id: uuid.UUID) -> User: + """Fetch a target user for admin mutation or raise 404.""" + target = await get_user_by_id(session, user_id) + if not target: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ErrorMessages.USER_NOT_FOUND, + ) + return target + + +def _guard_not_self(admin_id: uuid.UUID, target_id: uuid.UUID, message: str) -> None: + """Raise 400 when an admin targets their own account for a protected action.""" + if admin_id == target_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=message, + ) + + +async def update_user_admin_service( + request: Request, + session: AsyncSession, + current_user: User, + user_id: uuid.UUID, + payload: AdminUserUpdate, +) -> AdminUserListItem: + """Apply an admin-authored update to a user, honouring last-admin guards.""" + target = await _load_target(session, user_id) + + update_data = payload.model_dump(exclude_unset=True) + + new_role = update_data.get("role") + if new_role is not None and new_role != target.role: + _guard_not_self( + current_user.id, target.id, ErrorMessages.ADMIN_CANNOT_MODIFY_SELF + ) + demoting_admin = ( + target.role == SystemRole.ADMIN.value and new_role != SystemRole.ADMIN + ) + if demoting_admin and await is_last_active_admin(session, target.id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorMessages.ADMIN_CANNOT_DEMOTE_LAST_ADMIN, + ) + update_data["role"] = new_role.value + + if "is_active" in update_data and update_data["is_active"] != target.is_active: + _guard_not_self( + current_user.id, target.id, ErrorMessages.ADMIN_CANNOT_MODIFY_SELF + ) + deactivating_admin = ( + target.role == SystemRole.ADMIN.value and update_data["is_active"] is False + ) + if deactivating_admin and await is_last_active_admin(session, target.id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorMessages.ADMIN_CANNOT_DEMOTE_LAST_ADMIN, + ) + + updated = await update_user(session, target, update_data) + + await log_activity( + session=session, + user_id=current_user.id, + activity_type=ActivityType.UPDATE, + resource_type=ResourceType.USER, + resource_id=updated.id, + details={"updated_fields": list(update_data.keys()), "by_admin": True}, + request=request, + ) + + return AdminUserListItem.model_validate(updated) + + +async def suspend_user_admin_service( + request: Request, + session: AsyncSession, + current_user: User, + user_id: uuid.UUID, +) -> Message: + """Permanently suspend a user. + + The suspended account cannot log in and cannot self-reactivate. It is never + auto-deleted — only an admin may lift the suspension via unsuspend. + """ + target = await _load_target(session, user_id) + _guard_not_self(current_user.id, target.id, ErrorMessages.ADMIN_CANNOT_MODIFY_SELF) + + if target.role == SystemRole.ADMIN.value and await is_last_active_admin( + session, target.id + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorMessages.ADMIN_CANNOT_DEMOTE_LAST_ADMIN, + ) + + if target.suspended_at is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorMessages.ACCOUNT_ALREADY_SUSPENDED, + ) + + await suspend_user(session, target) + + await log_activity( + session=session, + user_id=current_user.id, + activity_type=ActivityType.UPDATE, + resource_type=ResourceType.USER, + resource_id=target.id, + details={"action": "admin_suspended_user"}, + request=request, + ) + + return Message(success=True, message=SuccessMessages.ADMIN_USER_SUSPENDED) + + +async def unsuspend_user_admin_service( + request: Request, + session: AsyncSession, + current_user: User, + user_id: uuid.UUID, +) -> Message: + """Lift an existing admin suspension and re-enable the account.""" + target = await _load_target(session, user_id) + + if target.suspended_at is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorMessages.ACCOUNT_NOT_SUSPENDED, + ) + + await unsuspend_user(session, target) + + await log_activity( + session=session, + user_id=current_user.id, + activity_type=ActivityType.UPDATE, + resource_type=ResourceType.USER, + resource_id=target.id, + details={"action": "admin_unsuspended_user"}, + request=request, + ) + + return Message(success=True, message=SuccessMessages.ADMIN_USER_UNSUSPENDED) + + +async def delete_user_admin_service( + request: Request, + session: AsyncSession, + current_user: User, + user_id: uuid.UUID, +) -> Message: + """Hard-delete a user. Protects the admin's own account and the last admin.""" + target = await _load_target(session, user_id) + _guard_not_self(current_user.id, target.id, ErrorMessages.ADMIN_CANNOT_DELETE_SELF) + + if target.role == SystemRole.ADMIN.value and await is_last_active_admin( + session, target.id + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorMessages.ADMIN_CANNOT_DELETE_LAST_ADMIN, + ) + + target_id = target.id + target_email = target.email + + await delete_user(session, target) + + await log_activity( + session=session, + user_id=current_user.id, + activity_type=ActivityType.DELETE, + resource_type=ResourceType.USER, + resource_id=target_id, + details={"action": "admin_deleted_user", "email": target_email}, + request=request, + ) + + return Message(success=True, message=SuccessMessages.ADMIN_USER_DELETED) + + +async def reset_password_admin_service( + request: Request, + session: AsyncSession, + current_user: User, + user_id: uuid.UUID, + lang: Language = Language.EN, +) -> Message: + """Trigger a password-reset email on behalf of the target user. + + The admin never sees or sets the new password; the user completes the reset + via the standard email-link flow used by self-service password recovery. + """ + target = await _load_target(session, user_id) + + token = create_password_reset_token(target.email) + reset_url = f"{settings.FRONTEND_HOST}/reset-password?token={token}" + + email_data = generate_password_reset_email( + reset_link=reset_url, + project_name=settings.PROJECT_NAME, + lang=lang, + ) + await send_email( + to=target.email, + subject=email_data["subject"], + body=email_data["html"], + plain_text=email_data["plain_text"], + user_id=str(target.id), + is_html=True, + ) + + await log_activity( + session=session, + user_id=current_user.id, + activity_type=ActivityType.UPDATE, + resource_type=ResourceType.AUTH, + resource_id=target.id, + details={"action": "admin_triggered_password_reset"}, + request=request, + ) + + return Message(success=True, message=SuccessMessages.ADMIN_PASSWORD_RESET_SENT) diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 4f77e25..ab48adf 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -28,8 +28,8 @@ from app.schemas.token import AuthTokens, Token from app.schemas.user import Language, UpdatePassword, UserCreate, UserPublic from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType -from app.services.user_activity_service import log_activity from app.services.user_service import create_user_service +from app.use_cases.log_activity import log_activity from app.utils.email_templates import ( generate_email_verification_email, generate_password_reset_email, @@ -113,6 +113,25 @@ async def authenticate( detail=ErrorMessages.INVALID_CREDENTIALS, ) + # Admin-suspended accounts are permanently locked out. This guard must run + # before the grace-window fall-through below, otherwise a suspended user + # would still receive tokens. + if user.suspended_at is not None: + if request: + await log_activity( + session=session, + user_id=user.id, + activity_type=ActivityType.LOGIN, + resource_type=ResourceType.AUTH, + status=ActivityStatus.FAILURE, + details={"reason": "account_suspended", "email": email}, + request=request, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ErrorMessages.ACCOUNT_SUSPENDED, + ) + # Accounts in the deletion grace window (is_active=False + deletion_scheduled_at) # are allowed to log in so the frontend can render the "cancel deletion" page. # The ``get_current_active_user`` dep still blocks them from regular endpoints. @@ -196,7 +215,7 @@ async def refresh_token_service( # Refresh works for users in the deletion grace window too (so they stay # on the cancel-deletion page without repeatedly re-authenticating). Only - # hard-deleted users are blocked. + # hard-deleted and admin-suspended users are blocked. user = await get_user_by_id(session, parsed_user_id) if not user: if request: @@ -214,6 +233,22 @@ async def refresh_token_service( detail=ErrorMessages.USER_INACTIVE, ) + if user.suspended_at is not None: + if request: + await log_activity( + session=session, + user_id=parsed_user_id, + activity_type=ActivityType.LOGIN, + resource_type=ResourceType.AUTH, + status=ActivityStatus.FAILURE, + details={"reason": "account_suspended"}, + request=request, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ErrorMessages.ACCOUNT_SUSPENDED, + ) + return Token( access_token=create_access_token(user_id), message=SuccessMessages.LOGIN_SUCCESS ) diff --git a/app/services/user_service.py b/app/services/user_service.py index 7c9e6e0..4f7d3f9 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -29,7 +29,7 @@ UserUpdateMe, ) from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType -from app.services.user_activity_service import log_activity +from app.use_cases.log_activity import log_activity from app.utils.email_templates import generate_account_deactivation_email @@ -114,6 +114,14 @@ async def reactivate_own_account_service( request: Request, session: AsyncSession, current_user: User ) -> Message: """Cancel a pending deletion and re-enable the account.""" + # Admin-suspended accounts cannot self-recover, even if an old session + # token is still in hand. Only an admin unsuspend lifts this state. + if current_user.suspended_at is not None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ErrorMessages.ACCOUNT_SUSPENDED, + ) + if current_user.deletion_scheduled_at is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/app/tests/admin/__init__.py b/app/tests/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/admin/conftest.py b/app/tests/admin/conftest.py new file mode 100644 index 0000000..13962fd --- /dev/null +++ b/app/tests/admin/conftest.py @@ -0,0 +1,78 @@ +"""Shared fixtures and helpers for the /admin endpoint tests. + +Kept in a sub-conftest so every file under ``app/tests/admin/`` gets the +admin-authenticated client and the seed helpers without re-importing them. +""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import select, update + +from app.models.user import User +from app.schemas.user import SystemRole +from app.tests.conftest import TestingSessionLocal + + +async def register_and_verify( + client: AsyncClient, + email: str, + password: str = "password123", +) -> None: + """Register a user and mark them verified so they can log in.""" + await client.post( + "/auth/register", + json={ + "email": email, + "password": password, + "first_name": "F", + "last_name": "L", + "title": "T", + }, + ) + async with TestingSessionLocal() as session: + await session.execute( + update(User).where(User.email == email).values(is_verified=True) + ) + await session.commit() + + +async def promote_to_admin(email: str) -> None: + """Directly flip a user's role to admin in the DB.""" + async with TestingSessionLocal() as session: + await session.execute( + update(User).where(User.email == email).values(role=SystemRole.ADMIN.value) + ) + await session.commit() + + +async def login(client: AsyncClient, email: str, password: str = "password123") -> None: + """Log in and let httpx store the returned auth cookies on the client.""" + response = await client.post( + "/auth/login", + data={"username": email, "password": password}, + ) + assert response.status_code == 200, response.text + + +async def get_user_id(email: str) -> str: + """Resolve a user's UUID from their email for use in path params.""" + async with TestingSessionLocal() as session: + result = await session.execute(select(User).where(User.email == email)) + return str(result.scalars().one().id) + + +@pytest.fixture +async def admin_client(client: AsyncClient) -> AsyncClient: + """Return an authenticated client whose user has admin role.""" + await register_and_verify(client, "admin@test.com") + await promote_to_admin("admin@test.com") + await login(client, "admin@test.com") + return client + + +@pytest.fixture +async def regular_client(client: AsyncClient) -> AsyncClient: + """Return an authenticated client whose user has the default user role.""" + await register_and_verify(client, "user@test.com") + await login(client, "user@test.com") + return client diff --git a/app/tests/admin/test_activities.py b/app/tests/admin/test_activities.py new file mode 100644 index 0000000..9906126 --- /dev/null +++ b/app/tests/admin/test_activities.py @@ -0,0 +1,61 @@ +"""End-to-end tests for /admin/activities endpoints.""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from app.models.user import User +from app.models.user_activity import UserActivity +from app.tests.admin.conftest import ( + get_user_id, + login, + register_and_verify, +) +from app.tests.conftest import TestingSessionLocal + + +@pytest.mark.asyncio +async def test_list_user_activities_returns_that_users_rows(admin_client: AsyncClient): + """Per-user activities endpoint must return only the targeted user's rows.""" + await register_and_verify(admin_client, "acts@test.com") + user_id = await get_user_id("acts@test.com") + await login(admin_client, "acts@test.com") # generates a LOGIN activity + # Log back in as the admin so the admin cookie is reinstated. + await login(admin_client, "admin@test.com") + + response = await admin_client.get(f"/admin/users/{user_id}/activities") + assert response.status_code == 200 + body = response.json() + assert body["total"] >= 1 + assert all(item["user_id"] == user_id for item in body["data"]) + + +@pytest.mark.asyncio +async def test_list_activities_global_filters(admin_client: AsyncClient): + """Global activities endpoint paginates all rows and supports filters.""" + # Seed a failure row so the status filter has something to find. + async with TestingSessionLocal() as session: + admin = ( + (await session.execute(select(User).where(User.email == "admin@test.com"))) + .scalars() + .one() + ) + session.add( + UserActivity( + user_id=admin.id, + activity_type="login", + resource_type="auth", + details={"reason": "invalid_password"}, + status="failure", + ) + ) + await session.commit() + + response = await admin_client.get("/admin/activities?limit=100") + assert response.status_code == 200 + body = response.json() + assert body["total"] >= 1 + + response = await admin_client.get("/admin/activities?status=failure") + body = response.json() + assert all(item["status"] == "failure" for item in body["data"]) diff --git a/app/tests/admin/test_stats.py b/app/tests/admin/test_stats.py new file mode 100644 index 0000000..f851aeb --- /dev/null +++ b/app/tests/admin/test_stats.py @@ -0,0 +1,55 @@ +"""End-to-end tests for the GET /admin/stats dashboard endpoint.""" + +import pytest +from httpx import AsyncClient + +from app.tests.admin.conftest import register_and_verify + + +@pytest.mark.asyncio +async def test_stats_requires_admin(regular_client: AsyncClient): + """A non-admin must receive 403 from /admin/stats.""" + response = await regular_client.get("/admin/stats") + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_stats_without_auth_returns_401(client: AsyncClient): + """An unauthenticated caller must receive 401, not 403.""" + response = await client.get("/admin/stats") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_stats_returns_expected_counts(admin_client: AsyncClient): + """Stats payload reflects the seeded admin plus verified and unverified users.""" + await register_and_verify(admin_client, "alice@test.com") + await register_and_verify(admin_client, "bob@test.com") + await admin_client.post( + "/auth/register", + json={ + "email": "unverified@test.com", + "password": "password123", + "first_name": "U", + "last_name": "V", + "title": "T", + }, + ) + + response = await admin_client.get("/admin/stats") + assert response.status_code == 200 + body = response.json() + + assert set(body.keys()) == { + "users_total", + "users_active", + "users_verified", + "users_admins", + "activities_total", + } + assert body["users_total"] >= 4 + assert body["users_active"] >= 4 + assert body["users_verified"] >= 3 + assert body["users_verified"] < body["users_total"] + assert body["users_admins"] >= 1 + assert body["activities_total"] >= 1 diff --git a/app/tests/admin/test_users.py b/app/tests/admin/test_users.py new file mode 100644 index 0000000..74e301f --- /dev/null +++ b/app/tests/admin/test_users.py @@ -0,0 +1,330 @@ +"""End-to-end tests for /admin/users endpoints. + +Covers listing, detail, update, suspend/unsuspend, delete, password reset, +self-protection, and the last-admin repository guard. +""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from app.core.messages.error_message import ErrorMessages +from app.core.messages.success_message import SuccessMessages +from app.models.user import User +from app.schemas.user import SystemRole +from app.tests.admin.conftest import ( + get_user_id, + promote_to_admin, + register_and_verify, +) +from app.tests.conftest import TestingSessionLocal + + +@pytest.mark.asyncio +async def test_list_users_requires_admin(regular_client: AsyncClient): + """A non-admin must receive 403 from any /admin endpoint.""" + response = await regular_client.get("/admin/users") + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_list_users_without_auth_returns_401(client: AsyncClient): + """An unauthenticated caller must receive 401, not 403.""" + response = await client.get("/admin/users") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_list_users_paginates_and_filters(admin_client: AsyncClient): + """Listing returns the admin plus seeded users and honours filters.""" + await register_and_verify(admin_client, "alice@test.com") + await register_and_verify(admin_client, "bob@test.com") + + response = await admin_client.get("/admin/users?limit=10") + assert response.status_code == 200 + body = response.json() + assert body["total"] >= 3 + emails = [u["email"] for u in body["data"]] + assert "alice@test.com" in emails + + response = await admin_client.get("/admin/users?search=alice") + body = response.json() + assert body["total"] == 1 + assert body["data"][0]["email"] == "alice@test.com" + + response = await admin_client.get(f"/admin/users?role={SystemRole.ADMIN.value}") + body = response.json() + assert all(u["role"] == SystemRole.ADMIN.value for u in body["data"]) + + +@pytest.mark.asyncio +async def test_get_user_returns_detail(admin_client: AsyncClient): + """GET /admin/users/{id} returns the full admin view.""" + await register_and_verify(admin_client, "detail@test.com") + user_id = await get_user_id("detail@test.com") + + response = await admin_client.get(f"/admin/users/{user_id}") + assert response.status_code == 200 + assert response.json()["email"] == "detail@test.com" + + +@pytest.mark.asyncio +async def test_get_user_not_found(admin_client: AsyncClient): + """Unknown user id must return 404 with the shared error code.""" + response = await admin_client.get( + "/admin/users/00000000-0000-0000-0000-000000000000" + ) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_user_changes_profile(admin_client: AsyncClient): + """PATCH updates the profile fields and returns the new detail.""" + await register_and_verify(admin_client, "edit@test.com") + user_id = await get_user_id("edit@test.com") + + response = await admin_client.patch( + f"/admin/users/{user_id}", + json={"first_name": "Edited", "title": "Staff"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["message"] == SuccessMessages.ADMIN_USER_UPDATED + assert body["user"]["first_name"] == "Edited" + assert body["user"]["title"] == "Staff" + + +@pytest.mark.asyncio +async def test_admin_cannot_change_user_email(admin_client: AsyncClient): + """An ``email`` key in the admin update payload is rejected by the schema. + + Identity (login + recovery channel) is owned by the user; an admin must + never be able to rewrite it. ``extra=forbid`` on AdminUserUpdate turns the + field into a 422 so it can't be dropped silently. + """ + await register_and_verify(admin_client, "identity@test.com") + user_id = await get_user_id("identity@test.com") + + response = await admin_client.patch( + f"/admin/users/{user_id}", + json={"email": "stolen@test.com"}, + ) + assert response.status_code == 422 + + async with TestingSessionLocal() as session: + result = await session.execute( + select(User).where(User.email == "identity@test.com") + ) + assert result.scalars().one_or_none() is not None # Email unchanged + result = await session.execute( + select(User).where(User.email == "stolen@test.com") + ) + assert result.scalars().first() is None + + +@pytest.mark.asyncio +async def test_admin_cannot_demote_self(admin_client: AsyncClient): + """An admin must not be able to change their own role.""" + admin_id = await get_user_id("admin@test.com") + + response = await admin_client.patch( + f"/admin/users/{admin_id}", + json={"role": SystemRole.USER.value}, + ) + assert response.status_code == 400 + assert response.json()["error"] == ErrorMessages.ADMIN_CANNOT_MODIFY_SELF + + +@pytest.mark.asyncio +async def test_demote_non_last_admin_succeeds(admin_client: AsyncClient): + """A second admin can be demoted when another active admin still remains.""" + await register_and_verify(admin_client, "second-admin@test.com") + await promote_to_admin("second-admin@test.com") + second_admin_id = await get_user_id("second-admin@test.com") + + response = await admin_client.patch( + f"/admin/users/{second_admin_id}", + json={"role": SystemRole.USER.value}, + ) + assert response.status_code == 200 + assert response.json()["user"]["role"] == SystemRole.USER.value + + +@pytest.mark.asyncio +async def test_is_last_active_admin_repository(): + """Repository guard correctly flags the sole remaining active admin. + + Exercised at the repo layer because the HTTP flow always has an active + admin caller, so the route-level guard is defence-in-depth for states + the dependency chain rejects. + """ + from app.core.security import get_password_hash + from app.repositories.admin.user import is_last_active_admin + + async with TestingSessionLocal() as session: + only_admin = User( + email="solo-admin@test.com", + hashed_password=get_password_hash("password123"), + role=SystemRole.ADMIN.value, + is_active=True, + is_verified=True, + ) + inactive_admin = User( + email="inactive-admin@test.com", + hashed_password=get_password_hash("password123"), + role=SystemRole.ADMIN.value, + is_active=False, + is_verified=True, + ) + regular = User( + email="plain@test.com", + hashed_password=get_password_hash("password123"), + role=SystemRole.USER.value, + is_active=True, + is_verified=True, + ) + session.add_all([only_admin, inactive_admin, regular]) + await session.commit() + await session.refresh(only_admin) + + assert await is_last_active_admin(session, only_admin.id) is True + + second_admin = User( + email="second-admin@test.com", + hashed_password=get_password_hash("password123"), + role=SystemRole.ADMIN.value, + is_active=True, + is_verified=True, + ) + session.add(second_admin) + await session.commit() + + assert await is_last_active_admin(session, only_admin.id) is False + + +@pytest.mark.asyncio +async def test_suspend_and_unsuspend_user(admin_client: AsyncClient): + """Suspend then unsuspend a user; suspended rows never get a deletion schedule.""" + await register_and_verify(admin_client, "toggle@test.com") + user_id = await get_user_id("toggle@test.com") + + response = await admin_client.post(f"/admin/users/{user_id}/suspend") + assert response.status_code == 200 + assert response.json()["message"] == SuccessMessages.ADMIN_USER_SUSPENDED + + async with TestingSessionLocal() as session: + user = ( + (await session.execute(select(User).where(User.email == "toggle@test.com"))) + .scalars() + .one() + ) + assert user.is_active is False + assert user.suspended_at is not None + # Critical invariant: admin suspension must NOT schedule deletion. + assert user.deletion_scheduled_at is None + + response = await admin_client.post(f"/admin/users/{user_id}/unsuspend") + assert response.status_code == 200 + assert response.json()["message"] == SuccessMessages.ADMIN_USER_UNSUSPENDED + + async with TestingSessionLocal() as session: + user = ( + (await session.execute(select(User).where(User.email == "toggle@test.com"))) + .scalars() + .one() + ) + assert user.is_active is True + assert user.suspended_at is None + + +@pytest.mark.asyncio +async def test_admin_cannot_suspend_self(admin_client: AsyncClient): + """Self-suspension must be blocked for admins.""" + admin_id = await get_user_id("admin@test.com") + response = await admin_client.post(f"/admin/users/{admin_id}/suspend") + assert response.status_code == 400 + assert response.json()["error"] == ErrorMessages.ADMIN_CANNOT_MODIFY_SELF + + +@pytest.mark.asyncio +async def test_admin_cannot_suspend_already_suspended(admin_client: AsyncClient): + """Re-suspending a suspended user returns the dedicated error code.""" + await register_and_verify(admin_client, "already-suspended@test.com") + user_id = await get_user_id("already-suspended@test.com") + + first = await admin_client.post(f"/admin/users/{user_id}/suspend") + assert first.status_code == 200 + + second = await admin_client.post(f"/admin/users/{user_id}/suspend") + assert second.status_code == 400 + assert second.json()["error"] == ErrorMessages.ACCOUNT_ALREADY_SUSPENDED + + +@pytest.mark.asyncio +async def test_admin_cannot_unsuspend_not_suspended(admin_client: AsyncClient): + """Unsuspending an active user returns the dedicated error code.""" + await register_and_verify(admin_client, "never-suspended@test.com") + user_id = await get_user_id("never-suspended@test.com") + + response = await admin_client.post(f"/admin/users/{user_id}/unsuspend") + assert response.status_code == 400 + assert response.json()["error"] == ErrorMessages.ACCOUNT_NOT_SUSPENDED + + +@pytest.mark.asyncio +async def test_delete_user_removes_row(admin_client: AsyncClient): + """DELETE permanently removes the target user.""" + await register_and_verify(admin_client, "bye@test.com") + user_id = await get_user_id("bye@test.com") + + response = await admin_client.delete(f"/admin/users/{user_id}") + assert response.status_code == 200 + assert response.json()["message"] == SuccessMessages.ADMIN_USER_DELETED + + async with TestingSessionLocal() as session: + result = await session.execute(select(User).where(User.email == "bye@test.com")) + assert result.scalars().first() is None + + +@pytest.mark.asyncio +async def test_admin_cannot_delete_self(admin_client: AsyncClient): + """Self-delete must be rejected with the dedicated error code.""" + admin_id = await get_user_id("admin@test.com") + response = await admin_client.delete(f"/admin/users/{admin_id}") + assert response.status_code == 400 + assert response.json()["error"] == ErrorMessages.ADMIN_CANNOT_DELETE_SELF + + +@pytest.mark.asyncio +async def test_delete_non_last_admin_succeeds(admin_client: AsyncClient): + """A second admin can be deleted while another active admin remains.""" + await register_and_verify(admin_client, "other-admin@test.com") + await promote_to_admin("other-admin@test.com") + other_admin_id = await get_user_id("other-admin@test.com") + + response = await admin_client.delete(f"/admin/users/{other_admin_id}") + assert response.status_code == 200 + assert response.json()["message"] == SuccessMessages.ADMIN_USER_DELETED + + async with TestingSessionLocal() as session: + result = await session.execute( + select(User).where(User.email == "other-admin@test.com") + ) + assert result.scalars().first() is None + + +@pytest.mark.asyncio +async def test_reset_password_sends_email(admin_client: AsyncClient, mock_email_send): + """Reset endpoint must hit the email service with the target user's address.""" + await register_and_verify(admin_client, "resetme@test.com") + user_id = await get_user_id("resetme@test.com") + + mock_email_send.reset_mock() + + response = await admin_client.post(f"/admin/users/{user_id}/reset-password") + assert response.status_code == 200 + assert response.json()["message"] == SuccessMessages.ADMIN_PASSWORD_RESET_SENT + + mock_email_send.assert_awaited_once() + call_kwargs = mock_email_send.await_args.kwargs + assert call_kwargs["to"] == "resetme@test.com" diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 2dd66b5..c83b8a8 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -79,6 +79,8 @@ async def mock_email_send(): with ( patch("app.core.email.send_email", new_callable=AsyncMock) as core_mock, patch("app.services.auth_service.send_email", new=core_mock), + patch("app.services.user_service.send_email", new=core_mock), + patch("app.services.admin.user_service.send_email", new=core_mock), ): yield core_mock diff --git a/app/tests/test_auth.py b/app/tests/test_auth.py index e603427..ef57271 100644 --- a/app/tests/test_auth.py +++ b/app/tests/test_auth.py @@ -318,3 +318,34 @@ async def test_change_password(client: AsyncClient): "/auth/login", data={"username": email, "password": old_password} ) assert old_login_response.status_code == 401 + + +@pytest.mark.asyncio +async def test_suspended_user_login_returns_account_suspended(client: AsyncClient): + """A suspended account must be refused at login with the dedicated code.""" + from sqlalchemy import update + + from app.models.user import User + from app.tests.conftest import TestingSessionLocal + from app.utils import utc_now + + email = "suspended@test.com" + password = "password123" + await client.post( + "/auth/register", + json={"email": email, "password": password, "first_name": "Sus"}, + ) + + async with TestingSessionLocal() as session: + await session.execute( + update(User) + .where(User.email == email) + .values(is_verified=True, is_active=False, suspended_at=utc_now()) + ) + await session.commit() + + response = await client.post( + "/auth/login", data={"username": email, "password": password} + ) + assert response.status_code == 403 + assert response.json()["error"] == ErrorMessages.ACCOUNT_SUSPENDED diff --git a/app/tests/test_deletion_worker.py b/app/tests/test_deletion_worker.py index fcf8a48..e344d47 100644 --- a/app/tests/test_deletion_worker.py +++ b/app/tests/test_deletion_worker.py @@ -143,3 +143,28 @@ async def test_repository_helpers_return_expected_set(): for user in due: await hard_delete_user(session, user) + + +@pytest.mark.asyncio +async def test_suspended_user_is_never_due_for_deletion(): + """Suspended rows must be skipped even if a stale deletion_scheduled_at exists. + + Guards the invariant that admin-suspended accounts are permanent and never + auto-deleted — the worker filter defends against any future code path that + might accidentally set deletion_scheduled_at on a suspended row. + """ + async with TestingSessionLocal() as session: + suspended = User( + email="suspended-worker@test.com", + hashed_password=get_password_hash("password123"), + is_active=False, + is_verified=True, + suspended_at=utc_now(), + # Deliberately drift: simulate a buggy writer that set both fields. + deletion_scheduled_at=utc_now() - timedelta(hours=1), + ) + session.add(suspended) + await session.commit() + + due = await get_users_due_for_deletion(session, now=utc_now(), limit=10) + assert all(u.email != "suspended-worker@test.com" for u in due) diff --git a/app/tests/test_users.py b/app/tests/test_users.py index 03d8159..d7a8b31 100644 --- a/app/tests/test_users.py +++ b/app/tests/test_users.py @@ -201,6 +201,29 @@ async def test_reactivate_on_active_account_returns_400(auth_client: AsyncClient assert response.status_code == 400 +@pytest.mark.asyncio +async def test_suspended_user_cannot_self_reactivate(auth_client: AsyncClient): + """Admin-suspended users must not be able to lift their own suspension.""" + from sqlalchemy import update + + from app.core.messages.error_message import ErrorMessages + from app.models.user import User + from app.tests.conftest import TestingSessionLocal + from app.utils import utc_now + + async with TestingSessionLocal() as session: + await session.execute( + update(User) + .where(User.email == "user_test@test.com") + .values(is_active=False, suspended_at=utc_now()) + ) + await session.commit() + + response = await auth_client.post("/users/me/reactivate") + assert response.status_code == 403 + assert response.json()["error"] == ErrorMessages.ACCOUNT_SUSPENDED + + @pytest.mark.asyncio async def test_me_exposes_deletion_schedule(auth_client: AsyncClient): """GET /users/me must include deletion_scheduled_at for deactivated users.""" diff --git a/app/use_cases/__init__.py b/app/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/user_activity_service.py b/app/use_cases/log_activity.py similarity index 76% rename from app/services/user_activity_service.py rename to app/use_cases/log_activity.py index 5d1a29e..dfc3919 100644 --- a/app/services/user_activity_service.py +++ b/app/use_cases/log_activity.py @@ -24,17 +24,9 @@ async def log_activity( status: ActivityStatus = ActivityStatus.SUCCESS, request: Request | None = None, ) -> UserActivity: - """ - Service to log user activity. - Automatically extracts IP address and User-Agent from the request if provided. - """ - ip_address = None - user_agent = None - - if request: - if request.client: - ip_address = request.client.host - user_agent = request.headers.get("user-agent") + """Record an audit entry, extracting IP and user-agent from the request.""" + ip_address = request.client.host if request and request.client else None + user_agent = request.headers.get("user-agent") if request else None activity_data = UserActivityCreate( user_id=user_id, diff --git a/docker-compose.yaml b/docker-compose.yaml index b918daf..f594f75 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -28,7 +28,7 @@ services: - "8000:8000" healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/v1/health/live"] - interval: 15s + interval: 10m timeout: 3s retries: 3 start_period: 40s diff --git a/pyproject.toml b/pyproject.toml index b572fc5..6fa28c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,11 @@ dev = [ "ruff>=0.15.2", ] +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +asyncio_default_test_loop_scope = "function" + [tool.ruff] target-version = "py312" line-length = 88