diff --git a/.test.env b/.test.env index 953a105..6c52399 100644 --- a/.test.env +++ b/.test.env @@ -26,6 +26,7 @@ SENTRY_DSN= # Channels CHANNEL_SR_MOD=1127695218900993410 +CHANNEL_MINOR_REVIEW=1437472925720280084 CHANNEL_VERIFY_LOGS=1012769518828339331 CHANNEL_BOT_COMMANDS=1276953350848588101 CHANNEL_SPOILER=2769521890099371011 @@ -35,6 +36,7 @@ CHANNEL_HOW_TO_VERIFY=1432333413980835840 # Roles ROLE_VERIFIED=1333333333333333337 +ROLE_VERIFIED_MINOR=1281517925395733615 ROLE_BIZCTF2022=7629466241011276950 ROLE_NOAH_GANG=6706800691011276950 @@ -101,3 +103,7 @@ SLACK_FEEDBACK_WEBHOOK="https://hook.slack.com/sdfsdfsf" #Feedback Webhook JIRA_WEBHOOK="https://automation.atlassian.com/sdfsdfsf" + +# Nexus API +NEXUS_API_BASE_URL="https://hackthebox.com/" +NEXUS_API_TOKEN="test_nexus_token" \ No newline at end of file diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 3e77820..0e59486 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -13,4 +13,4 @@ TheWeeknd <98106526+ToxicBiohazard@users.noreply.github.com> Dimosthenis Schizas makelarisjr <8687447+makelarisjr@users.noreply.github.com> Jelle Janssens -Ryan Gordon \ No newline at end of file +Ryan Gordon diff --git a/alembic/versions/05c1d218bb76_change_unban_time_data_type.py b/alembic/versions/05c1d218bb76_change_unban_time_data_type.py index 6a455d1..63fc7db 100644 --- a/alembic/versions/05c1d218bb76_change_unban_time_data_type.py +++ b/alembic/versions/05c1d218bb76_change_unban_time_data_type.py @@ -5,9 +5,10 @@ Create Date: 2023-05-09 13:28:06.763604 """ -from alembic import op from sqlalchemy.dialects import mysql +from alembic import op + # revision identifiers, used by Alembic. revision = '05c1d218bb76' down_revision = '6948a2436536' diff --git a/alembic/versions/82ea695d0a65_add_minor_report_and_minor_review_.py b/alembic/versions/82ea695d0a65_add_minor_report_and_minor_review_.py new file mode 100644 index 0000000..cc66f26 --- /dev/null +++ b/alembic/versions/82ea695d0a65_add_minor_report_and_minor_review_.py @@ -0,0 +1,48 @@ +"""add minor_report and minor_review_reviewer tables + +Revision ID: 82ea695d0a65 +Revises: 4fc1c39216c9 +Create Date: 2026-02-16 12:31:57.651377 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '82ea695d0a65' +down_revision = '4fc1c39216c9' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "minor_report", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", mysql.BIGINT(display_width=18), nullable=False), + sa.Column("reporter_id", mysql.BIGINT(display_width=18), nullable=False), + sa.Column("suspected_age", sa.Integer(), nullable=False), + sa.Column("evidence", mysql.TEXT(), nullable=False), + sa.Column("report_message_id", mysql.BIGINT(display_width=20), nullable=False), + sa.Column("status", mysql.VARCHAR(length=32), nullable=False, server_default="pending"), + sa.Column("reviewer_id", mysql.BIGINT(display_width=18), nullable=True), + sa.Column("created_at", mysql.TIMESTAMP(), nullable=False), + sa.Column("updated_at", mysql.TIMESTAMP(), nullable=False), + sa.Column("associated_ban_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "minor_review_reviewer", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", mysql.BIGINT(display_width=18), nullable=False), + sa.Column("added_by", mysql.BIGINT(display_width=18), nullable=True), + sa.Column("created_at", mysql.TIMESTAMP(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id"), + ) + + +def downgrade() -> None: + op.drop_table("minor_review_reviewer") + op.drop_table("minor_report") diff --git a/alembic/versions/a5f283a4cfde_change_unmute_time_data_type.py b/alembic/versions/a5f283a4cfde_change_unmute_time_data_type.py index e8739f8..8d110d6 100644 --- a/alembic/versions/a5f283a4cfde_change_unmute_time_data_type.py +++ b/alembic/versions/a5f283a4cfde_change_unmute_time_data_type.py @@ -5,9 +5,10 @@ Create Date: 2023-05-09 13:34:17.055796 """ -from alembic import op from sqlalchemy.dialects import mysql +from alembic import op + # revision identifiers, used by Alembic. revision = 'a5f283a4cfde' down_revision = '05c1d218bb76' diff --git a/alembic/versions/c3f1a2b4d5e6_add_fk_minor_report_associated_ban_id.py b/alembic/versions/c3f1a2b4d5e6_add_fk_minor_report_associated_ban_id.py new file mode 100644 index 0000000..5dbca96 --- /dev/null +++ b/alembic/versions/c3f1a2b4d5e6_add_fk_minor_report_associated_ban_id.py @@ -0,0 +1,33 @@ +"""add FK constraint on minor_report.associated_ban_id + +Revision ID: c3f1a2b4d5e6 +Revises: 82ea695d0a65 +Create Date: 2026-04-25 00:00:00.000000 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'c3f1a2b4d5e6' +down_revision = '82ea695d0a65' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_foreign_key( + "fk_minor_report_associated_ban_id", + "minor_report", + "ban", + ["associated_ban_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint( + "fk_minor_report_associated_ban_id", + "minor_report", + type_="foreignkey", + ) diff --git a/contributors.sh b/contributors.sh index d753bc2..641cd4c 100755 --- a/contributors.sh +++ b/contributors.sh @@ -19,4 +19,4 @@ TheWeeknd <98106526+ToxicBiohazard@users.noreply.github.com> EOF -git log --format='%aN <%aE>' | sort -uf >> "$file" \ No newline at end of file +git log --format='%aN <%aE>' | sort -uf >> "$file" diff --git a/pyproject.toml b/pyproject.toml index 99fe3b8..6e447b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "audioop-lts>=0.2.2,<0.3.0", "aiohttp>=3.13.4", "urllib3>=2.6.3", + "python-dateutil>=2.8.0,<3.0.0", ] [dependency-groups] diff --git a/src/cmds/automation/scheduled_tasks.py b/src/cmds/automation/scheduled_tasks.py index 9b763fc..40c4dfb 100644 --- a/src/cmds/automation/scheduled_tasks.py +++ b/src/cmds/automation/scheduled_tasks.py @@ -1,15 +1,23 @@ import asyncio import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from dateutil.relativedelta import relativedelta +from discord import Member from discord.ext import commands, tasks from sqlalchemy import select from src import settings from src.bot import Bot -from src.database.models import Ban, Mute +from src.database.models import Ban, MinorReport, Mute from src.database.session import AsyncSessionLocal from src.helpers.ban import unban_member, unmute_member +from src.helpers.minor_verification import ( + APPROVED, + CONSENT_VERIFIED, + assign_minor_role, + years_until_18, +) from src.helpers.schedule import schedule logger = logging.getLogger(__name__) @@ -28,7 +36,7 @@ async def all_tasks(self) -> None: logger.debug("Gathering scheduled tasks...") await self.auto_unban() await self.auto_unmute() - # await asyncio.gather(self.auto_unmute()) + await self.auto_remove_minor_role() logger.debug("Scheduling completed.") async def auto_unban(self) -> None: @@ -99,6 +107,80 @@ async def auto_unmute(self) -> None: await asyncio.gather(*unmute_tasks) + async def auto_remove_minor_role(self) -> None: + """Remove minor role from users who have reached 18 based on report data.""" + logger.debug("Checking for minor roles to remove based on age.") + now = datetime.now(timezone.utc) + + async with AsyncSessionLocal() as session: + result = await session.scalars( + select(MinorReport).filter(MinorReport.status.in_([APPROVED, CONSENT_VERIFIED])) + ) + reports = result.all() + + for guild_id in settings.guild_ids: + guild = self.bot.get_guild(guild_id) + if not guild: + logger.warning(f"Unable to find guild with ID {guild_id} for minor role cleanup.") + continue + + for report in reports: + # Compute exact 18th birthday based on suspected age at report time. + created_at = report.created_at + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + years = years_until_18(report.suspected_age) + expires_at = created_at + relativedelta(years=years) + if now < expires_at: + continue + + member = await self.bot.get_member_or_user(guild, report.user_id) + if not isinstance(member, Member): + continue + + role_id = settings.roles.VERIFIED_MINOR + role = guild.get_role(role_id) + if not role or role not in member.roles: + continue + + logger.info( + "Removing minor role from user %s (%s) because they have reached 18.", + member, + member.id, + ) + try: + await member.remove_roles(role, atomic=True) + except Exception as exc: + logger.warning( + "Failed to remove minor role from %s (%s): %s", member, member.id, exc + ) + + @commands.Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Assign minor role on rejoin if consent is verified and they are still under 18.""" + async with AsyncSessionLocal() as session: + result = await session.scalars( + select(MinorReport).filter( + MinorReport.user_id == member.id, + MinorReport.status == CONSENT_VERIFIED, + ) + ) + report = result.first() + + if not report: + return + + now = datetime.now(timezone.utc) + created_at = report.created_at + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + years = years_until_18(report.suspected_age) + expires_at = created_at + relativedelta(years=years) + if now >= expires_at: + return + + await assign_minor_role(member, member.guild) + def setup(bot: Bot) -> None: """Load the `ScheduledTasks` cog.""" diff --git a/src/cmds/core/flag_minor.py b/src/cmds/core/flag_minor.py new file mode 100644 index 0000000..adc05e5 --- /dev/null +++ b/src/cmds/core/flag_minor.py @@ -0,0 +1,227 @@ +"""Flag verified users as potentially underage for review.""" + +import logging +from datetime import datetime, timezone + +import discord +from discord import ApplicationContext, WebhookMessage +from discord.ext.commands import has_any_role + +from src.bot import Bot +from src.core import settings +from src.database.models import MinorReport +from src.database.session import AsyncSessionLocal +from src.helpers.minor_verification import ( + PENDING, + assign_minor_role, + check_parental_consent, + get_active_minor_report, + get_htb_user_id_for_discord, + years_until_18, +) +from src.views.minorreportview import HTB_PROFILE_URL, MinorReportView, build_minor_report_embed + +logger = logging.getLogger(__name__) + + +class FlagMinorCog(discord.Cog): + """Commands for flagging potentially underage users.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @discord.slash_command( + guild_ids=settings.guild_ids, + description="Flag a verified user as potentially underage for review.", + ) + @has_any_role( + *settings.role_groups.get("ALL_ADMINS"), + *settings.role_groups.get("ALL_MODS"), + ) + async def flag_minor( + self, + ctx: ApplicationContext, + user: discord.Member, + suspected_age: int, + evidence: str, + ) -> ApplicationContext | WebhookMessage: + """Flag a verified user as potentially underage. Only MOD+ can use this.""" + if not ctx.guild: + return await ctx.respond("This command can only be used in a server.", ephemeral=True) + + if suspected_age < 1 or suspected_age > 17: + return await ctx.respond( + "Suspected age must be between 1 and 17.", + ephemeral=True, + ) + + verified_role_id = settings.roles.VERIFIED + minor_role_id = getattr(settings.roles, "VERIFIED_MINOR", None) + if not minor_role_id: + return await ctx.respond( + "Minor review is not configured (VERIFIED_MINOR role missing).", + ephemeral=True, + ) + + verified_role = ctx.guild.get_role(verified_role_id) + minor_role = ctx.guild.get_role(minor_role_id) + if not verified_role or not minor_role: + return await ctx.respond( + "Required roles are not configured on this server.", + ephemeral=True, + ) + + target = await self.bot.get_member_or_user(ctx.guild, user.id) + if not target: + return await ctx.respond("User not found.", ephemeral=True) + + if not isinstance(target, discord.Member): + return await ctx.respond( + "User must be a member of this server and have the Verified role.", + ephemeral=True, + ) + + if verified_role not in target.roles: + return await ctx.respond( + "That user is not verified. Only verified users can be flagged.", + ephemeral=True, + ) + + if minor_role in target.roles: + return await ctx.respond( + "That user already has the verified-minor status. No need to flag.", + ephemeral=True, + ) + + status_message = await ctx.respond( + "Creating or updating minor report, please wait...", + ephemeral=True, + ) + + has_consent = await check_parental_consent(target.id) + if has_consent: + added = await assign_minor_role(target, ctx.guild) + await status_message.edit( + content=( + "Parental consent already on file. No report created." + + (" Role assigned." if added else " Role was already assigned.") + ), + ) + return + + review_channel_id = getattr(settings.channels, "MINOR_REVIEW", None) or 0 + if not review_channel_id: + await status_message.edit( + content="Minor review channel is not configured. Report could not be created.", + ) + return + + review_channel = ctx.guild.get_channel(review_channel_id) + if not review_channel: + await status_message.edit( + content="Minor review channel not found. Report could not be created.", + ) + return + + now = datetime.now(timezone.utc) + existing = await get_active_minor_report(target.id) + + if existing: + async with AsyncSessionLocal() as session: + r = await session.get(MinorReport, existing.id) + if r: + r.suspected_age = suspected_age + r.evidence = evidence + r.reporter_id = ctx.user.id + r.updated_at = now + await session.commit() + report = r + else: + report = existing + htb_id = await get_htb_user_id_for_discord(target.id) + htb_url = f"{HTB_PROFILE_URL}{htb_id}" if htb_id else None + embed = build_minor_report_embed( + report, + ctx.guild, + reported_user=target, + status_notes=f"Report updated by <@{ctx.user.id}>.", + htb_profile_url=htb_url, + ) + try: + msg = await review_channel.fetch_message(report.report_message_id) + await msg.edit(embed=embed) + except (discord.NotFound, discord.HTTPException) as e: + logger.warning("Could not edit existing report message: %s", e) + await status_message.edit( + content="Report updated with new information. Review channel message edited.", + ) + return + + view = MinorReportView(self.bot) + embed = discord.Embed( + title=f"Minor Report - {PENDING}", + color=0xFFA500, + ) + embed.add_field( + name="User", + value=f"<@{target.id}> ({target.id})", + inline=False, + ) + embed.add_field(name="Suspected Age", value=str(suspected_age), inline=True) + embed.add_field( + name="Suggested Ban Duration", + value=f"{years_until_18(suspected_age)} years (until 18)", + inline=True, + ) + embed.add_field(name="Evidence", value=evidence or "—", inline=False) + embed.add_field(name="Flagged By", value=f"<@{ctx.user.id}>", inline=True) + embed.add_field(name="Flagged At", value=now.strftime("%Y-%m-%d %H:%M UTC"), inline=True) + if target.display_avatar.url: + embed.set_thumbnail(url=target.display_avatar.url) + embed.set_footer(text="Report pending | Last updated: " + now.strftime("%Y-%m-%d %H:%M UTC")) + + sent = await review_channel.send(embed=embed, view=view) + report = MinorReport( + user_id=target.id, + reporter_id=ctx.user.id, + suspected_age=suspected_age, + evidence=evidence, + report_message_id=sent.id, + status=PENDING, + created_at=now, + updated_at=now, + ) + async with AsyncSessionLocal() as session: + session.add(report) + await session.commit() + await session.refresh(report) + + htb_id = await get_htb_user_id_for_discord(target.id) + htb_url = f"{HTB_PROFILE_URL}{htb_id}" if htb_id else None + embed_with_id = build_minor_report_embed( + report, + ctx.guild, + reported_user=target, + htb_profile_url=htb_url, + ) + embed_with_id.set_footer( + text=f"Report ID: {report.id} | Last updated: {report.updated_at.strftime('%Y-%m-%d %H:%M UTC')}" + ) + await sent.edit(embed=embed_with_id) + + await status_message.edit( + content=( + "Report created and posted to the review channel." + ), + ) + return + + @discord.Cog.listener() + async def on_ready(self) -> None: + """Register persistent view when bot is ready.""" + self.bot.add_view(MinorReportView(self.bot)) + + +def setup(bot: Bot) -> None: + """Load the FlagMinorCog.""" + bot.add_cog(FlagMinorCog(bot)) diff --git a/src/cmds/core/minor_reviewers.py b/src/cmds/core/minor_reviewers.py new file mode 100644 index 0000000..b91fc16 --- /dev/null +++ b/src/cmds/core/minor_reviewers.py @@ -0,0 +1,144 @@ +"""Administrator commands to manage who can review minor reports.""" + +import logging +from datetime import datetime, timezone + +import discord +from discord import ApplicationContext, WebhookMessage +from discord.ext.commands import has_any_role +from sqlalchemy import select + +from src.bot import Bot +from src.core import settings +from src.database.models import MinorReviewReviewer +from src.database.session import AsyncSessionLocal +from src.helpers.minor_verification import get_minor_review_reviewer_ids, invalidate_reviewer_ids_cache + +logger = logging.getLogger(__name__) + +# Initial reviewer IDs (one-time seed when table is empty) +DEFAULT_REVIEWER_IDS = (561210274653274133, 96269737343844352, 484040243818004491) + + +class MinorReviewersCog(discord.Cog): + """Admin commands to add/remove/list minor report reviewers.""" + + def __init__(self, bot: Bot): + self.bot = bot + + minor_reviewers = discord.SlashCommandGroup( + "minor_reviewers", + "Manage who can review minor reports (Administrators only).", + guild_ids=settings.guild_ids, + ) + + @minor_reviewers.command(description="Add a user as a minor report reviewer.") + @has_any_role(*settings.role_groups.get("ALL_ADMINS")) + async def add( + self, + ctx: ApplicationContext, + user: discord.Member, + ) -> ApplicationContext | WebhookMessage: + """Add a user to the list of reviewers.""" + uid = user.id + async with AsyncSessionLocal() as session: + stmt = select(MinorReviewReviewer).filter(MinorReviewReviewer.user_id == uid).limit(1) + result = await session.scalars(stmt) + existing = result.first() + if existing: + return await ctx.respond( + f"{user.mention} is already a minor report reviewer.", + ephemeral=True, + ) + now = datetime.now(timezone.utc) + session.add( + MinorReviewReviewer( + user_id=uid, + added_by=ctx.user.id, + created_at=now, + ) + ) + await session.commit() + invalidate_reviewer_ids_cache() + return await ctx.respond( + f"Added {user.mention} as a minor report reviewer.", + ephemeral=True, + ) + + @minor_reviewers.command(description="Remove a user from minor report reviewers.") + @has_any_role(*settings.role_groups.get("ALL_ADMINS")) + async def remove( + self, + ctx: ApplicationContext, + user: discord.Member, + ) -> ApplicationContext | WebhookMessage: + """Remove a user from the list of reviewers.""" + uid = user.id + async with AsyncSessionLocal() as session: + stmt = select(MinorReviewReviewer).filter(MinorReviewReviewer.user_id == uid).limit(1) + result = await session.scalars(stmt) + row = result.first() + if not row: + return await ctx.respond( + f"{user.mention} is not in the minor report reviewer list.", + ephemeral=True, + ) + await session.delete(row) + await session.commit() + invalidate_reviewer_ids_cache() + return await ctx.respond( + f"Removed {user.mention} from minor report reviewers.", + ephemeral=True, + ) + + @minor_reviewers.command(name="list", description="List users who can review minor reports.") + @has_any_role(*settings.role_groups.get("ALL_ADMINS")) + async def list_reviewers(self, ctx: ApplicationContext) -> ApplicationContext | WebhookMessage: + """List current minor report reviewers.""" + ids = await get_minor_review_reviewer_ids() + if not ids: + return await ctx.respond( + "There are no minor report reviewers configured. Add some with `/minor_reviewers add`.", + ephemeral=True, + ) + lines = [f"<@{uid}> ({uid})" for uid in ids] + return await ctx.respond( + "**Minor report reviewers:**\n" + "\n".join(lines), + ephemeral=True, + ) + + @minor_reviewers.command( + name="seed", + description="Seed initial reviewers (only if list is empty). One-time setup.", + ) + @has_any_role(*settings.role_groups.get("ALL_ADMINS")) + async def seed(self, ctx: ApplicationContext) -> ApplicationContext | WebhookMessage: + """Add default reviewer IDs if the table is empty.""" + async with AsyncSessionLocal() as session: + stmt = select(MinorReviewReviewer).limit(1) + result = await session.scalars(stmt) + if result.first(): + return await ctx.respond( + "Reviewers already configured. Use add/remove to change.", + ephemeral=True, + ) + now = datetime.now(timezone.utc) + for uid in DEFAULT_REVIEWER_IDS: + session.add( + MinorReviewReviewer( + user_id=uid, + added_by=ctx.user.id, + created_at=now, + ) + ) + await session.commit() + invalidate_reviewer_ids_cache() + return await ctx.respond( + f"Seeded {len(DEFAULT_REVIEWER_IDS)} initial reviewer(s). Use `/minor_reviewers list` to see them.", + ephemeral=True, + ) + + +def setup(bot: Bot) -> None: + """Load the MinorReviewersCog.""" + bot.add_cog(MinorReviewersCog(bot)) diff --git a/src/cmds/core/mute.py b/src/cmds/core/mute.py index cb1087c..6cff792 100644 --- a/src/cmds/core/mute.py +++ b/src/cmds/core/mute.py @@ -1,6 +1,6 @@ from datetime import datetime -from discord import ApplicationContext, Interaction, WebhookMessage, slash_command, Member +from discord import ApplicationContext, Interaction, Member, WebhookMessage, slash_command from discord.errors import Forbidden from discord.ext import commands from discord.ext.commands import has_any_role diff --git a/src/core/config.py b/src/core/config.py index 8c69337..d2864e5 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -195,6 +195,9 @@ class Global(BaseSettings): SLACK_FEEDBACK_WEBHOOK: str = "" JIRA_WEBHOOK: str = "" + NEXUS_API_BASE_URL: str | None = None + NEXUS_API_TOKEN: str | None = None + ROOT: Path = None VERSION: str = "unknown" diff --git a/src/database/models/__init__.py b/src/database/models/__init__.py index 15ab602..84d322a 100644 --- a/src/database/models/__init__.py +++ b/src/database/models/__init__.py @@ -7,5 +7,7 @@ from .htb_discord_link import HtbDiscordLink from .infraction import Infraction from .macro import Macro +from .minor_report import MinorReport +from .minor_review_reviewer import MinorReviewReviewer from .mute import Mute from .user_note import UserNote diff --git a/src/database/models/minor_report.py b/src/database/models/minor_report.py new file mode 100644 index 0000000..6354b0b --- /dev/null +++ b/src/database/models/minor_report.py @@ -0,0 +1,43 @@ +# flake8: noqa: D101 +from datetime import datetime + +from sqlalchemy import ForeignKey, Integer +from sqlalchemy.dialects.mysql import BIGINT, TEXT, TIMESTAMP, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from . import Base + + +class MinorReport(Base): + """ + Represents a minor flag report for review by select moderators. + + Attributes: + id: Primary key. + user_id: Discord user ID of the reported user. + reporter_id: Discord user ID of the moderator who flagged. + suspected_age: Suspected age (1-17). + evidence: Evidence for the flag. + report_message_id: Discord message ID in the review channel. + status: pending, approved, denied, consent_verified. + reviewer_id: Discord user ID of moderator who approved/denied (nullable). + created_at: When the report was created. + updated_at: When the report was last updated. + associated_ban_id: Ban record ID if user was banned via this report (nullable). + """ + + __tablename__ = "minor_report" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(BIGINT(18), nullable=False) + reporter_id: Mapped[int] = mapped_column(BIGINT(18), nullable=False) + suspected_age: Mapped[int] = mapped_column(Integer, nullable=False) + evidence: Mapped[str] = mapped_column(TEXT, nullable=False) + report_message_id: Mapped[int] = mapped_column(BIGINT(20), nullable=False) + status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="pending") + reviewer_id: Mapped[int | None] = mapped_column(BIGINT(18), nullable=True) + created_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False) + updated_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False) + associated_ban_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("ban.id", ondelete="SET NULL"), nullable=True + ) diff --git a/src/database/models/minor_review_reviewer.py b/src/database/models/minor_review_reviewer.py new file mode 100644 index 0000000..52fc202 --- /dev/null +++ b/src/database/models/minor_review_reviewer.py @@ -0,0 +1,22 @@ +# flake8: noqa: D101 +from datetime import datetime + +from sqlalchemy import Integer +from sqlalchemy.dialects.mysql import BIGINT, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from . import Base + + +class MinorReviewReviewer(Base): + """ + Stores Discord user IDs of users allowed to review minor reports. + Configurable at runtime by Administrators. + """ + + __tablename__ = "minor_review_reviewer" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(BIGINT(18), nullable=False, unique=True) + added_by: Mapped[int | None] = mapped_column(BIGINT(18), nullable=True) + created_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False) diff --git a/src/helpers/ban.py b/src/helpers/ban.py index 763a0dc..c36e7aa 100644 --- a/src/helpers/ban.py +++ b/src/helpers/ban.py @@ -6,16 +6,7 @@ from enum import Enum import discord -from discord import ( - Forbidden, - Guild, - HTTPException, - Member, - NotFound, - User, - TextChannel, - ClientUser, -) +from discord import ClientUser, Forbidden, Guild, HTTPException, Member, NotFound, TextChannel, User from sqlalchemy import select from sqlalchemy.exc import NoResultFound @@ -99,7 +90,7 @@ async def _get_ban_or_create( async def _create_ban_response( - member: Member | User, end_date: str, dm_banned_member: bool, needs_approval: bool + member: Member | User, end_date: str, dm_banned_member: bool, needs_approval: bool, ban_id: int | None = None ) -> SimpleResponse: """Create a SimpleResponse for ban operations.""" if needs_approval: @@ -120,6 +111,7 @@ async def _create_ban_response( message=message, delete_after=0 if not needs_approval else None, code=BanCodes.SUCCESS, + ban_id=ban_id, ) @@ -337,6 +329,7 @@ async def ban_member_with_epoch( message=f"A ban with id: {ban_id} already exists for member {member}", delete_after=None, code=BanCodes.ALREADY_EXISTS, + ban_id=ban_id, ) # DM member, before we ban, else we cannot dm since we do not share a guild @@ -402,7 +395,7 @@ async def ban_member_with_epoch( await guild.get_channel(settings.channels.SR_MOD).send(embed=embed, view=view) # type: ignore return await _create_ban_response( - member, end_date, dm_banned_member, needs_approval + member, end_date, dm_banned_member, needs_approval, ban_id=ban_id ) diff --git a/src/helpers/minor_verification.py b/src/helpers/minor_verification.py new file mode 100644 index 0000000..a324354 --- /dev/null +++ b/src/helpers/minor_verification.py @@ -0,0 +1,170 @@ +"""Helpers for minor flagging and parental consent verification.""" + +import asyncio +import logging +import time +from datetime import datetime, timedelta, timezone + +import aiohttp +from discord import Forbidden, Guild, HTTPException, Member +from sqlalchemy import select + +from src.core import settings +from src.database.models import HtbDiscordLink, MinorReport, MinorReviewReviewer +from src.database.session import AsyncSessionLocal + +logger = logging.getLogger(__name__) + +# Cache for reviewer IDs (TTL 60s) to avoid DB hit on every button interaction. +_reviewer_ids_cache: tuple[int, ...] | None = None +_reviewer_ids_cache_ts: float = 0 +REVIEWER_CACHE_TTL_SEC = 60 + +PENDING = "pending" +APPROVED = "approved" +DENIED = "denied" +CONSENT_VERIFIED = "consent_verified" + + +async def check_parental_consent(discord_user_id: int) -> bool: + """ + Check if parental consent exists for a Discord user via the Nexus API. + + POST to NEXUS_API_BASE_URL/discord/user_lookup/parental_consent_exists with + {"discord_id": ""} and a Bearer token. Returns True iff the + response body contains {"exists": true}. Any error is treated as no consent. + """ + base_url = settings.NEXUS_API_BASE_URL or "" + if not base_url: + logger.warning("NEXUS_API_BASE_URL not set; consent check skipped.") + return False + + token = settings.NEXUS_API_TOKEN or "" + if not token: + logger.warning("NEXUS_API_TOKEN not set; consent check skipped.") + return False + + endpoint = f"{base_url.rstrip('/')}/discord/user_lookup/parental_consent_exists" + payload = {"discord_id": str(discord_user_id)} + + try: + async with aiohttp.ClientSession() as http: + async with http.post( + endpoint, + json=payload, + headers={"Authorization": f"Bearer {token}"}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + body = await resp.text() + logger.info( + "Nexus consent check for discord_id=%s: status=%s body=%s", + discord_user_id, + resp.status, + body, + ) + if resp.status != 200: + return False + try: + import json + data = json.loads(body) + except (ValueError, TypeError): + logger.warning( + "Nexus consent check returned non-JSON body for discord_id=%s", + discord_user_id, + ) + return False + return bool(data.get("exists")) + except aiohttp.ClientError as e: + logger.warning("Nexus consent check request failed: %s", e) + return False + except asyncio.TimeoutError as e: + logger.warning("Nexus consent check timed out: %s", e) + return False + + +async def assign_minor_role(member: Member, guild: Guild) -> bool: + """Assign the discrete minor role to the member. Returns True if added.""" + role_id = settings.roles.VERIFIED_MINOR + role = guild.get_role(role_id) + if not role: + return False + if role in member.roles: + return False + try: + await member.add_roles(role, atomic=True) + return True + except (Forbidden, HTTPException) as e: + logger.warning("Failed to assign minor role to %s: %s", member.id, e) + return False + + +async def get_htb_user_id_for_discord(discord_user_id: int) -> int | None: + """Get HTB user ID for a Discord user from HtbDiscordLink.""" + async with AsyncSessionLocal() as session: + stmt = select(HtbDiscordLink).filter( + HtbDiscordLink.discord_user_id == discord_user_id + ).limit(1) + result = await session.scalars(stmt) + link = result.first() + if link: + return int(link.htb_user_id) + return None + + +async def get_active_minor_report(user_id: int) -> MinorReport | None: + """Get an active (pending) minor report for the user, if any.""" + async with AsyncSessionLocal() as session: + stmt = ( + select(MinorReport) + .filter(MinorReport.user_id == user_id, MinorReport.status == PENDING) + .limit(1) + ) + result = await session.scalars(stmt) + return result.first() + + +def calculate_ban_duration(suspected_age: int) -> int: + """ + Return Unix epoch timestamp when ban should end (user turns 18). + + suspected_age must be 1-17. Ban duration is (18 - suspected_age) years from now. + """ + if suspected_age < 1 or suspected_age > 17: + raise ValueError("suspected_age must be between 1 and 17") + now = datetime.now(timezone.utc) + years_until_18 = 18 - suspected_age + end = now + timedelta(days=365 * years_until_18) + return int(end.timestamp()) + + +def years_until_18(suspected_age: int) -> int: + """Return number of years until user turns 18.""" + if suspected_age < 1 or suspected_age > 17: + raise ValueError("suspected_age must be between 1 and 17") + return 18 - suspected_age + + +async def get_minor_review_reviewer_ids() -> tuple[int, ...]: + """Return Discord user IDs of users allowed to review minor reports (from DB).""" + global _reviewer_ids_cache, _reviewer_ids_cache_ts + now = time.monotonic() + if _reviewer_ids_cache is not None and (now - _reviewer_ids_cache_ts) < REVIEWER_CACHE_TTL_SEC: + return _reviewer_ids_cache + async with AsyncSessionLocal() as session: + stmt = select(MinorReviewReviewer.user_id) + result = await session.scalars(stmt) + _reviewer_ids_cache = tuple(int(uid) for uid in result.all()) + _reviewer_ids_cache_ts = now + return _reviewer_ids_cache + + +async def is_minor_review_moderator(user_id: int) -> bool: + """Return True if the user is allowed to review minor reports (from DB).""" + reviewer_ids = await get_minor_review_reviewer_ids() + return user_id in reviewer_ids + + +def invalidate_reviewer_ids_cache() -> None: + """Clear the reviewer IDs cache so the next check reads from the DB.""" + global _reviewer_ids_cache + _reviewer_ids_cache = None \ No newline at end of file diff --git a/src/helpers/responses.py b/src/helpers/responses.py index e80da1a..e4aaada 100644 --- a/src/helpers/responses.py +++ b/src/helpers/responses.py @@ -1,16 +1,24 @@ import json from typing import Any + class SimpleResponse(object): """A simple response object.""" - def __init__(self, message: str, delete_after: int | None = None, code: str | Any = None): + def __init__( + self, + message: str, + delete_after: int | None = None, + code: str | Any = None, + ban_id: int | None = None, + ): self.message = message self.delete_after = delete_after self.code = code + self.ban_id = ban_id def __str__(self): return json.dumps(dict(self), ensure_ascii=False) # type: ignore - + def __repr__(self): return self.__str__() diff --git a/src/views/minorreportview.py b/src/views/minorreportview.py new file mode 100644 index 0000000..4686510 --- /dev/null +++ b/src/views/minorreportview.py @@ -0,0 +1,428 @@ +"""View and embed builder for minor reports in the review channel.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +import discord +from discord import Guild, HTTPException, Interaction, Member, NotFound, User +from discord.ui import Button, InputText, Modal, View +from sqlalchemy import select + +from src.bot import Bot +from src.core import settings # noqa: F401 +from src.database.models import MinorReport, UserNote +from src.database.session import AsyncSessionLocal +from src.helpers.ban import ban_member_with_epoch, get_ban, unban_member +from src.helpers.minor_verification import ( + APPROVED, + CONSENT_VERIFIED, + DENIED, + PENDING, + assign_minor_role, + check_parental_consent, + get_htb_user_id_for_discord, + is_minor_review_moderator, + years_until_18, +) + +logger = logging.getLogger(__name__) + +HTB_PROFILE_URL = "https://app.hackthebox.com/users/" + +# Button custom_ids - we look up report by message_id +CUSTOM_ID_APPROVE = "minor_report_approve" +CUSTOM_ID_DENY = "minor_report_deny" +CUSTOM_ID_RECHECK = "minor_report_recheck" + + +def _status_color(status: str) -> int: + if status == PENDING: + return 0xFFA500 # Orange + if status == APPROVED: + return 0xFF2429 # Red + if status == DENIED: + return 0x00FF00 # Green + if status == CONSENT_VERIFIED: + return 0x0099FF # Blue + return 0x808080 + + +def build_minor_report_embed( + report: MinorReport, + guild: Guild, + *, + reported_user: Member | User | None = None, + status_notes: str = "", + htb_profile_url: str | None = None, +) -> discord.Embed: + """Build the embed for a minor report message.""" + status = report.status + title = f"Minor Report #{report.id} - {status.upper().replace('_', ' ')}" + embed = discord.Embed(title=title, color=_status_color(status)) + + user_mention = f"<@{report.user_id}>" + embed.add_field(name="User", value=f"{user_mention} ({report.user_id})", inline=False) + if htb_profile_url: + embed.add_field( + name="HTB Profile", + value=f"[View profile]({htb_profile_url})", + inline=False, + ) + + embed.add_field(name="Suspected Age", value=str(report.suspected_age), inline=True) + years = years_until_18(report.suspected_age) + embed.add_field( + name="Suggested Ban Duration", + value=f"{years} years (until 18)", + inline=True, + ) + embed.add_field(name="Evidence", value=report.evidence or "—", inline=False) + embed.add_field(name="Flagged By", value=f"<@{report.reporter_id}>", inline=True) + created = report.created_at + if isinstance(created, datetime): + created_str = created.strftime("%Y-%m-%d %H:%M UTC") + else: + created_str = str(created) + embed.add_field(name="Flagged At", value=created_str, inline=True) + if status_notes: + embed.add_field(name="Status Updates", value=status_notes, inline=False) + if reported_user and reported_user.display_avatar.url: + embed.set_thumbnail(url=reported_user.display_avatar.url) + updated = report.updated_at + updated_str = updated.strftime("%Y-%m-%d %H:%M UTC") if isinstance(updated, datetime) else str(updated) + embed.set_footer(text=f"Report ID: {report.id} | Last updated: {updated_str}") + + return embed + + +async def get_report_by_message_id(message_id: int) -> MinorReport | None: + """Load MinorReport by report_message_id.""" + async with AsyncSessionLocal() as session: + stmt = select(MinorReport).filter(MinorReport.report_message_id == message_id).limit(1) + result = await session.scalars(stmt) + return result.first() + + +async def update_report_status( + report_id: int, + status: str, + reviewer_id: int, + *, + associated_ban_id: int | None = None, +) -> None: + """Update report status and updated_at.""" + now = datetime.now(timezone.utc) + async with AsyncSessionLocal() as session: + report = await session.get(MinorReport, report_id) + if report: + report.status = status + report.reviewer_id = reviewer_id + report.updated_at = now + if associated_ban_id is not None: + report.associated_ban_id = associated_ban_id + await session.commit() + + +# Modal for approving ban (confirm/adjust duration) +class ApproveBanModal(Modal): + """Modal to confirm or adjust ban duration when approving a minor report.""" + + def __init__(self, bot: Bot, report: MinorReport, parent_view: MinorReportView): + super().__init__(title="Approve Ban") + self.bot = bot + self.report = report + self.parent_view = parent_view + years = years_until_18(report.suspected_age) + default_duration = f"{years}y" + self.add_item( + InputText( + label="Ban duration", + placeholder="e.g. 5y or 3y", + required=True, + value=default_duration, + ) + ) + + async def callback(self, interaction: Interaction) -> None: + """Validate duration, create ban, update report and embed.""" + from src.helpers.duration import validate_duration + duration_str = self.children[0].value.strip() + dur, dur_exc = validate_duration(duration_str) + if dur_exc or dur <= 0: + await interaction.response.send_message( + dur_exc or "Invalid duration.", + ephemeral=True, + ) + return + guild = interaction.guild + if not guild: + await interaction.response.send_message("Guild not found.", ephemeral=True) + return + member = await self.bot.get_member_or_user(guild, self.report.user_id) + if not member: + await interaction.response.send_message("User not found in guild.", ephemeral=True) + return + reason = ( + "Parental consent is missing. Please submit parental consent after reviewing the article: " + "https://help.hackthebox.com/en/articles/9456556-parental-consent-and-approval-for-users-under-18" + ) + end_date = datetime.fromtimestamp(dur, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + response = await ban_member_with_epoch( + self.bot, + guild, + member, + dur, + reason, + f"Minor report approval by {interaction.user.mention} ({interaction.user.id})", + author=interaction.user, + needs_approval=True, + ) + ban_id = response.ban_id if response else None + await update_report_status( + self.report.id, + APPROVED, + interaction.user.id, + associated_ban_id=ban_id, + ) + await interaction.response.send_message( + response.message or f"Ban submitted until {end_date} (UTC). Awaiting SR_MOD approval.", + ephemeral=True, + ) + status_notes = f"Approved by <@{interaction.user.id}> at " + report_for_embed = await get_report_by_message_id(interaction.message.id) or self.report + htb_id = await get_htb_user_id_for_discord(report_for_embed.user_id) + htb_url = f"{HTB_PROFILE_URL}{htb_id}" if htb_id else None + embed = build_minor_report_embed( + report_for_embed, + guild, + reported_user=member, + status_notes=status_notes, + htb_profile_url=htb_url, + ) + # After approval, disable further approval/denial on this message and + # change the recheck button label for this report only so reviewers + # clearly see that it will check consent and unban. + for child in self.parent_view.children: + if isinstance(child, _ApproveButton) or isinstance(child, _DenyButton): + child.disabled = True + if isinstance(child, _RecheckButton): + child.label = "Check Consent & Unban" + # Keep the view so reviewers can later recheck consent and unban if needed. + await self.parent_view._edit_report_message(interaction, embed, view=self.parent_view) + + +class DenyReportModal(Modal): + """Modal to enter denial reason when denying a minor report.""" + + def __init__(self, bot: Bot, report: MinorReport, parent_view: MinorReportView): + super().__init__(title="Deny Report") + self.bot = bot + self.report = report + self.parent_view = parent_view + self.add_item( + InputText( + label="Reason for denial", + placeholder="Brief reason", + required=True, + ) + ) + + async def callback(self, interaction: Interaction) -> None: + """Save denial reason, add user note, update report embed.""" + reason = self.children[0].value.strip() or "No reason given" + guild = interaction.guild + if not guild: + await interaction.response.send_message("Guild not found.", ephemeral=True) + return + await update_report_status(self.report.id, DENIED, interaction.user.id) + note_text = f"Minor flag denied: {reason}" + today = datetime.now(timezone.utc).date() + user_note = UserNote( + user_id=self.report.user_id, + note=note_text, + moderator_id=interaction.user.id, + date=today, + ) + async with AsyncSessionLocal() as session: + session.add(user_note) + await session.commit() + await interaction.response.send_message( + "Report denied and note added to user history.", + ephemeral=True, + ) + ts = int(datetime.now(timezone.utc).timestamp()) + status_notes = f"Denied by <@{interaction.user.id}> at . Reason: {reason}" + report_updated = await get_report_by_message_id(interaction.message.id) + report_for_embed = report_updated or self.report + htb_id = await get_htb_user_id_for_discord(report_for_embed.user_id) + htb_url = f"{HTB_PROFILE_URL}{htb_id}" if htb_id else None + embed = build_minor_report_embed( + report_for_embed, + guild, + status_notes=status_notes, + htb_profile_url=htb_url, + ) + try: + await interaction.message.edit(embed=embed, view=None) + except (HTTPException, NotFound): + pass + + +class _ApproveButton(Button): + def __init__(self): + super().__init__(label="Approve Ban", style=discord.ButtonStyle.success, custom_id=CUSTOM_ID_APPROVE) + + async def callback(self, interaction: Interaction) -> None: + view: MinorReportView = self.view + report = await view._get_report(interaction) + if report and report.status == PENDING: + await view._approve_callback(interaction, report) + elif report: + await interaction.response.send_message("This report is no longer pending.", ephemeral=True) + + +class _DenyButton(Button): + def __init__(self): + super().__init__(label="Deny Report", style=discord.ButtonStyle.danger, custom_id=CUSTOM_ID_DENY) + + async def callback(self, interaction: Interaction) -> None: + view: MinorReportView = self.view + report = await view._get_report(interaction) + if report and report.status == PENDING: + await view._deny_callback(interaction, report) + elif report: + await interaction.response.send_message("This report is no longer pending.", ephemeral=True) + + +class _RecheckButton(Button): + def __init__(self): + # Default label for newly created (pending) reports. + super().__init__(label="Recheck Consent", style=discord.ButtonStyle.primary, custom_id=CUSTOM_ID_RECHECK) + + async def callback(self, interaction: Interaction) -> None: + view: MinorReportView = self.view + report = await view._get_report(interaction) + if report: + await view._recheck_callback(interaction, report) + + +class MinorReportView(View): + """Persistent view for minor report actions. Look up report by message_id.""" + + def __init__(self, bot: Bot): + super().__init__(timeout=None) + self.bot = bot + self.add_item(_ApproveButton()) + self.add_item(_DenyButton()) + self.add_item(_RecheckButton()) + + async def _get_report(self, interaction: Interaction) -> MinorReport | None: + return await get_report_by_message_id(interaction.message.id) + + async def _check_reviewer(self, interaction: Interaction) -> bool: + if not await is_minor_review_moderator(interaction.user.id): + await interaction.response.send_message( + "You are not authorized to review minor reports.", + ephemeral=True, + ) + return False + return True + + async def interaction_check(self, interaction: Interaction) -> bool: + """Ensure report exists and user is an allowed reviewer.""" + report = await self._get_report(interaction) + if not report: + await interaction.response.send_message( + "Report not found or already resolved.", + ephemeral=True, + ) + return False + return await self._check_reviewer(interaction) + + @staticmethod + async def _edit_report_message(interaction: Interaction, embed: discord.Embed, view: View | None = None) -> None: + """Edit the report message with updated embed and optional view.""" + try: + await interaction.message.edit(embed=embed, view=view) + except (HTTPException, NotFound) as e: + logger.warning("Failed to edit minor report message: %s", e) + + async def _approve_callback(self, interaction: Interaction, report: MinorReport) -> None: + modal = ApproveBanModal(self.bot, report, self) + await interaction.response.send_modal(modal) + + async def _deny_callback(self, interaction: Interaction, report: MinorReport) -> None: + modal = DenyReportModal(self.bot, report, self) + await interaction.response.send_modal(modal) + + async def _recheck_callback(self, interaction: Interaction, report: MinorReport) -> None: + await interaction.response.defer(ephemeral=True) + has_consent = await check_parental_consent(report.user_id) + guild = interaction.guild + if not guild: + await interaction.followup.send("Guild not found.", ephemeral=True) + return + member = await self.bot.get_member_or_user(guild, report.user_id) + if has_consent: + # Try to assign the minor role only if we have a real Member object. + if isinstance(member, Member): + await assign_minor_role(member, guild) + existing_ban = await get_ban(member) if member else await get_ban(discord.Object(id=report.user_id)) + if existing_ban and report.associated_ban_id and existing_ban.id == report.associated_ban_id: + # Unban by member if present, otherwise by user id. + if member: + await unban_member(guild, member) + else: + await unban_member(guild, discord.Object(id=report.user_id)) + await interaction.followup.send( + "Consent found. User unbanned." + + (" Minor role assigned." if isinstance(member, Member) else " Minor role will be assigned when they rejoin."), + ephemeral=True, + ) + else: + await interaction.followup.send( + "Consent found." + + (" Minor role assigned." if isinstance(member, Member) else " Minor role will be assigned when they rejoin.") + + (" User was not banned by this report." if not existing_ban else ""), + ephemeral=True, + ) + status_notes = ( + f"Consent verified by <@{interaction.user.id}> at " + f"" + ) + else: + await interaction.followup.send( + "Consent still not found. No changes made.", + ephemeral=True, + ) + status_notes = ( + f"Recheck (no consent) by <@{interaction.user.id}> at " + f"" + ) + + # Single write path: update status (only changes on consent found), reviewer, and timestamp. + now = datetime.now(timezone.utc) + new_status = CONSENT_VERIFIED if has_consent else report.status + async with AsyncSessionLocal() as session: + r = await session.get(MinorReport, report.id) + if r: + r.status = new_status + r.reviewer_id = interaction.user.id + r.updated_at = now + await session.commit() + report_for_embed = r or report + htb_id = await get_htb_user_id_for_discord(report_for_embed.user_id) + htb_url = f"{HTB_PROFILE_URL}{htb_id}" if htb_id else None + embed = build_minor_report_embed( + report_for_embed, + guild, + reported_user=member, + status_notes=status_notes, + htb_profile_url=htb_url, + ) + # If consent is found, this is a terminal state: remove all buttons. + # Otherwise, keep the existing view so reviewers can try rechecking again. + view = None if has_consent else self + await self._edit_report_message(interaction, embed, view) diff --git a/src/webhooks/handlers/__init__.py b/src/webhooks/handlers/__init__.py index 4036462..8d41dfc 100644 --- a/src/webhooks/handlers/__init__.py +++ b/src/webhooks/handlers/__init__.py @@ -1,8 +1,9 @@ -from discord import Bot from typing import Any -from src.webhooks.handlers.account import AccountHandler +from discord import Bot + from src.webhooks.handlers.academy import AcademyHandler +from src.webhooks.handlers.account import AccountHandler from src.webhooks.handlers.mp import MPHandler from src.webhooks.types import Platform, WebhookBody diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index 517f4a3..143eb73 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -1,4 +1,5 @@ from datetime import datetime + from discord import Bot from src.core import settings diff --git a/src/webhooks/server.py b/src/webhooks/server.py index 1c4d9e3..672ece0 100644 --- a/src/webhooks/server.py +++ b/src/webhooks/server.py @@ -1,7 +1,7 @@ import hashlib import hmac -import logging import json +import logging from typing import Any, Dict from fastapi import FastAPI, HTTPException, Request diff --git a/tests/src/cmds/automation/test_scheduled_tasks.py b/tests/src/cmds/automation/test_scheduled_tasks.py new file mode 100644 index 0000000..2b2420d --- /dev/null +++ b/tests/src/cmds/automation/test_scheduled_tasks.py @@ -0,0 +1,259 @@ +"""Tests for ScheduledTasks cog (parental consent: auto_remove_minor_role, on_member_join).""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import discord +import pytest + +from src.cmds.automation import scheduled_tasks +from src.database.models import MinorReport +from src.helpers.minor_verification import APPROVED, CONSENT_VERIFIED +from tests import helpers + + +@pytest.fixture(autouse=True) +def stop_task_loop(): + """Prevent ScheduledTasks from starting the background task loop in tests.""" + + def init_no_loop(self, bot): + self.bot = bot + # Do not call self.all_tasks.start() + + with patch.object(scheduled_tasks.ScheduledTasks, "__init__", init_no_loop): + yield + + +class TestScheduledTasksMinorRole: + """Tests for minor-role-related scheduled task logic.""" + + @pytest.mark.asyncio + async def test_auto_remove_minor_role_no_reports(self, bot): + """Test auto_remove_minor_role when there are no reports.""" + mock_session = MagicMock() + mock_scalars_result = MagicMock() + mock_scalars_result.all.return_value = [] + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with ( + patch('src.cmds.automation.scheduled_tasks.AsyncSessionLocal', return_value=AsyncContextManager()), + patch('src.cmds.automation.scheduled_tasks.settings') as mock_settings, + ): + mock_settings.guild_ids = [123] + cog = scheduled_tasks.ScheduledTasks(bot) + await cog.auto_remove_minor_role() + # No role removals when there are no reports + + @pytest.mark.asyncio + async def test_auto_remove_minor_role_skips_when_not_yet_18(self, bot): + """Test auto_remove_minor_role skips reports where user has not reached 18.""" + now = datetime.now(timezone.utc) + # Report from 6 months ago, suspected_age 17 -> expires 1 year after creation -> still future + created = now - timedelta(days=180) + report = MinorReport( + id=1, + user_id=999, + reporter_id=2, + suspected_age=17, + evidence="x", + report_message_id=1, + status=CONSENT_VERIFIED, + created_at=created, + updated_at=now, + ) + mock_session = MagicMock() + mock_scalars_result = MagicMock() + mock_scalars_result.all.return_value = [report] + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with ( + patch('src.cmds.automation.scheduled_tasks.AsyncSessionLocal', return_value=AsyncContextManager()), + patch('src.cmds.automation.scheduled_tasks.settings') as mock_settings, + ): + mock_settings.guild_ids = [123] + bot.get_guild = MagicMock(return_value=helpers.MockGuild()) + mock_member = helpers.MockMember(id=999, name="User") + mock_member.remove_roles = AsyncMock() + role = helpers.MockRole(id=456, name="Verified Minor") + mock_member.roles = [role] + bot.get_member_or_user = AsyncMock(return_value=mock_member) + bot.get_guild.return_value.get_role = lambda rid: role if rid == 456 else None + + cog = scheduled_tasks.ScheduledTasks(bot) + await cog.auto_remove_minor_role() + + # User is 17, report created 1 year ago -> 1 year until 18 -> not yet expired + mock_member.remove_roles.assert_not_called() + + @pytest.mark.asyncio + async def test_auto_remove_minor_role_removes_when_18(self, bot): + """Test auto_remove_minor_role removes role when user has reached 18.""" + now = datetime.now(timezone.utc) + # Report from 4 years ago, suspected_age 15 -> expires 3 years after creation -> clearly past + created = now.replace(year=now.year - 4) + report = MinorReport( + id=1, + user_id=999, + reporter_id=2, + suspected_age=15, + evidence="x", + report_message_id=1, + status=CONSENT_VERIFIED, + created_at=created, + updated_at=now, + ) + mock_session = MagicMock() + mock_scalars_result = MagicMock() + mock_scalars_result.all.return_value = [report] + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with ( + patch('src.cmds.automation.scheduled_tasks.AsyncSessionLocal', return_value=AsyncContextManager()), + patch('src.cmds.automation.scheduled_tasks.settings') as mock_settings, + ): + mock_settings.guild_ids = [123] + mock_settings.roles.VERIFIED_MINOR = 456 + guild = helpers.MockGuild() + role = helpers.MockRole(id=456, name="Verified Minor") + guild.get_role = lambda rid: role if rid == 456 else None + bot.get_guild = MagicMock(return_value=guild) + # MagicMock(spec=discord.Member) makes isinstance(mock, discord.Member) return True + mock_member = MagicMock(spec=discord.Member) + mock_member.id = 999 + mock_member.roles = [role] + mock_member.remove_roles = AsyncMock() + bot.get_member_or_user = AsyncMock(return_value=mock_member) + + cog = scheduled_tasks.ScheduledTasks(bot) + await cog.auto_remove_minor_role() + + mock_member.remove_roles.assert_called_once_with(role, atomic=True) + + @pytest.mark.asyncio + async def test_on_member_join_no_report(self, bot): + """Test on_member_join does nothing when no consent_verified report for user.""" + member = helpers.MockMember(id=999, name="User") + member.guild = helpers.MockGuild() + + mock_session = MagicMock() + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = None + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with ( + patch('src.cmds.automation.scheduled_tasks.AsyncSessionLocal', return_value=AsyncContextManager()), + patch('src.cmds.automation.scheduled_tasks.assign_minor_role', new_callable=AsyncMock) as assign_mock, + ): + cog = scheduled_tasks.ScheduledTasks(bot) + await cog.on_member_join(member) + + assign_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_on_member_join_assigns_when_under_18(self, bot): + """Test on_member_join assigns minor role when report is consent_verified and user under 18.""" + member = helpers.MockMember(id=999, name="User") + member.guild = helpers.MockGuild() + now = datetime.now(timezone.utc) + created = now - timedelta(days=100) + report = MinorReport( + id=1, + user_id=999, + reporter_id=2, + suspected_age=15, + evidence="x", + report_message_id=1, + status=CONSENT_VERIFIED, + created_at=created, + updated_at=now, + ) + + mock_session = MagicMock() + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = report + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with ( + patch('src.cmds.automation.scheduled_tasks.AsyncSessionLocal', return_value=AsyncContextManager()), + patch('src.cmds.automation.scheduled_tasks.assign_minor_role', new_callable=AsyncMock) as assign_mock, + ): + cog = scheduled_tasks.ScheduledTasks(bot) + await cog.on_member_join(member) + + assign_mock.assert_called_once_with(member, member.guild) + + @pytest.mark.asyncio + async def test_on_member_join_skips_when_already_18(self, bot): + """Test on_member_join does not assign when user has reached 18 (expires_at in past).""" + member = helpers.MockMember(id=999, name="User") + member.guild = helpers.MockGuild() + now = datetime.now(timezone.utc) + # Report from 5 years ago, suspected_age 15 -> would be 20 now + created = now - timedelta(days=365 * 5) + report = MinorReport( + id=1, + user_id=999, + reporter_id=2, + suspected_age=15, + evidence="x", + report_message_id=1, + status=CONSENT_VERIFIED, + created_at=created, + updated_at=now, + ) + + mock_session = MagicMock() + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = report + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with ( + patch('src.cmds.automation.scheduled_tasks.AsyncSessionLocal', return_value=AsyncContextManager()), + patch('src.cmds.automation.scheduled_tasks.assign_minor_role', new_callable=AsyncMock) as assign_mock, + ): + cog = scheduled_tasks.ScheduledTasks(bot) + await cog.on_member_join(member) + + assign_mock.assert_not_called() diff --git a/tests/src/cmds/core/test_flag_minor.py b/tests/src/cmds/core/test_flag_minor.py new file mode 100644 index 0000000..774578f --- /dev/null +++ b/tests/src/cmds/core/test_flag_minor.py @@ -0,0 +1,231 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.cmds.core import flag_minor +from src.database.models import MinorReport +from tests import helpers + + +class TestFlagMinorCog: + """Test the `FlagMinor` cog.""" + + @pytest.mark.asyncio + async def test_flag_minor_success_no_consent(self, ctx, bot): + """Test flagging a minor where Nexus reports no parental consent — report created.""" + ctx.user = helpers.MockMember(id=1, name="Test Moderator") + + verified_role = helpers.MockRole(id=123, name="Verified") + minor_role = helpers.MockRole(id=456, name="Verified Minor") + + user = helpers.MockMember(id=2, name="Suspected Minor") + user.roles = [verified_role] + + ctx.guild.get_role = lambda id: verified_role if id == 123 else minor_role if id == 456 else None + bot.get_member_or_user.return_value = user + + with ( + patch("src.cmds.core.flag_minor.get_htb_user_id_for_discord", new_callable=AsyncMock) as get_link_mock, + patch("src.cmds.core.flag_minor.check_parental_consent", new_callable=AsyncMock) as consent_mock, + patch("src.cmds.core.flag_minor.get_active_minor_report", new_callable=AsyncMock) as get_report_mock, + patch("src.cmds.core.flag_minor.AsyncSessionLocal") as session_mock, + patch("src.cmds.core.flag_minor.settings") as mock_settings, + ): + mock_settings.roles.VERIFIED = 123 + mock_settings.roles.VERIFIED_MINOR = 456 + mock_settings.channels.MINOR_REVIEW = 999 + + get_link_mock.return_value = None + consent_mock.return_value = False + get_report_mock.return_value = None + + mock_session = AsyncMock() + session_mock.return_value.__aenter__.return_value = mock_session + + mock_message = helpers.MockMessage(id=12345) + mock_channel = MagicMock() + mock_channel.send = AsyncMock(return_value=mock_message) + mock_channel.fetch_message = AsyncMock(return_value=mock_message) + ctx.guild.get_channel = MagicMock(return_value=mock_channel) + + cog = flag_minor.FlagMinorCog(bot) + await cog.flag_minor.callback(cog, ctx, user, 15, "User stated they are 15 in chat") + + assert ctx.respond.called + consent_mock.assert_called_once_with(user.id) + + @pytest.mark.asyncio + async def test_flag_minor_consent_already_exists(self, ctx, bot): + """Test flagging a minor when Nexus confirms parental consent already on file.""" + ctx.user = helpers.MockMember(id=1, name="Test Moderator") + + verified_role = helpers.MockRole(id=123, name="Verified") + minor_role = helpers.MockRole(id=456, name="Verified Minor") + + user = helpers.MockMember(id=2, name="Suspected Minor") + user.roles = [verified_role] + + ctx.guild.get_role = lambda id: verified_role if id == 123 else minor_role if id == 456 else None + bot.get_member_or_user.return_value = user + + with ( + patch("src.cmds.core.flag_minor.check_parental_consent", new_callable=AsyncMock) as consent_mock, + patch("src.cmds.core.flag_minor.get_active_minor_report", new_callable=AsyncMock) as get_report_mock, + patch("src.cmds.core.flag_minor.assign_minor_role", new_callable=AsyncMock) as assign_role_mock, + patch("src.cmds.core.flag_minor.settings") as mock_settings, + ): + mock_settings.roles.VERIFIED = 123 + mock_settings.roles.VERIFIED_MINOR = 456 + mock_settings.channels.MINOR_REVIEW = 999 + + get_report_mock.return_value = None + consent_mock.return_value = True + assign_role_mock.return_value = True + + cog = flag_minor.FlagMinorCog(bot) + await cog.flag_minor.callback(cog, ctx, user, 15, "User stated they are 15 in chat") + + assert ctx.respond.called + consent_mock.assert_called_once_with(user.id) + + @pytest.mark.asyncio + async def test_flag_minor_existing_report(self, ctx, bot): + """Test flagging a minor when an active report already exists — report updated.""" + ctx.user = helpers.MockMember(id=1, name="Test Moderator") + + verified_role = helpers.MockRole(id=123, name="Verified") + minor_role = helpers.MockRole(id=456, name="Verified Minor") + + user = helpers.MockMember(id=2, name="Suspected Minor") + user.roles = [verified_role] + + ctx.guild.get_role = lambda id: verified_role if id == 123 else minor_role if id == 456 else None + bot.get_member_or_user.return_value = user + + existing_report = MinorReport( + id=1, + user_id=2, + reporter_id=3, + suspected_age=15, + evidence="Previous evidence", + report_message_id=99999, + status="pending", + ) + + mock_session = AsyncMock() + mock_session.get = AsyncMock(return_value=existing_report) + mock_session.commit = AsyncMock() + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + mock_message = helpers.MockMessage(id=99999) + mock_channel = MagicMock() + mock_channel.fetch_message = AsyncMock(return_value=mock_message) + ctx.guild.get_channel = MagicMock(return_value=mock_channel) + + with ( + patch("src.cmds.core.flag_minor.get_htb_user_id_for_discord", new_callable=AsyncMock) as get_link_mock, + patch("src.cmds.core.flag_minor.check_parental_consent", new_callable=AsyncMock) as consent_mock, + patch("src.cmds.core.flag_minor.get_active_minor_report", new_callable=AsyncMock) as get_report_mock, + patch("src.cmds.core.flag_minor.AsyncSessionLocal", return_value=AsyncContextManager()), + patch("src.cmds.core.flag_minor.settings") as mock_settings, + ): + mock_settings.roles.VERIFIED = 123 + mock_settings.roles.VERIFIED_MINOR = 456 + mock_settings.channels.MINOR_REVIEW = 999 + + get_link_mock.return_value = None + consent_mock.return_value = False + get_report_mock.return_value = existing_report + + cog = flag_minor.FlagMinorCog(bot) + await cog.flag_minor.callback(cog, ctx, user, 15, "User stated they are 15 in chat") + + assert ctx.respond.called + + @pytest.mark.asyncio + async def test_flag_minor_invalid_age(self, ctx, bot): + """Test flagging with an invalid age (outside 1-17 range).""" + ctx.user = helpers.MockMember(id=1, name="Test Moderator") + user = helpers.MockMember(id=2, name="User") + bot.get_member_or_user.return_value = user + + cog = flag_minor.FlagMinorCog(bot) + + await cog.flag_minor.callback(cog, ctx, user, 0, "Evidence") + assert ctx.respond.called or ctx.followup.send.called + + ctx.reset_mock() + await cog.flag_minor.callback(cog, ctx, user, 18, "Evidence") + assert ctx.respond.called or ctx.followup.send.called + + @pytest.mark.asyncio + async def test_flag_minor_no_review_channel_configured(self, ctx, bot): + """Test early return when MINOR_REVIEW channel is not configured.""" + verified_role = helpers.MockRole(id=123, name="Verified") + minor_role = helpers.MockRole(id=456, name="Verified Minor") + user = helpers.MockMember(id=2, name="Suspected Minor") + user.roles = [verified_role] + ctx.guild.get_role = lambda id: verified_role if id == 123 else minor_role if id == 456 else None + bot.get_member_or_user.return_value = user + + status_edit = AsyncMock() + ctx.respond.return_value = MagicMock(edit=status_edit) + + with ( + patch("src.cmds.core.flag_minor.get_htb_user_id_for_discord", new_callable=AsyncMock), + patch("src.cmds.core.flag_minor.check_parental_consent", new_callable=AsyncMock) as consent_mock, + patch("src.cmds.core.flag_minor.get_active_minor_report", new_callable=AsyncMock), + patch("src.cmds.core.flag_minor.settings") as mock_settings, + ): + mock_settings.roles.VERIFIED = 123 + mock_settings.roles.VERIFIED_MINOR = 456 + mock_settings.channels.MINOR_REVIEW = None + consent_mock.return_value = False + + cog = flag_minor.FlagMinorCog(bot) + await cog.flag_minor.callback(cog, ctx, user, 15, "Evidence") + + status_edit.assert_called_once() + assert "not configured" in status_edit.call_args[1].get("content", "").lower() + + @pytest.mark.asyncio + async def test_flag_minor_review_channel_not_found(self, ctx, bot): + """Test early return when review channel ID is set but channel not found.""" + verified_role = helpers.MockRole(id=123, name="Verified") + minor_role = helpers.MockRole(id=456, name="Verified Minor") + user = helpers.MockMember(id=2, name="Suspected Minor") + user.roles = [verified_role] + ctx.guild.get_role = lambda id: verified_role if id == 123 else minor_role if id == 456 else None + ctx.guild.get_channel = MagicMock(return_value=None) + bot.get_member_or_user.return_value = user + + status_edit = AsyncMock() + ctx.respond.return_value = MagicMock(edit=status_edit) + + with ( + patch("src.cmds.core.flag_minor.get_htb_user_id_for_discord", new_callable=AsyncMock), + patch("src.cmds.core.flag_minor.check_parental_consent", new_callable=AsyncMock) as consent_mock, + patch("src.cmds.core.flag_minor.get_active_minor_report", new_callable=AsyncMock), + patch("src.cmds.core.flag_minor.settings") as mock_settings, + ): + mock_settings.roles.VERIFIED = 123 + mock_settings.roles.VERIFIED_MINOR = 456 + mock_settings.channels.MINOR_REVIEW = 999 + consent_mock.return_value = False + + cog = flag_minor.FlagMinorCog(bot) + await cog.flag_minor.callback(cog, ctx, user, 15, "Evidence") + + status_edit.assert_called_once() + assert "not found" in status_edit.call_args[1].get("content", "").lower() + + def test_setup(self, bot): + """Test the setup method of the cog.""" + flag_minor.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/src/cmds/core/test_minor_reviewers.py b/tests/src/cmds/core/test_minor_reviewers.py new file mode 100644 index 0000000..da816dc --- /dev/null +++ b/tests/src/cmds/core/test_minor_reviewers.py @@ -0,0 +1,167 @@ +"""Tests for the MinorReviewers cog (parental consent feature).""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.cmds.core import minor_reviewers +from src.database.models import MinorReviewReviewer +from tests import helpers + + +class TestMinorReviewersCog: + """Test the MinorReviewers cog.""" + + @pytest.mark.asyncio + async def test_add_success(self, ctx): + """Test adding a reviewer when not already in list.""" + ctx.user = helpers.MockMember(id=1, name="Admin") + user = helpers.MockMember(id=2, name="New Reviewer") + + mock_session = MagicMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = None + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with ( + patch('src.cmds.core.minor_reviewers.AsyncSessionLocal', return_value=AsyncContextManager()), + patch('src.cmds.core.minor_reviewers.invalidate_reviewer_ids_cache') as invalidate_mock + ): + cog = minor_reviewers.MinorReviewersCog(AsyncMock()) + await cog.add.callback(cog, ctx, user) + + ctx.respond.assert_called_once() + call_args = ctx.respond.call_args[0][0] + assert "Added" in call_args and "reviewer" in call_args + invalidate_mock.assert_called_once() + + @pytest.mark.asyncio + async def test_add_already_exists(self, ctx): + """Test adding a reviewer who is already in the list.""" + ctx.user = helpers.MockMember(id=1, name="Admin") + user = helpers.MockMember(id=2, name="Existing Reviewer") + existing = MinorReviewReviewer( + id=1, user_id=2, added_by=1, created_at=datetime.now(timezone.utc) + ) + + mock_session = MagicMock() + mock_session.add = MagicMock() + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = existing + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with patch('src.cmds.core.minor_reviewers.AsyncSessionLocal', return_value=AsyncContextManager()): + cog = minor_reviewers.MinorReviewersCog(AsyncMock()) + await cog.add.callback(cog, ctx, user) + + ctx.respond.assert_called_once() + assert "already" in ctx.respond.call_args[0][0].lower() + + @pytest.mark.asyncio + async def test_remove_success(self, ctx): + """Test removing a reviewer.""" + ctx.user = helpers.MockMember(id=1, name="Admin") + user = helpers.MockMember(id=2, name="Reviewer") + row = MinorReviewReviewer( + id=1, user_id=2, added_by=1, created_at=datetime.now(timezone.utc) + ) + + mock_session = MagicMock() + mock_session.delete = AsyncMock() + mock_session.commit = AsyncMock() + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = row + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with ( + patch('src.cmds.core.minor_reviewers.AsyncSessionLocal', return_value=AsyncContextManager()), + patch('src.cmds.core.minor_reviewers.invalidate_reviewer_ids_cache') as invalidate_mock + ): + cog = minor_reviewers.MinorReviewersCog(AsyncMock()) + await cog.remove.callback(cog, ctx, user) + + ctx.respond.assert_called_once() + assert "Removed" in ctx.respond.call_args[0][0] + invalidate_mock.assert_called_once() + + @pytest.mark.asyncio + async def test_remove_not_in_list(self, ctx): + """Test removing a user who is not in the reviewer list.""" + ctx.user = helpers.MockMember(id=1, name="Admin") + user = helpers.MockMember(id=2, name="User") + + mock_session = MagicMock() + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = None + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with patch('src.cmds.core.minor_reviewers.AsyncSessionLocal', return_value=AsyncContextManager()): + cog = minor_reviewers.MinorReviewersCog(AsyncMock()) + await cog.remove.callback(cog, ctx, user) + + ctx.respond.assert_called_once() + assert "not" in ctx.respond.call_args[0][0].lower() + + @pytest.mark.asyncio + async def test_list_reviewers_empty(self, ctx): + """Test list when no reviewers configured.""" + with patch( + 'src.cmds.core.minor_reviewers.get_minor_review_reviewer_ids', + new_callable=AsyncMock, + return_value=(), + ): + cog = minor_reviewers.MinorReviewersCog(AsyncMock()) + await cog.list_reviewers.callback(cog, ctx) + + ctx.respond.assert_called_once() + assert "no" in ctx.respond.call_args[0][0].lower() or "empty" in ctx.respond.call_args[0][0].lower() + + @pytest.mark.asyncio + async def test_list_reviewers_with_ids(self, ctx): + """Test list when reviewers exist.""" + with patch( + 'src.cmds.core.minor_reviewers.get_minor_review_reviewer_ids', + new_callable=AsyncMock, + return_value=(111, 222), + ): + cog = minor_reviewers.MinorReviewersCog(AsyncMock()) + await cog.list_reviewers.callback(cog, ctx) + + ctx.respond.assert_called_once() + assert "111" in ctx.respond.call_args[0][0] and "222" in ctx.respond.call_args[0][0] + + def test_setup(self, bot): + """Test cog setup.""" + minor_reviewers.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/src/database/models/test_minor_report_model.py b/tests/src/database/models/test_minor_report_model.py new file mode 100644 index 0000000..e4cf3b0 --- /dev/null +++ b/tests/src/database/models/test_minor_report_model.py @@ -0,0 +1,142 @@ +import pytest +from sqlalchemy import delete, insert, select, update +from unittest.mock import MagicMock + +from src.database.models import MinorReport + + +class TestMinorReportModel: + + @pytest.mark.asyncio + async def test_select(self, session): + async with session() as session: + # Define return value for select + session.get.return_value = MinorReport( + id=1, + user_id=123456789, + reporter_id=987654321, + suspected_age=15, + evidence="User stated they are 15 in chat", + report_message_id=111222333, + status="pending" + ) + + report = await session.get(MinorReport, 1) + assert report.id == 1 + assert report.user_id == 123456789 + assert report.status == "pending" + + # Check if the method was called with the correct argument + session.get.assert_called_once() + + @pytest.mark.asyncio + async def test_insert(self, session): + async with session() as session: + # Define return value for insert + session.add.return_value = None + session.commit.return_value = None + + query = insert(MinorReport).values( + user_id=123456789, + reporter_id=987654321, + suspected_age=15, + evidence="User stated they are 15 in chat", + report_message_id=111222333, + status="pending" + ) + session.add(query) + await session.commit() + + # Check if the methods were called with the correct arguments + session.add.assert_called_once_with(query) + session.commit.assert_called_once() + + @pytest.mark.asyncio + async def test_update_status(self, session): + async with session() as session: + # Define return value for update + session.execute.return_value = None + session.commit.return_value = None + + query = ( + update(MinorReport) + .where(MinorReport.id == 1) + .values(status="approved", reviewer_id=555666777) + ) + await session.execute(query) + await session.commit() + + # Check if the methods were called with the correct arguments + session.execute.assert_called_once_with(query) + session.commit.assert_called_once() + + @pytest.mark.asyncio + async def test_update_associated_ban(self, session): + async with session() as session: + # Define return value for update + session.execute.return_value = None + session.commit.return_value = None + + query = ( + update(MinorReport) + .where(MinorReport.id == 1) + .values(associated_ban_id=42) + ) + await session.execute(query) + await session.commit() + + # Check if the methods were called with the correct arguments + session.execute.assert_called_once_with(query) + session.commit.assert_called_once() + + @pytest.mark.asyncio + async def test_delete(self, session): + async with session() as session: + # Define a MinorReport record to delete + report = MinorReport( + user_id=123456789, + reporter_id=987654321, + suspected_age=15, + evidence="User stated they are 15 in chat", + report_message_id=111222333, + status="pending" + ) + session.add(report) + await session.commit() + + # Define return value for delete + session.execute.return_value = None + session.commit.return_value = None + + # Delete the MinorReport record from the database + query = delete(MinorReport).where(MinorReport.id == report.id) + await session.execute(query) + + # Check if the methods were called with the correct arguments + session.execute.assert_called_once_with(query) + session.commit.assert_called_once() + + @pytest.mark.asyncio + async def test_select_by_user_id(self, session): + async with session() as session: + # Mock the execute return value + mock_scalars = MagicMock() + mock_scalars.first.return_value = MinorReport( + id=1, + user_id=123456789, + reporter_id=987654321, + suspected_age=15, + evidence="Evidence", + report_message_id=111222333, + status="pending" + ) + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + session.execute.return_value = mock_result + + query = select(MinorReport).where(MinorReport.user_id == 123456789) + result = await session.execute(query) + report = result.scalars().first() + + assert report.user_id == 123456789 + session.execute.assert_called_once() diff --git a/tests/src/helpers/test_minor_verification.py b/tests/src/helpers/test_minor_verification.py new file mode 100644 index 0000000..24feed7 --- /dev/null +++ b/tests/src/helpers/test_minor_verification.py @@ -0,0 +1,440 @@ +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiohttp import ClientSession + +from src.helpers.minor_verification import ( + APPROVED, + CONSENT_VERIFIED, + DENIED, + PENDING, + assign_minor_role, + calculate_ban_duration, + check_parental_consent, + get_active_minor_report, + get_htb_user_id_for_discord, + get_minor_review_reviewer_ids, + invalidate_reviewer_ids_cache, + is_minor_review_moderator, + years_until_18, +) +from src.database.models import MinorReport +from tests import helpers + + +class MockResponse: + def __init__(self, status, text_data="", json_data=None): + self.status = status + self._text = text_data + self._json = json_data or {} + + async def text(self): + return self._text + + async def json(self): + return self._json + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + +class TestMinorVerificationHelpers: + """Test the minor verification helper functions.""" + + def test_years_until_18_minor(self): + """Test years until 18 calculation for a minor.""" + assert years_until_18(15) == 3 + assert years_until_18(17) == 1 + assert years_until_18(10) == 8 + + def test_years_until_18_invalid_age(self): + """Test years until 18 raises ValueError for invalid ages.""" + with pytest.raises(ValueError, match="suspected_age must be between 1 and 17"): + years_until_18(18) + with pytest.raises(ValueError, match="suspected_age must be between 1 and 17"): + years_until_18(0) + with pytest.raises(ValueError, match="suspected_age must be between 1 and 17"): + years_until_18(25) + + def test_calculate_ban_duration_minor(self): + """Test ban duration calculation for minors.""" + # 15 years old -> returns Unix timestamp 3 years from now + now = time.time() + duration = calculate_ban_duration(15) + # Should be approximately 3 years from now + three_years_seconds = 3 * 365 * 24 * 60 * 60 + expected_timestamp = now + three_years_seconds + # Allow 1 day tolerance for execution time + assert abs(duration - expected_timestamp) < 86400 + + def test_calculate_ban_duration_edge_cases(self): + """Test ban duration edge cases.""" + now = time.time() + + # 17 years old -> 1 year from now + duration = calculate_ban_duration(17) + one_year_seconds = 365 * 24 * 60 * 60 + expected_timestamp = now + one_year_seconds + assert abs(duration - expected_timestamp) < 86400 + + # 18+ should raise ValueError + with pytest.raises(ValueError, match="suspected_age must be between 1 and 17"): + calculate_ban_duration(18) + + # Age 1 -> 17 years from now + duration = calculate_ban_duration(1) + seventeen_years_seconds = 17 * 365 * 24 * 60 * 60 + expected_timestamp = now + seventeen_years_seconds + assert abs(duration - expected_timestamp) < 86400 * 2 # 2 day tolerance for longer duration + + @pytest.mark.asyncio + async def test_check_parental_consent_exists(self): + """Test successful consent check via Nexus when consent exists.""" + discord_id = 123456789012345678 + + mock_response = MockResponse( + status=200, + text_data='{"exists": true}', + json_data={"exists": True}, + ) + + with ( + patch("src.helpers.minor_verification.settings") as mock_settings, + patch.object(ClientSession, "post", return_value=mock_response), + ): + mock_settings.NEXUS_API_BASE_URL = "https://nexus.example.com/secret" + mock_settings.NEXUS_API_TOKEN = "test_token" + + result = await check_parental_consent(discord_id) + + assert result is True + + @pytest.mark.asyncio + async def test_check_parental_consent_not_exists(self): + """Test Nexus consent check when consent doesn't exist.""" + discord_id = 123456789012345678 + + mock_response = MockResponse( + status=200, + text_data='{"exists": false}', + json_data={"exists": False}, + ) + + with ( + patch("src.helpers.minor_verification.settings") as mock_settings, + patch.object(ClientSession, "post", return_value=mock_response), + ): + mock_settings.NEXUS_API_BASE_URL = "https://nexus.example.com/secret" + mock_settings.NEXUS_API_TOKEN = "test_token" + + result = await check_parental_consent(discord_id) + + assert result is False + + @pytest.mark.asyncio + async def test_check_parental_consent_missing_url(self): + """Test consent check returns False when NEXUS_API_BASE_URL is not set.""" + with patch("src.helpers.minor_verification.settings") as mock_settings: + mock_settings.NEXUS_API_BASE_URL = None + mock_settings.NEXUS_API_TOKEN = "test_token" + + result = await check_parental_consent(123456789012345678) + + assert result is False + + @pytest.mark.asyncio + async def test_check_parental_consent_missing_token(self): + """Test consent check returns False when NEXUS_API_TOKEN is not set.""" + with patch("src.helpers.minor_verification.settings") as mock_settings: + mock_settings.NEXUS_API_BASE_URL = "https://nexus.example.com/secret" + mock_settings.NEXUS_API_TOKEN = None + + result = await check_parental_consent(123456789012345678) + + assert result is False + + @pytest.mark.asyncio + async def test_check_parental_consent_http_error(self): + """Test consent check returns False on non-200 HTTP response.""" + discord_id = 123456789012345678 + + mock_response = MockResponse(status=500, text_data="Internal Server Error") + + with ( + patch("src.helpers.minor_verification.settings") as mock_settings, + patch.object(ClientSession, "post", return_value=mock_response), + ): + mock_settings.NEXUS_API_BASE_URL = "https://nexus.example.com/secret" + mock_settings.NEXUS_API_TOKEN = "test_token" + + result = await check_parental_consent(discord_id) + + assert result is False + + @pytest.mark.asyncio + async def test_get_htb_user_id_for_discord_found(self): + """Test getting HTB user ID when link exists.""" + discord_id = 123456789 + htb_id = 999 + + mock_link = type('obj', (object,), {'htb_user_id': htb_id})() + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = mock_link + + mock_session = AsyncMock() + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with patch('src.helpers.minor_verification.AsyncSessionLocal', return_value=AsyncContextManager()): + result = await get_htb_user_id_for_discord(discord_id) + + assert result == htb_id + + @pytest.mark.asyncio + async def test_get_htb_user_id_for_discord_not_found(self): + """Test getting HTB user ID when no link exists.""" + discord_id = 123456789 + + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = None + + mock_session = AsyncMock() + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with patch('src.helpers.minor_verification.AsyncSessionLocal', return_value=AsyncContextManager()): + result = await get_htb_user_id_for_discord(discord_id) + + assert result is None + + @pytest.mark.asyncio + async def test_get_active_minor_report_found(self): + """Test getting active minor report when one exists.""" + user_id = 123456789 + + mock_report = MinorReport( + id=1, + user_id=user_id, + reporter_id=987654321, + suspected_age=15, + evidence="Evidence", + report_message_id=111222333, + status=PENDING + ) + + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = mock_report + + mock_session = AsyncMock() + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with patch('src.helpers.minor_verification.AsyncSessionLocal', return_value=AsyncContextManager()): + result = await get_active_minor_report(user_id) + + assert result == mock_report + assert result.user_id == user_id + + @pytest.mark.asyncio + async def test_get_active_minor_report_not_found(self): + """Test getting active minor report when none exists.""" + user_id = 123456789 + + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = None + + mock_session = AsyncMock() + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with patch('src.helpers.minor_verification.AsyncSessionLocal', return_value=AsyncContextManager()): + result = await get_active_minor_report(user_id) + + assert result is None + + @pytest.mark.asyncio + async def test_get_minor_review_reviewer_ids(self): + """Test getting reviewer IDs.""" + # The function queries MinorReviewReviewer.user_id which returns just IDs, not objects + mock_ids = [111, 222, 333] + + mock_scalars_result = MagicMock() + mock_scalars_result.all.return_value = mock_ids + + mock_session = AsyncMock() + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with patch('src.helpers.minor_verification.AsyncSessionLocal', return_value=AsyncContextManager()): + # Clear cache first + invalidate_reviewer_ids_cache() + result = await get_minor_review_reviewer_ids() + + assert result == (111, 222, 333) + + @pytest.mark.asyncio + async def test_is_minor_review_moderator_true(self): + """Test checking if user is a reviewer (positive case).""" + user_id = 111 + + mock_ids = [111, 222] + + mock_scalars_result = MagicMock() + mock_scalars_result.all.return_value = mock_ids + + mock_session = AsyncMock() + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with patch('src.helpers.minor_verification.AsyncSessionLocal', return_value=AsyncContextManager()): + # Clear cache + invalidate_reviewer_ids_cache() + result = await is_minor_review_moderator(user_id) + + assert result is True + + @pytest.mark.asyncio + async def test_is_minor_review_moderator_false(self): + """Test checking if user is a reviewer (negative case).""" + user_id = 999 + + mock_ids = [111, 222] + + mock_scalars_result = MagicMock() + mock_scalars_result.all.return_value = mock_ids + + mock_session = AsyncMock() + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with patch('src.helpers.minor_verification.AsyncSessionLocal', return_value=AsyncContextManager()): + # Clear cache + invalidate_reviewer_ids_cache() + result = await is_minor_review_moderator(user_id) + + assert result is False + + def test_invalidate_reviewer_ids_cache(self): + """Test invalidating the reviewer cache.""" + # This function just resets the cache, so we can call it and ensure no errors + invalidate_reviewer_ids_cache() + # If it doesn't raise an exception, the test passes + + def test_status_constants(self): + """Test that status constants are defined correctly.""" + assert PENDING == "pending" + assert APPROVED == "approved" + assert DENIED == "denied" + assert CONSENT_VERIFIED == "consent_verified" + + @pytest.mark.asyncio + async def test_assign_minor_role_success(self): + """Test assigning minor role when member does not have it.""" + member = helpers.MockMember(id=1, name="User") + guild = helpers.MockGuild() + role = helpers.MockRole(id=456, name="Verified Minor") + member.roles = [] + member.add_roles = AsyncMock() + guild.get_role = lambda id: role if id == 456 else None + + with patch('src.helpers.minor_verification.settings') as mock_settings: + mock_settings.roles.VERIFIED_MINOR = 456 + result = await assign_minor_role(member, guild) + + assert result is True + member.add_roles.assert_called_once_with(role, atomic=True) + + @pytest.mark.asyncio + async def test_assign_minor_role_already_has_role(self): + """Test assign_minor_role when member already has the role.""" + role = helpers.MockRole(id=456, name="Verified Minor") + member = helpers.MockMember(id=1, name="User") + member.roles = [role] + member.add_roles = AsyncMock() + guild = helpers.MockGuild() + guild.get_role = lambda id: role if id == 456 else None + + with patch('src.helpers.minor_verification.settings') as mock_settings: + mock_settings.roles.VERIFIED_MINOR = 456 + result = await assign_minor_role(member, guild) + + assert result is False + member.add_roles.assert_not_called() + + @pytest.mark.asyncio + async def test_assign_minor_role_role_not_found(self): + """Test assign_minor_role when guild does not have the role.""" + member = helpers.MockMember(id=1, name="User") + guild = helpers.MockGuild() + guild.get_role = lambda id: None + + with patch('src.helpers.minor_verification.settings') as mock_settings: + mock_settings.roles.VERIFIED_MINOR = 456 + result = await assign_minor_role(member, guild) + + assert result is False + + @pytest.mark.asyncio + async def test_assign_minor_role_forbidden(self): + """Test assign_minor_role when add_roles raises Forbidden.""" + from discord import Forbidden + + member = helpers.MockMember(id=1, name="User") + member.roles = [] + fake_response = MagicMock(status=403) + member.add_roles = AsyncMock(side_effect=Forbidden(fake_response, "Forbidden")) + role = helpers.MockRole(id=456, name="Verified Minor") + guild = helpers.MockGuild() + guild.get_role = lambda id: role if id == 456 else None + + with patch('src.helpers.minor_verification.settings') as mock_settings: + mock_settings.roles.VERIFIED_MINOR = 456 + result = await assign_minor_role(member, guild) + + assert result is False diff --git a/tests/src/views/test_minorreportview.py b/tests/src/views/test_minorreportview.py new file mode 100644 index 0000000..439f042 --- /dev/null +++ b/tests/src/views/test_minorreportview.py @@ -0,0 +1,388 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from discord import ButtonStyle, Interaction + +from src.database.models import Ban, MinorReport +from src.views.minorreportview import ( + MinorReportView, + build_minor_report_embed, + get_report_by_message_id, +) +from tests import helpers + + +class TestMinorReportView: + """Test the MinorReportView Discord UI component.""" + + @pytest.mark.asyncio + async def test_view_has_persistent_buttons(self, bot): + """Test that view is constructed with persistent buttons.""" + view = MinorReportView(bot) + + # View should have timeout=None for persistence + assert view.timeout is None + + # View should have 3 buttons + assert len(view.children) == 3 + + # All buttons should have custom_ids for persistence + for child in view.children: + assert hasattr(child, 'custom_id') + assert child.custom_id is not None + + @pytest.mark.asyncio + async def test_get_report_helper(self, bot): + """Test the _get_report helper method.""" + view = MinorReportView(bot) + + # Create mock interaction with message + interaction = AsyncMock(spec=Interaction) + interaction.message = helpers.MockMessage(id=12345) + + mock_report = MinorReport( + id=1, + user_id=999, + reporter_id=2, + suspected_age=15, + evidence="Evidence", + report_message_id=12345, + status="pending" + ) + + with patch('src.views.minorreportview.AsyncSessionLocal') as session_mock: + mock_session = AsyncMock() + session_mock.return_value.__aenter__.return_value = mock_session + + # Mock scalars result + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = mock_report + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + # Call _get_report + result = await view._get_report(interaction) + + assert result == mock_report + + @pytest.mark.asyncio + async def test_get_report_not_found(self, bot): + """Test _get_report when report doesn't exist.""" + view = MinorReportView(bot) + + # Create mock interaction with message + interaction = AsyncMock(spec=Interaction) + interaction.message = helpers.MockMessage(id=12345) + + with patch('src.views.minorreportview.AsyncSessionLocal') as session_mock: + mock_session = AsyncMock() + session_mock.return_value.__aenter__.return_value = mock_session + + # Mock scalars result with no report + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = None + mock_session.scalars = AsyncMock(return_value=mock_scalars_result) + + # Call _get_report + result = await view._get_report(interaction) + + assert result is None + + @pytest.mark.asyncio + async def test_build_minor_report_embed(self, bot): + """Test building a minor report embed.""" + mock_report = MinorReport( + id=1, + user_id=999, + reporter_id=888, + suspected_age=15, + evidence="User stated they are 15", + report_message_id=12345, + status="pending" + ) + + mock_guild = helpers.MockGuild() + mock_reporter = helpers.MockMember(id=888, name="Reporter") + mock_guild.get_member = lambda id: mock_reporter if id == 888 else None + + # build_minor_report_embed takes 2 positional args and keyword-only args + embed = build_minor_report_embed(mock_report, mock_guild) + + # Verify embed has required fields + assert embed.title is not None + assert "15" in str(embed.description) or "15" in str(embed.fields) + assert "pending" in str(embed.color).lower() or embed.color is not None + + @pytest.mark.asyncio + async def test_htb_profile_url_constant(self, bot): + """Test that HTB_PROFILE_URL constant is defined.""" + from src.views.minorreportview import HTB_PROFILE_URL + + assert HTB_PROFILE_URL is not None + assert isinstance(HTB_PROFILE_URL, str) + assert "hackthebox" in HTB_PROFILE_URL.lower() + + @pytest.mark.asyncio + async def test_view_initialization(self, bot): + """Test view is initialized correctly.""" + view = MinorReportView(bot) + + # Check bot is stored + assert view.bot == bot + + # Check timeout is None for persistence + assert view.timeout is None + + # Check children are added + assert len(view.children) > 0 + + @pytest.mark.asyncio + async def test_view_button_styles(self, bot): + """Test that view buttons have correct styles.""" + view = MinorReportView(bot) + + # Check button has children (the actual buttons) + assert len(view.children) == 3 + + @pytest.mark.asyncio + async def test_view_button_labels(self, bot): + """Test that view buttons have correct labels.""" + view = MinorReportView(bot) + + # Check view has buttons + assert len(view.children) == 3 + # Buttons should have custom IDs for persistence + custom_ids = [child.custom_id for child in view.children if hasattr(child, 'custom_id')] + assert len(custom_ids) == 3 + + @pytest.mark.asyncio + async def test_button_custom_ids_are_unique(self, bot): + """Test that button custom IDs are unique for persistence.""" + view = MinorReportView(bot) + + custom_ids = [child.custom_id for child in view.children if hasattr(child, 'custom_id')] + + # All custom IDs should be unique + assert len(custom_ids) == len(set(custom_ids)) + + # Should have 3 unique custom IDs + assert len(custom_ids) == 3 + + @pytest.mark.asyncio + async def test_check_reviewer_authorized(self, bot): + """Test _check_reviewer when user is a moderator.""" + view = MinorReportView(bot) + interaction = AsyncMock(spec=Interaction) + interaction.user = helpers.MockMember(id=1, name="Mod") + interaction.response = AsyncMock() + + with patch( + 'src.views.minorreportview.is_minor_review_moderator', + new_callable=AsyncMock, + return_value=True, + ): + result = await view._check_reviewer(interaction) + + assert result is True + interaction.response.send_message.assert_not_called() + + @pytest.mark.asyncio + async def test_check_reviewer_unauthorized(self, bot): + """Test _check_reviewer when user is not a moderator.""" + view = MinorReportView(bot) + interaction = AsyncMock(spec=Interaction) + interaction.user = helpers.MockMember(id=1, name="User") + interaction.response = AsyncMock() + + with patch( + 'src.views.minorreportview.is_minor_review_moderator', + new_callable=AsyncMock, + return_value=False, + ): + result = await view._check_reviewer(interaction) + + assert result is False + interaction.response.send_message.assert_called_once() + assert "not authorized" in interaction.response.send_message.call_args[0][0].lower() + + @pytest.mark.asyncio + async def test_interaction_check_no_report(self, bot): + """Test interaction_check when report is not found.""" + view = MinorReportView(bot) + interaction = AsyncMock(spec=Interaction) + interaction.message = helpers.MockMessage(id=999) + interaction.user = helpers.MockMember(id=1, name="Mod") + interaction.response = AsyncMock() + + with patch( + 'src.views.minorreportview.get_report_by_message_id', + new_callable=AsyncMock, + return_value=None, + ): + result = await view.interaction_check(interaction) + + assert result is False + interaction.response.send_message.assert_called_once() + assert "not found" in interaction.response.send_message.call_args[0][0].lower() + + @pytest.mark.asyncio + async def test_interaction_check_report_exists_authorized(self, bot): + """Test interaction_check when report exists and user is authorized.""" + view = MinorReportView(bot) + mock_report = MinorReport( + id=1, + user_id=999, + reporter_id=2, + suspected_age=15, + evidence="Evidence", + report_message_id=12345, + status="pending", + ) + interaction = AsyncMock(spec=Interaction) + interaction.message = helpers.MockMessage(id=12345) + interaction.user = helpers.MockMember(id=1, name="Mod") + interaction.response = AsyncMock() + + with ( + patch( + 'src.views.minorreportview.get_report_by_message_id', + new_callable=AsyncMock, + return_value=mock_report, + ), + patch( + 'src.views.minorreportview.is_minor_review_moderator', + new_callable=AsyncMock, + return_value=True, + ), + ): + result = await view.interaction_check(interaction) + + assert result is True + interaction.response.send_message.assert_not_called() + + @pytest.mark.asyncio + async def test_recheck_callback_consent_not_found(self, bot): + """Test _recheck_callback when Nexus reports no consent — status unchanged.""" + view = MinorReportView(bot) + now = datetime.now(timezone.utc) + mock_report = MinorReport( + id=1, + user_id=999, + reporter_id=2, + suspected_age=15, + evidence="Evidence", + report_message_id=12345, + status="approved", + created_at=now, + updated_at=now, + ) + interaction = AsyncMock(spec=Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.message = AsyncMock() + interaction.message.edit = AsyncMock() + interaction.message.id = 12345 + interaction.guild = helpers.MockGuild() + interaction.user = helpers.MockMember(id=1, name="Mod") + bot.get_member_or_user = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.get = AsyncMock(return_value=None) + mock_session.commit = AsyncMock() + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with ( + patch( + "src.views.minorreportview.check_parental_consent", + new_callable=AsyncMock, + return_value=False, + ), + patch("src.views.minorreportview.AsyncSessionLocal", return_value=AsyncContextManager()), + patch( + "src.views.minorreportview.get_htb_user_id_for_discord", + new_callable=AsyncMock, + return_value=None, + ), + ): + await view._recheck_callback(interaction, mock_report) + + interaction.followup.send.assert_called_once() + assert "consent still not found" in interaction.followup.send.call_args[0][0].lower() + + @pytest.mark.asyncio + async def test_recheck_callback_consent_found_no_ban(self, bot): + """Test _recheck_callback when Nexus confirms consent and user was not banned by this report.""" + view = MinorReportView(bot) + now = datetime.now(timezone.utc) + mock_report = MinorReport( + id=1, + user_id=999, + reporter_id=2, + suspected_age=15, + evidence="Evidence", + report_message_id=12345, + status="approved", + associated_ban_id=None, + created_at=now, + updated_at=now, + ) + mock_member = helpers.MockMember(id=999, name="User") + interaction = AsyncMock(spec=Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.message = AsyncMock() + interaction.message.edit = AsyncMock() + interaction.message.id = 12345 + interaction.guild = helpers.MockGuild() + interaction.user = helpers.MockMember(id=1, name="Mod") + bot.get_member_or_user = AsyncMock(return_value=mock_member) + + mock_session = MagicMock() + mock_session.get = AsyncMock(return_value=mock_report) + mock_session.commit = AsyncMock() + + class AsyncContextManager: + async def __aenter__(self): + return mock_session + + async def __aexit__(self, exc_type, exc, tb): + pass + + with ( + patch( + "src.views.minorreportview.check_parental_consent", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "src.views.minorreportview.assign_minor_role", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "src.views.minorreportview.get_ban", + new_callable=AsyncMock, + return_value=None, + ), + patch("src.views.minorreportview.AsyncSessionLocal", return_value=AsyncContextManager()), + patch( + "src.views.minorreportview.get_htb_user_id_for_discord", + new_callable=AsyncMock, + return_value=None, + ), + ): + await view._recheck_callback(interaction, mock_report) + + interaction.followup.send.assert_called_once() + assert "consent found" in interaction.followup.send.call_args[0][0].lower() + view.bot.get_member_or_user.assert_called_once() diff --git a/tests/src/webhooks/handlers/test_account.py b/tests/src/webhooks/handlers/test_account.py index e18cf7e..1c32890 100644 --- a/tests/src/webhooks/handlers/test_account.py +++ b/tests/src/webhooks/handlers/test_account.py @@ -7,7 +7,7 @@ from fastapi import HTTPException from src.webhooks.handlers.account import AccountHandler -from src.webhooks.types import WebhookBody, Platform, WebhookEvent +from src.webhooks.types import Platform, WebhookBody, WebhookEvent from tests import helpers @@ -62,7 +62,7 @@ async def test_handle_account_deleted_event(self, bot): discord_id = 123456789 account_id = 987654321 mock_member = helpers.MockMember(id=discord_id) - + body = WebhookBody( platform=Platform.ACCOUNT, event=WebhookEvent.ACCOUNT_DELETED, @@ -78,9 +78,9 @@ async def test_handle_account_deleted_event(self, bot): ): mock_settings.roles.VERIFIED = helpers.MockRole(id=99999, name="Verified") mock_member.remove_roles = AsyncMock() - + result = await handler.handle(body, bot) - + # Should succeed and return success assert result == handler.success() @@ -144,14 +144,14 @@ async def test_handle_account_linked_success(self, bot): patch.object(handler.logger, "info") as mock_log, ): mock_settings.channels.VERIFY_LOGS = 12345 - + # Mock the bot's guild structure and channel mock_channel = MagicMock() mock_channel.send = AsyncMock() mock_guild = MagicMock() mock_guild.get_channel.return_value = mock_channel bot.guilds = [mock_guild] - + result = await handler._handle_account_linked(body, bot) # Verify all method calls @@ -175,7 +175,7 @@ async def test_handle_account_linked_success(self, bot): f"Account {account_id} linked to {discord_id}", extra={"account_id": account_id, "discord_id": discord_id}, ) - + # Should return success assert result == handler.success() @@ -307,7 +307,7 @@ async def test_handle_account_unlinked_success(self, bot): mock_member.remove_roles.assert_called_once_with( mock_role, atomic=True ) - + # Should return success assert result == handler.success() diff --git a/tests/src/webhooks/handlers/test_base.py b/tests/src/webhooks/handlers/test_base.py index ba774cf..99cb2f9 100644 --- a/tests/src/webhooks/handlers/test_base.py +++ b/tests/src/webhooks/handlers/test_base.py @@ -7,7 +7,7 @@ from fastapi import HTTPException from src.webhooks.handlers.base import BaseHandler -from src.webhooks.types import WebhookBody, Platform, WebhookEvent +from src.webhooks.types import Platform, WebhookBody, WebhookEvent from tests import helpers diff --git a/tests/src/webhooks/test_handlers_init.py b/tests/src/webhooks/test_handlers_init.py index ce0b436..b1ebe27 100644 --- a/tests/src/webhooks/test_handlers_init.py +++ b/tests/src/webhooks/test_handlers_init.py @@ -1,12 +1,13 @@ -from unittest import mock from typing import Callable +from unittest import mock import pytest -from src.webhooks.handlers import handlers, can_handle, handle +from src.webhooks.handlers import can_handle, handle, handlers from src.webhooks.types import Platform, WebhookBody, WebhookEvent from tests.conftest import bot + class TestHandlersInit: def test_handler_init(self): assert handlers is not None @@ -24,7 +25,7 @@ def test_can_handle_success(self): def test_handle_success(self): with mock.patch("src.webhooks.handlers.handlers", {Platform.MAIN: lambda x, y: 1337}): assert handle(WebhookBody(platform=Platform.MAIN, event=WebhookEvent.ACCOUNT_LINKED, properties={}, traits={}), bot) == 1337 - + def test_handle_unknown_platform(self): with pytest.raises(ValueError): handle(WebhookBody(platform="UNKNOWN", event=WebhookEvent.ACCOUNT_LINKED, properties={}, traits={}), bot) @@ -32,4 +33,4 @@ def test_handle_unknown_platform(self): def test_handle_unknown_event(self): with mock.patch("src.webhooks.handlers.handlers", {Platform.MAIN: lambda x, y: 1337}): with pytest.raises(ValueError): - handle(WebhookBody(platform=Platform.MAIN, event="UNKNOWN", properties={}, traits={}), bot) \ No newline at end of file + handle(WebhookBody(platform=Platform.MAIN, event="UNKNOWN", properties={}, traits={}), bot) diff --git a/uv.lock b/uv.lock index 13f29f1..f74926d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", @@ -757,6 +757,7 @@ dependencies = [ { name = "py-cord" }, { name = "pydantic", extra = ["dotenv"] }, { name = "pymysql" }, + { name = "python-dateutil" }, { name = "sentry-sdk", extra = ["sqlalchemy"] }, { name = "slack-sdk" }, { name = "sqlalchemy", extra = ["asyncio"] }, @@ -803,6 +804,7 @@ requires-dist = [ { name = "py-cord", specifier = ">=2.4.1,<3.0.0" }, { name = "pydantic", extras = ["dotenv"], specifier = ">=1.10.7,<2.0.0" }, { name = "pymysql", specifier = ">=1.1.1,<2.0.0" }, + { name = "python-dateutil", specifier = ">=2.8.0,<3.0.0" }, { name = "sentry-sdk", extras = ["sqlalchemy"], specifier = ">=2.8.0,<3.0.0" }, { name = "slack-sdk", specifier = ">=3.27.1,<4.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.9,<3.0.0" },