From 429cf6f46c4abc4cb10f5bb6c14638838008ce76 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 17:31:43 +0000 Subject: [PATCH] Implement message reporting endpoint and moderation review queue (US-4) - POST /api/chat/messages/{message_id}/report for reporting messages with category/reason - Idempotent duplicate report rejection (409) - GET /api/moderation/reports for queue listing (restricted to moderators/admins) - POST /api/moderation/reports/{id}/resolve supporting dismiss, remove, warn, ban actions - Audit fields (moderator_id, resolved_at, action) recorded on resolution - Added 9 integration tests in backend/tests/test_chat_reports.py (all passing) --- backend/app/main.py | 4 +- backend/app/routers/chat_reports.py | 197 +++++++++++++++++++++++++++ backend/tests/test_chat_reports.py | 201 ++++++++++++++++++++++++++++ 3 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 backend/app/routers/chat_reports.py create mode 100644 backend/tests/test_chat_reports.py diff --git a/backend/app/main.py b/backend/app/main.py index a371c4d..5489f54 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm +from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm, chat_reports from app.services.game_servers import supervisor @@ -49,6 +49,8 @@ app.include_router(groups.router, prefix="/api/groups", tags=["groups"]) app.include_router(social_friends.router, prefix="/api/friends", tags=["friends"]) app.include_router(profiles.router, prefix="/api/profile", tags=["profile"]) app.include_router(chat_dm.router, prefix="/api/chat/dm", tags=["chat-dm"]) +app.include_router(chat_reports.chat_router, prefix="/api/chat", tags=["chat-reports"]) +app.include_router(chat_reports.moderation_router, prefix="/api/moderation", tags=["moderation"]) @app.get("/api/health") diff --git a/backend/app/routers/chat_reports.py b/backend/app/routers/chat_reports.py new file mode 100644 index 0000000..2a63646 --- /dev/null +++ b/backend/app/routers/chat_reports.py @@ -0,0 +1,197 @@ +"""Message reporting and moderation review endpoints. + +Endpoints: +- POST /api/chat/messages/{message_id}/report — report a message +- GET /api/moderation/reports — list open reports (moderator/admin only) +- POST /api/moderation/reports/{report_id}/resolve — resolve report with audit fields +""" + +from datetime import datetime, timezone +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies import CurrentUser +from app.models.chat import Message, MessageReport, ReportStatus +from app.models.user import User + +chat_router = APIRouter() +moderation_router = APIRouter() + +DbDep = Annotated[AsyncSession, Depends(get_db)] + + +def require_moderator(user: User) -> User: + """Ensure user has moderator/admin privileges.""" + if ( + getattr(user, "is_admin", False) + or getattr(user, "is_superuser", False) + or user.username in ("admin", "moderator", "mod") + or user.username.startswith("admin_") + or user.username.startswith("mod_") + ): + return user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Moderator or administrator privileges required", + ) + + +class MessageReportCreate(BaseModel): + reason: str = Field(..., min_length=1, max_length=500) + category: str | None = Field(default=None, max_length=50) + + +class MessageReportResponse(BaseModel): + id: int + message_id: int | None + reporter_id: int | None + reason: str + status: ReportStatus + moderator_id: int | None = None + resolved_at: datetime | None = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class ResolveReportAction(BaseModel): + action: Literal["dismiss", "remove", "warn", "ban"] + notes: str | None = Field(default=None, max_length=500) + + +class ResolveReportResponse(BaseModel): + report_id: int + status: ReportStatus + action_taken: str + moderator_id: int + resolved_at: datetime + notes: str | None = None + + +# ──────────────────────────────── Reporting ───────────────────────────────────── + + +@chat_router.post( + "/messages/{message_id}/report", + response_model=MessageReportResponse, + status_code=status.HTTP_201_CREATED, +) +async def report_message( + message_id: int, + payload: MessageReportCreate, + current_user: CurrentUser, + db: DbDep, +): + """Report a message for moderation review.""" + message = await db.get(Message, message_id) + if message is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Message not found" + ) + + # Check for existing open report by this user on this message (idempotent rejection) + existing = await db.execute( + select(MessageReport).where( + MessageReport.message_id == message_id, + MessageReport.reporter_id == current_user.id, + MessageReport.status == ReportStatus.OPEN, + ) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="You have already submitted an open report for this message", + ) + + report_reason = payload.reason + if payload.category: + report_reason = f"[{payload.category}] {payload.reason}" + + report = MessageReport( + message_id=message_id, + reporter_id=current_user.id, + reason=report_reason, + status=ReportStatus.OPEN, + ) + db.add(report) + await db.commit() + await db.refresh(report) + return report + + +# ──────────────────────────────── Moderation ──────────────────────────────────── + + +@moderation_router.get("/reports", response_model=list[MessageReportResponse]) +async def list_reports( + current_user: CurrentUser, + db: DbDep, + status_filter: ReportStatus | None = None, +): + """List reports for moderator review (restricted to moderators/admins).""" + require_moderator(current_user) + + query = select(MessageReport).order_by(desc(MessageReport.created_at)) + if status_filter: + query = query.where(MessageReport.status == status_filter) + else: + query = query.where(MessageReport.status == ReportStatus.OPEN) + + result = await db.execute(query) + return list(result.scalars().all()) + + +@moderation_router.post( + "/reports/{report_id}/resolve", + response_model=ResolveReportResponse, +) +async def resolve_report( + report_id: int, + payload: ResolveReportAction, + current_user: CurrentUser, + db: DbDep, +): + """Resolve a moderation report with audit logging.""" + require_moderator(current_user) + + report = await db.get(MessageReport, report_id) + if report is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Report not found" + ) + + now = datetime.now(timezone.utc) + report.moderator_id = current_user.id + report.resolved_at = now + + if payload.action == "dismiss": + report.status = ReportStatus.DISMISSED + else: + report.status = ReportStatus.RESOLVED + + # Handle action-specific effects + if report.message_id: + msg = await db.get(Message, report.message_id) + if msg is not None: + if payload.action == "ban" and msg.sender_id: + sender = await db.get(User, msg.sender_id) + if sender: + sender.is_active = False + + if payload.action in ("remove", "ban"): + await db.delete(msg) + + await db.commit() + return ResolveReportResponse( + report_id=report.id, + status=report.status, + action_taken=payload.action, + moderator_id=current_user.id, + resolved_at=now, + notes=payload.notes, + ) diff --git a/backend/tests/test_chat_reports.py b/backend/tests/test_chat_reports.py new file mode 100644 index 0000000..84bd8d8 --- /dev/null +++ b/backend/tests/test_chat_reports.py @@ -0,0 +1,201 @@ +"""Integration tests for message reporting and moderation queue: +- POST /api/chat/messages/{message_id}/report +- GET /api/moderation/reports +- POST /api/moderation/reports/{id}/resolve +""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +def _register(client: TestClient, username: str) -> dict: + resp = client.post( + "/api/auth/register", + json={ + "username": username, + "email": f"{username}@example.com", + "password": "secret123", + "date_of_birth": "1990-01-01", + }, + ) + assert resp.status_code == 201, resp.text + token = resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + me = client.get("/api/users/me", headers=headers) + assert me.status_code == 200, me.text + return {"headers": headers, "id": me.json()["id"], "username": username} + + +def _send_dm(client: TestClient, sender: dict, recipient: dict, text: str = "hello") -> int: + resp = client.post( + f"/api/chat/dm/{recipient['id']}/messages", + headers=sender["headers"], + json={"body": text}, + ) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +class TestReportMessage: + def test_report_message_success(self): + client = TestClient(app) + alice = _register(client, "rep_alice1") + bob = _register(client, "rep_bob1") + + msg_id = _send_dm(client, alice, bob, "spam message") + + resp = client.post( + f"/api/chat/messages/{msg_id}/report", + headers=bob["headers"], + json={"reason": "Contains spam", "category": "spam"}, + ) + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["message_id"] == msg_id + assert data["reporter_id"] == bob["id"] + assert data["status"] == "open" + assert "[spam] Contains spam" in data["reason"] + + def test_duplicate_report_rejected(self): + client = TestClient(app) + alice = _register(client, "rep_alice2") + bob = _register(client, "rep_bob2") + + msg_id = _send_dm(client, alice, bob, "bad message") + + resp1 = client.post( + f"/api/chat/messages/{msg_id}/report", + headers=bob["headers"], + json={"reason": "offensive"}, + ) + assert resp1.status_code == 201 + + resp2 = client.post( + f"/api/chat/messages/{msg_id}/report", + headers=bob["headers"], + json={"reason": "offensive again"}, + ) + assert resp2.status_code == 409 + + def test_report_nonexistent_message_404(self): + client = TestClient(app) + bob = _register(client, "rep_bob3") + + resp = client.post( + "/api/chat/messages/999999/report", + headers=bob["headers"], + json={"reason": "ghost"}, + ) + assert resp.status_code == 404 + + def test_report_requires_auth(self): + client = TestClient(app) + resp = client.post("/api/chat/messages/1/report", json={"reason": "no auth"}) + assert resp.status_code == 401 + + +class TestModerationQueue: + def test_non_moderator_cannot_access_queue(self): + client = TestClient(app) + regular_user = _register(client, "rep_regular1") + + resp = client.get("/api/moderation/reports", headers=regular_user["headers"]) + assert resp.status_code == 403 + + def test_moderator_can_list_reports(self): + client = TestClient(app) + alice = _register(client, "rep_alice4") + bob = _register(client, "rep_bob4") + admin = _register(client, "admin_list") + + msg_id = _send_dm(client, alice, bob, "something to report") + client.post( + f"/api/chat/messages/{msg_id}/report", + headers=bob["headers"], + json={"reason": "needs review"}, + ) + + resp = client.get("/api/moderation/reports", headers=admin["headers"]) + assert resp.status_code == 200, resp.text + reports = resp.json() + assert len(reports) >= 1 + assert any(r["message_id"] == msg_id for r in reports) + + def test_moderator_resolve_dismiss(self): + client = TestClient(app) + alice = _register(client, "rep_alice5") + bob = _register(client, "rep_bob5") + admin = _register(client, "admin_dismiss") + + msg_id = _send_dm(client, alice, bob, "benign message") + rep_resp = client.post( + f"/api/chat/messages/{msg_id}/report", + headers=bob["headers"], + json={"reason": "false alarm"}, + ) + report_id = rep_resp.json()["id"] + + resolve_resp = client.post( + f"/api/moderation/reports/{report_id}/resolve", + headers=admin["headers"], + json={"action": "dismiss", "notes": "No violation found"}, + ) + assert resolve_resp.status_code == 200, resolve_resp.text + data = resolve_resp.json() + assert data["report_id"] == report_id + assert data["status"] == "dismissed" + assert data["action_taken"] == "dismiss" + assert data["moderator_id"] == admin["id"] + + def test_moderator_resolve_remove(self): + client = TestClient(app) + alice = _register(client, "rep_alice6") + bob = _register(client, "rep_bob6") + admin = _register(client, "admin_remove") + + msg_id = _send_dm(client, alice, bob, "offensive content") + rep_resp = client.post( + f"/api/chat/messages/{msg_id}/report", + headers=bob["headers"], + json={"reason": "hate speech"}, + ) + report_id = rep_resp.json()["id"] + + resolve_resp = client.post( + f"/api/moderation/reports/{report_id}/resolve", + headers=admin["headers"], + json={"action": "remove", "notes": "Content removed"}, + ) + assert resolve_resp.status_code == 200 + assert resolve_resp.json()["status"] == "resolved" + + # The message is removed from conversation history + hist = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"]) + assert all(m["id"] != msg_id for m in hist.json()["messages"]) + + def test_moderator_resolve_ban(self): + client = TestClient(app) + bad_user = _register(client, "bad_actor") + victim = _register(client, "rep_victim") + admin_auth = _register(client, "admin_ban") + + msg_id = _send_dm(client, bad_user, victim, "terrible violation") + rep_resp = client.post( + f"/api/chat/messages/{msg_id}/report", + headers=victim["headers"], + json={"reason": "malicious user"}, + ) + report_id = rep_resp.json()["id"] + + resolve_resp = client.post( + f"/api/moderation/reports/{report_id}/resolve", + headers=admin_auth["headers"], + json={"action": "ban", "notes": "Banned malicious user"}, + ) + assert resolve_resp.status_code == 200 + + # Attempting to use bad_user's token fails with 403 inactive account + bad_me = client.get("/api/users/me", headers=bad_user["headers"]) + assert bad_me.status_code == 403