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)
This commit is contained in:
2026-09-15 17:31:43 +00:00
parent 01a5520c20
commit 429cf6f46c
3 changed files with 401 additions and 1 deletions
+3 -1
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings 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 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(social_friends.router, prefix="/api/friends", tags=["friends"])
app.include_router(profiles.router, prefix="/api/profile", tags=["profile"]) 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_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") @app.get("/api/health")
+197
View File
@@ -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,
)
+201
View File
@@ -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