diff --git a/.test.env b/.test.env index bc1a7ec..cc19e81 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 @@ -33,6 +34,7 @@ CHANNEL_BOT_LOGS=1105517088266788925 # Roles ROLE_VERIFIED=1333333333333333337 +ROLE_VERIFIED_MINOR=1281517925395733615 ROLE_BIZCTF2022=7629466241011276950 ROLE_NOAH_GANG=6706800691011276950 @@ -98,3 +100,7 @@ SLACK_FEEDBACK_WEBHOOK="https://hook.slack.com/sdfsdfsf" #Feedback Webhook JIRA_WEBHOOK="https://automation.atlassian.com/sdfsdfsf" + +# Parental Consent +PARENTAL_CONSENT_CHECK_URL="https://hackthebox.com/" +PARENTAL_CONSENT_SECRET="supersecretykey" \ 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/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/src/bot.py b/src/bot.py index f598844..efc9a14 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,18 +1,33 @@ import logging import socket +from typing import TypeVar import discord from aiohttp import AsyncResolver, ClientSession, TCPConnector from discord import ( - ApplicationContext, Cog, DiscordException, Embed, Forbidden, Guild, HTTPException, Member, NotFound, User, + ApplicationContext, + Cog, + DiscordException, + Embed, + Forbidden, + Guild, + HTTPException, + Member, + NotFound, + User, ) from discord.ext.commands import Bot as DiscordBot from discord.ext.commands import ( - CommandNotFound, CommandOnCooldown, DefaultHelpCommand, MissingAnyRole, MissingPermissions, - MissingRequiredArgument, NoPrivateMessage, UserInputError, + CommandNotFound, + CommandOnCooldown, + DefaultHelpCommand, + MissingAnyRole, + MissingPermissions, + MissingRequiredArgument, + NoPrivateMessage, + UserInputError, ) from sqlalchemy.exc import NoResultFound -from typing import TypeVar from src import trace_config from src.core import constants, settings @@ -50,7 +65,7 @@ async def on_ready(self) -> None: if self.http_session is None: logger.debug("Starting the HTTP session") self.http_session = ClientSession( - connector=TCPConnector(resolver=AsyncResolver(), family=socket.AF_INET), + connector=TCPConnector(resolver=AsyncResolver(), family=socket.AF_INET), trace_configs=[trace_config] ) diff --git a/src/cmds/automation/auto_verify.py b/src/cmds/automation/auto_verify.py index 98c538d..542d0e0 100644 --- a/src/cmds/automation/auto_verify.py +++ b/src/cmds/automation/auto_verify.py @@ -16,7 +16,7 @@ def __init__(self, bot: Bot): async def process_reverification(self, member: Member | User) -> None: """Re-verifation process for a member. - + TODO: Reimplement once it's possible to fetch link state from the HTB Account. """ raise VerificationError("Not implemented") diff --git a/src/cmds/automation/scheduled_tasks.py b/src/cmds/automation/scheduled_tasks.py index 9b763fc..035734d 100644 --- a/src/cmds/automation/scheduled_tasks.py +++ b/src/cmds/automation/scheduled_tasks.py @@ -1,15 +1,22 @@ import asyncio import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +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 +35,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 +106,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 approximate 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 + timedelta(days=365 * years) + if now < expires_at: + continue + + member: Member | None = await self.bot.get_member_or_user(guild, report.user_id) + if not 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 + timedelta(days=365 * 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..7bb3b73 --- /dev/null +++ b/src/cmds/core/flag_minor.py @@ -0,0 +1,235 @@ +"""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_account_identifier_for_discord, + 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, + ) + + account_identifier = await get_account_identifier_for_discord(target.id) + if not account_identifier: + await status_message.edit( + content="Could not find linked HTB account for this user. They must be verified first.", + ) + return + + has_consent = await check_parental_consent(account_identifier) + 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 8c33e0d..cbece41 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -64,8 +64,9 @@ class Channels(BaseSettings): BOT_COMMANDS: int SPOILER: int BOT_LOGS: int + MINOR_REVIEW: int = 0 - @validator("DEVLOG", "SR_MOD", "VERIFY_LOGS", "BOT_COMMANDS", "SPOILER", "BOT_LOGS") + @validator("DEVLOG", "SR_MOD", "VERIFY_LOGS", "BOT_COMMANDS", "SPOILER", "BOT_LOGS", "MINOR_REVIEW") def check_ids_format(cls, v: list[int]) -> list[int]: """Validate discord ids format.""" if not v: @@ -93,6 +94,7 @@ class AcademyCertificates(BaseSettings): class Roles(BaseSettings): """The roles settings.""" VERIFIED: int + VERIFIED_MINOR: int # Moderation COMMUNITY_MANAGER: int @@ -198,6 +200,9 @@ class Global(BaseSettings): SLACK_FEEDBACK_WEBHOOK: str = "" JIRA_WEBHOOK: str = "" + PARENTAL_CONSENT_CHECK_URL: str | None = None + PARENTAL_CONSENT_SECRET: str | None = None + ROOT: Path = None VERSION: str | None = None diff --git a/src/database/models/__init__.py b/src/database/models/__init__.py index 216cf22..60e6f53 100644 --- a/src/database/models/__init__.py +++ b/src/database/models/__init__.py @@ -6,5 +6,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..1275eac --- /dev/null +++ b/src/database/models/minor_report.py @@ -0,0 +1,39 @@ +# flake8: noqa: D101 +from datetime import datetime + +from sqlalchemy import 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). + """ + + 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, 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..7d235ed --- /dev/null +++ b/src/database/models/minor_review_reviewer.py @@ -0,0 +1,20 @@ +# 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. + """ + + 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 92fe22e..d24ffa8 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 @@ -156,7 +147,7 @@ async def handle_platform_ban_or_update( extra_log_data: dict | None = None, ) -> dict: """Handle platform ban by either creating new ban, updating existing ban, or taking no action. - + Args: bot: The Discord bot instance guild: The guild to ban the member from @@ -169,15 +160,15 @@ async def handle_platform_ban_or_update( log_channel_id: Channel ID for logging ban actions logger: Logger instance for recording events extra_log_data: Additional data to include in log entries - + Returns: dict with 'action' key indicating what was done: 'unbanned', 'extended', 'no_action', 'updated', 'created' """ if extra_log_data is None: extra_log_data = {} - + expires_dt = datetime.fromtimestamp(expires_timestamp) - + existing_ban = await get_ban(member) if not existing_ban: # No existing ban, create new one @@ -189,10 +180,10 @@ async def handle_platform_ban_or_update( ) logger.info(f"Created new platform ban for user {member.id} until {expires_at_str}", extra=extra_log_data) return {"action": "created"} - + # Existing ban found - determine what to do based on ban type and timing is_platform_ban = existing_ban.reason.startswith("Platform Ban") - + if is_platform_ban: # Platform bans have authority over other platform bans if expires_dt < datetime.now(): @@ -202,7 +193,7 @@ async def handle_platform_ban_or_update( await guild.get_channel(log_channel_id).send(msg) # type: ignore logger.info(msg, extra=extra_log_data) return {"action": "unbanned"} - + if existing_ban.unban_time < expires_timestamp: # Extend the existing platform ban existing_ban.unban_time = expires_timestamp @@ -228,7 +219,7 @@ async def handle_platform_ban_or_update( await update_ban(existing_ban) logger.info(f"Updated existing ban for user {member.id} until {expires_at_str}.", extra=extra_log_data) return {"action": "updated"} - + # Default case (shouldn't reach here, but for safety) logger.warning(f"Unexpected case in platform ban handling for user {member.id}", extra=extra_log_data) return {"action": "no_action"} @@ -245,7 +236,7 @@ async def ban_member_with_epoch( needs_approval: bool = True, ) -> SimpleResponse: """Ban a member from the guild until a specific epoch time. - + Args: bot: The Discord bot instance guild: The guild to ban the member from @@ -255,7 +246,7 @@ async def ban_member_with_epoch( evidence: Evidence supporting the ban author: The member issuing the ban (defaults to bot user) needs_approval: Whether the ban requires approval - + Returns: SimpleResponse with the result of the ban operation, or None if no response needed """ @@ -283,7 +274,7 @@ async def ban_member_with_epoch( if author is None: author = bot.user - + # Author should never be None at this point if author is None: raise ValueError("Author cannot be None") @@ -393,7 +384,7 @@ async def ban_member( needs_approval: bool = True, ) -> SimpleResponse: """Ban a member from the guild using a duration. - + Args: bot: The Discord bot instance guild: The guild to ban the member from @@ -403,7 +394,7 @@ async def ban_member( evidence: Evidence supporting the ban author: The member issuing the ban (defaults to bot user) needs_approval: Whether the ban requires approval - + Returns: SimpleResponse with the result of the ban operation, or None if no response needed """ @@ -516,7 +507,7 @@ async def mute_member( if author is None: author = bot.user - + # Author should never be None at this point if author is None: raise ValueError("Author cannot be None") diff --git a/src/helpers/minor_verification.py b/src/helpers/minor_verification.py new file mode 100644 index 0000000..e2cb191 --- /dev/null +++ b/src/helpers/minor_verification.py @@ -0,0 +1,194 @@ +"""Helpers for minor flagging and parental consent verification.""" + +import asyncio +import hashlib +import hmac +import json +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(account_identifier: str) -> bool: + """ + Check if parental consent form exists via Cloud Function. + + POST to PARENTAL_CONSENT_CHECK_URL with file_name set to the user's SSO UUID. + Returns True iff the function responds with HTTP 200 and a JSON body + containing {"exist": true}. Any other response is treated as no consent. + """ + if not account_identifier: + return False + + url = getattr(settings, "PARENTAL_CONSENT_CHECK_URL", None) or "" + if not url: + logger.warning("PARENTAL_CONSENT_CHECK_URL not set; consent check skipped.") + return False + + secret = getattr(settings, "PARENTAL_CONSENT_SECRET", None) or "" + if not secret: + logger.warning("PARENTAL_CONSENT_SECRET not set; consent check skipped.") + return False + + payload = {"file_name": account_identifier} + body_bytes = json.dumps(payload).encode("utf-8") + signature = hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha1).hexdigest() + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + url, + data=body_bytes, + headers={ + "Content-Type": "application/json", + "X-Signature": signature, + }, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + body = await resp.text() + logger.info( + "Parental consent check response for %s: status=%s body=%s", + account_identifier, + resp.status, + body, + ) + if resp.status != 200: + return False + + try: + data = json.loads(body) + except json.JSONDecodeError: + logger.warning( + "Parental consent check returned non-JSON body for %s", + account_identifier, + ) + return False + + return bool(data.get("exist")) + except aiohttp.ClientError as e: + logger.warning("Parental consent check request failed: %s", e) + return False + except asyncio.TimeoutError as e: + logger.warning("Parental 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_account_identifier_for_discord(discord_user_id: int) -> str | None: + """Get HTB account identifier (SSO UUID) 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 link.account_identifier + return None + + +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..25b8c78 100644 --- a/src/helpers/responses.py +++ b/src/helpers/responses.py @@ -1,6 +1,7 @@ import json from typing import Any + class SimpleResponse(object): """A simple response object.""" @@ -11,6 +12,6 @@ def __init__(self, message: str, delete_after: int | None = None, code: str | An def __str__(self): return json.dumps(dict(self), ensure_ascii=False) # type: ignore - + def __repr__(self): return self.__str__() diff --git a/src/helpers/verification.py b/src/helpers/verification.py index f67f43f..96ba1e2 100644 --- a/src/helpers/verification.py +++ b/src/helpers/verification.py @@ -1,24 +1,15 @@ import logging import traceback -from typing import Dict, List, Optional, Any, TypeVar +from typing import Any, Dict, List, Optional, TypeVar import aiohttp import discord -from discord import ( - ApplicationContext, - Forbidden, - HTTPException, - Member, - Role, - User, - Guild, -) +from discord import ApplicationContext, Forbidden, Guild, HTTPException, Member, Role, User from discord.ext.commands import GuildNotFound, MemberNotFound -from src.bot import Bot, BOT_TYPE - +from src.bot import BOT_TYPE, Bot from src.core import settings -from src.helpers.ban import BanCodes, ban_member, _send_ban_notice +from src.helpers.ban import BanCodes, _send_ban_notice, ban_member logger = logging.getLogger(__name__) @@ -102,7 +93,7 @@ async def get_user_details(labs_id: int | str) -> dict: if not labs_id: return {} - + user_profile_api_url = f"{settings.API_V4_URL}/user/profile/basic/{labs_id}" user_content_api_url = f"{settings.API_V4_URL}/user/profile/content/{labs_id}" @@ -213,7 +204,7 @@ async def _handle_banned_user(member: Member, bot: BOT_TYPE): "Please login to confirm ban details and contact HTB Support to appeal." ), "N/A", - None, + None, needs_approval=False, ) if resp.code == BanCodes.SUCCESS: @@ -283,7 +274,7 @@ async def process_account_identification( if not nickname_changed and htb_user_details.get("username"): logger.debug( f"Falling back on HTB username to set nickname for {member.name} with ID {member.id}." - ) + ) await _set_nickname(member, htb_user_details["username"]) except Exception as e: logger.error(f"Failed to process labs identification for user {member.id}: {e}") @@ -304,20 +295,20 @@ async def process_labs_identification( htb_user_details: dict, user: Optional[Member | User], bot: Bot ) -> Optional[List[Role]]: """Returns roles to assign if identification was successfully processed.""" - + # Resolve member and guild member, guild = await _resolve_member_and_guild(user, bot) - + # Get roles to remove and assign to_remove = _get_roles_to_remove(member, guild) to_assign = await _process_role_assignments(htb_user_details, guild) - + # Remove roles that will be reassigned to_remove = list(set(to_remove) - set(to_assign)) - + # Apply role changes await _apply_role_changes(member, to_remove, to_assign) - + return to_assign @@ -327,14 +318,14 @@ async def _resolve_member_and_guild( """Resolve member and guild from user object.""" if isinstance(user, Member): return user, user.guild - + if isinstance(user, User) and len(user.mutual_guilds) == 1: guild = user.mutual_guilds[0] member = await bot.get_member_or_user(guild, user.id) if not member: raise MemberNotFound(str(user.id)) return member, guild # type: ignore - + raise GuildNotFound(f"Could not identify member {user} in guild.") @@ -345,7 +336,7 @@ def _get_roles_to_remove(member: Member, guild: Guild) -> list[Role]: all_ranks = settings.role_groups.get("ALL_RANKS", []) all_positions = settings.role_groups.get("ALL_POSITIONS", []) removable_role_ids = all_ranks + all_positions - + for role in member.roles: if role.id in removable_role_ids: guild_role = guild.get_role(role.id) @@ -361,36 +352,36 @@ async def _process_role_assignments( ) -> list[Role]: """Process role assignments based on HTB user details.""" to_assign = [] - + # Process rank roles to_assign.extend(_process_rank_roles(htb_user_details.get("rank", ""), guild)) - + # Process season rank roles to_assign.extend(await _process_season_rank_roles(htb_user_details.get("id", ""), guild)) - + # Process VIP roles to_assign.extend(_process_vip_roles(htb_user_details, guild)) - + # Process HOF position roles to_assign.extend(_process_hof_position_roles(htb_user_details.get("ranking", "unranked"), guild)) - + # Process creator roles to_assign.extend(_process_creator_roles(htb_user_details.get("content", {}), guild)) - + return to_assign def _process_rank_roles(rank: str, guild: Guild) -> list[Role]: """Process rank-based role assignments.""" roles = [] - + if rank and rank not in ["Deleted", "Moderator", "Ambassador", "Admin", "Staff"]: role_id = settings.get_post_or_rank(rank) if role_id: role = guild.get_role(role_id) if role: roles.append(role) - + return roles @@ -418,7 +409,7 @@ def _process_vip_roles(htb_user_details: dict, guild: Guild) -> list[Role]: vip_role = guild.get_role(settings.roles.VIP) if vip_role: roles.append(vip_role) - + if htb_user_details.get("isDedicatedVip", False): vip_plus_role = guild.get_role(settings.roles.VIP_PLUS) if vip_plus_role: @@ -437,7 +428,7 @@ def _process_hof_position_roles(htb_user_ranking: str | int, guild: Guild) -> li if hof_position != "unranked": position = int(hof_position) pos_top = _get_position_tier(position) - + if pos_top: pos_role_id = settings.get_post_or_rank(pos_top) if pos_role_id: @@ -467,7 +458,7 @@ def _process_creator_roles(htb_user_content: dict, guild: Guild) -> list[Role]: if box_creator_role: logger.debug("Adding box creator role to user.") roles.append(box_creator_role) - + if htb_user_content.get("challenges"): challenge_creator_role = guild.get_role(settings.roles.CHALLENGE_CREATOR) if challenge_creator_role: @@ -493,7 +484,7 @@ async def _apply_role_changes( await member.remove_roles(*to_remove, atomic=True) except Exception as e: logger.error(f"Error removing roles from user {member.id}: {e}") - + try: if to_assign: await member.add_roles(*to_assign, atomic=True) diff --git a/src/views/minorreportview.py b/src/views/minorreportview.py new file mode 100644 index 0000000..f8c5581 --- /dev/null +++ b/src/views/minorreportview.py @@ -0,0 +1,444 @@ +"""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 Ban, 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_account_identifier_for_discord, + 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, + ) + # Get the ban id we just created + async with AsyncSessionLocal() as session: + stmt = select(Ban).filter(Ban.user_id == member.id).order_by(Ban.id.desc()).limit(1) + result = await session.scalars(stmt) + ban = result.first() + ban_id = ban.id if ban 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) + account_id = await get_account_identifier_for_discord(report.user_id) + if not account_id: + await interaction.followup.send( + "Could not find linked account for this user.", + ephemeral=True, + ) + return + has_consent = await check_parental_consent(account_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, + ) + await update_report_status(report.id, CONSENT_VERIFIED, interaction.user.id) + 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"" + ) + + # Persist updated status/reviewer/timestamp + if has_consent: + report.status = CONSENT_VERIFIED + report.reviewer_id = interaction.user.id + report.updated_at = datetime.now(timezone.utc) + async with AsyncSessionLocal() as session: + r = await session.get(MinorReport, report.id) + if r: + r.status = report.status + r.reviewer_id = report.reviewer_id + r.updated_at = report.updated_at + 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/handlers/mp.py b/src/webhooks/handlers/mp.py index bc8a842..e95c230 100644 --- a/src/webhooks/handlers/mp.py +++ b/src/webhooks/handlers/mp.py @@ -1,7 +1,10 @@ -from discord import Bot, Member, Role - +from datetime import datetime from typing import Literal +import discord +from discord import Bot, Member, Role +from sqlalchemy import select + from src.core import settings from src.webhooks.handlers.base import BaseHandler from src.webhooks.types import WebhookBody, WebhookEvent @@ -30,21 +33,19 @@ async def _handle_subscription_change(self, body: WebhookBody, bot: Bot) -> dict """ Handles the subscription change event. """ - discord_id, _ = self.validate_common_properties(body) + discord_id = self.validate_discord_id(body.properties.get("discord_id")) + _ = self.validate_account_id(body.properties.get("account_id")) subscription_name = self.validate_property( body.properties.get("subscription_name"), "subscription_name" ) member = await self.get_guild_member(discord_id, bot) - subscription_id = settings.get_post_or_rank(subscription_name) - if not subscription_id: + role = settings.get_post_or_rank(subscription_name) + if not role: raise ValueError(f"Invalid subscription name: {subscription_name}") - # Use the base handler's role swapping method - role_group = [int(r) for r in settings.role_groups["ALL_LABS_SUBSCRIPTIONS"]] - await self.swap_role_in_group(member, subscription_id, role_group, bot) - + await member.add_roles(bot.guilds[0].get_role(role), atomic=True) # type: ignore return self.success() async def _handle_hof_change(self, body: WebhookBody, bot: Bot) -> dict: @@ -52,7 +53,12 @@ async def _handle_hof_change(self, body: WebhookBody, bot: Bot) -> dict: Handles the HOF change event. """ self.logger.info("Handling HOF change event.") - discord_id, account_id = self.validate_common_properties(body) + discord_id = self.validate_discord_id( + self.get_property_or_trait(body, "discord_id") + ) + account_id = self.validate_account_id( + self.get_property_or_trait(body, "account_id") + ) hof_tier: Literal["1", "10"] = self.validate_property( self.get_property_or_trait(body, "hof_tier"), "hof_tier", # type: ignore @@ -119,7 +125,12 @@ async def _handle_rank_up(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the rank up event. """ - discord_id, account_id = self.validate_common_properties(body) + discord_id = self.validate_discord_id( + self.get_property_or_trait(body, "discord_id") + ) + account_id = self.validate_account_id( + self.get_property_or_trait(body, "account_id") + ) rank = self.validate_property(self.get_property_or_trait(body, "rank"), "rank") member = await self.get_guild_member(discord_id, bot) @@ -137,12 +148,38 @@ async def _handle_rank_up(self, body: WebhookBody, bot: Bot) -> dict: ) raise err - # Use the base handler's role swapping method - role_group = [int(r) for r in settings.role_groups["ALL_RANKS"]] - changes_made = await self.swap_role_in_group(member, rank_id, role_group, bot) - - if not changes_made: - return self.success() # No changes needed + rank_role = bot.guilds[0].get_role(rank_id) + rank_roles = [ + bot.guilds[0].get_role(int(r)) for r in settings.role_groups["ALL_RANKS"] + ] # All rank roles + new_role = next( + (r for r in rank_roles if r and r.id == rank_role.id), None + ) # Get passed rank as role from rank roles + old_role = next( + (r for r in member.roles if r in rank_roles), None + ) # Find existing rank role on user + + if old_role == new_role: + return self.success() + + if old_role: + await member.remove_roles(old_role, atomic=True) # Yeet the old role + + if new_role: + await member.add_roles(new_role, atomic=True) # Add the new role + + if not new_role: + # Why are you passing me BS roles? + err = ValueError(f"Cannot find role for '{rank}'") + self.logger.error( + err, + extra={ + "account_id": account_id, + "discord_id": discord_id, + "rank": rank, + }, + ) + raise err return self.success() @@ -150,13 +187,18 @@ async def _handle_season_rank(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the season rank event. """ - discord_id, account_id = self.validate_common_properties(body) + discord_id = self.validate_discord_id( + self.get_property_or_trait(body, "discord_id") + ) + account_id = self.validate_account_id( + self.get_property_or_trait(body, "account_id") + ) season_rank = self.validate_property( self.get_property_or_trait(body, "season_rank"), "season_rank" ) - season_role_id = settings.get_season(season_rank) - if not season_role_id: + season_role = settings.get_season(season_rank) + if not season_role: err = ValueError(f"Cannot find role for '{season_rank}'") self.logger.error( err, @@ -170,8 +212,30 @@ async def _handle_season_rank(self, body: WebhookBody, bot: Bot) -> dict: member = await self.get_guild_member(discord_id, bot) - # Use the base handler's role swapping method - role_group = [int(r) for r in settings.role_groups["ALL_SEASON_RANKS"]] - await self.swap_role_in_group(member, season_role_id, role_group, bot) + all_season_roles = [ + bot.guilds[0].get_role(int(r)) for r in settings.role_groups["ALL_SEASON_RANKS"] + ] + new_role = next( + (r for r in all_season_roles if r and r.id == season_role.id), None + ) + old_role = next((r for r in member.roles if r in all_season_roles), None) + + if old_role == new_role: + return self.success() + + if old_role: + await member.remove_roles(old_role, atomic=True) + + if new_role: + await member.add_roles(new_role, atomic=True) + + return self.success() + + async def _find_user_with_role(self, bot: Bot, role: Role | None) -> Member | None: + """ + Finds the user with the given role. + """ + if not role: + return None - return self.success() \ No newline at end of file + return next((m for m in role.members), None) 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..e7a29d1 --- /dev/null +++ b/tests/src/cmds/automation/test_scheduled_tasks.py @@ -0,0 +1,256 @@ +"""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 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 1 year ago, suspected_age 17 -> expires in 1 year from creation + created = now - timedelta(days=365) + 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 3 years ago, suspected_age 15 -> 3 years until 18 -> expired + created = now - timedelta(days=365 * 3) + 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) + mock_member = helpers.MockMember(id=999, name="User") + 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..869f9ae --- /dev/null +++ b/tests/src/cmds/core/test_flag_minor.py @@ -0,0 +1,336 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.cmds.core import flag_minor +from src.database.models import MinorReport, MinorReviewReviewer +from tests import helpers + + +class TestFlagMinorCog: + """Test the `FlagMinor` cog.""" + + @pytest.mark.asyncio + async def test_flag_minor_success_no_htb_account(self, ctx, bot): + """Test flagging a minor with no HTB account linked.""" + ctx.user = helpers.MockMember(id=1, name="Test Moderator") + + # Create verified role mock + verified_role = helpers.MockRole(id=123, name="Verified") + minor_role = helpers.MockRole(id=456, name="Verified Minor") + + # Mock user with verified role + user = helpers.MockMember(id=2, name="Suspected Minor") + user.roles = [verified_role] # User has verified role + + # Mock guild get_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.get_account_identifier_for_discord', new_callable=AsyncMock) as get_acct_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 + + # Mock no HTB account linked + get_link_mock.return_value = None + get_acct_mock.return_value = None + get_report_mock.return_value = None # No existing report + + # Mock session for database operations + mock_session = AsyncMock() + session_mock.return_value.__aenter__.return_value = mock_session + + # Mock review channel (command uses ctx.guild.get_channel) + 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" + ) + + # Assertions + assert ctx.respond.called + + @pytest.mark.asyncio + async def test_flag_minor_success_htb_account_no_consent(self, ctx, bot): + """Test flagging a minor with HTB account but no parental consent.""" + ctx.user = helpers.MockMember(id=1, name="Test Moderator") + + # Create verified role mock + verified_role = helpers.MockRole(id=123, name="Verified") + minor_role = helpers.MockRole(id=456, name="Verified Minor") + + # Mock user with verified role + user = helpers.MockMember(id=2, name="Suspected Minor") + user.roles = [verified_role] + + # Mock guild get_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.get_account_identifier_for_discord', new_callable=AsyncMock) as get_acct_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 = 123 + get_acct_mock.return_value = "test-account-uuid" + get_report_mock.return_value = None + consent_mock.return_value = False + + 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 + + @pytest.mark.asyncio + async def test_flag_minor_consent_already_exists(self, ctx, bot): + """Test flagging a minor when parental consent already exists.""" + ctx.user = helpers.MockMember(id=1, name="Test Moderator") + + # Create verified role mock + verified_role = helpers.MockRole(id=123, name="Verified") + minor_role = helpers.MockRole(id=456, name="Verified Minor") + + # Mock user with verified role + user = helpers.MockMember(id=2, name="Suspected Minor") + user.roles = [verified_role] + + # Mock guild get_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.get_account_identifier_for_discord', new_callable=AsyncMock) as get_acct_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.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_link_mock.return_value = 123 + get_acct_mock.return_value = "test-account-uuid" + 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 + + @pytest.mark.asyncio + async def test_flag_minor_existing_report(self, ctx, bot): + """Test flagging a minor when a report already exists.""" + ctx.user = helpers.MockMember(id=1, name="Test Moderator") + + # Create verified role mock + verified_role = helpers.MockRole(id=123, name="Verified") + minor_role = helpers.MockRole(id=456, name="Verified Minor") + + # Mock user with verified role + user = helpers.MockMember(id=2, name="Suspected Minor") + user.roles = [verified_role] + + # Mock guild get_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" + ) + + 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.get_account_identifier_for_discord', new_callable=AsyncMock) as get_acct_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.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 + get_acct_mock.return_value = None + 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) + + # Test age too low - should respond with error + await cog.flag_minor.callback( + cog, ctx, user, 0, "Evidence" + ) + # The command validates age and returns early with error + assert ctx.respond.called or ctx.followup.send.called + + # Test age too high + ctx.reset_mock() + await cog.flag_minor.callback( + cog, ctx, user, 18, "Evidence" + ) + # The command validates age and returns early with error + assert ctx.respond.called or ctx.followup.send.called + + @pytest.mark.asyncio + async def test_flag_minor_no_account_identifier(self, ctx, bot): + """Test early return when user has no linked HTB account.""" + 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) as get_link_mock, + patch('src.cmds.core.flag_minor.get_account_identifier_for_discord', new_callable=AsyncMock) as get_acct_mock, + patch('src.cmds.core.flag_minor.check_parental_consent', new_callable=AsyncMock), + 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 + get_link_mock.return_value = None + get_acct_mock.return_value = None # No linked account + + cog = flag_minor.FlagMinorCog(bot) + await cog.flag_minor.callback(cog, ctx, user, 15, "Evidence") + + assert ctx.respond.called + status_edit.assert_called_once() + call_args = status_edit.call_args[1] + assert "Could not find linked HTB account" in call_args.get("content", "") + + @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.get_account_identifier_for_discord', new_callable=AsyncMock) as get_acct_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), + 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 # Not configured + get_acct_mock.return_value = "some-uuid" + 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) # Channel not found + 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.get_account_identifier_for_discord', new_callable=AsyncMock) as get_acct_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), + 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_acct_mock.return_value = "some-uuid" + 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_ban.py b/tests/src/helpers/test_ban.py index 1ae616c..0fb1c39 100644 --- a/tests/src/helpers/test_ban.py +++ b/tests/src/helpers/test_ban.py @@ -4,7 +4,6 @@ import pytest from discord import Forbidden, HTTPException -from datetime import datetime, timezone from src.helpers.ban import _check_member, _dm_banned_member, ban_member from src.helpers.responses import SimpleResponse diff --git a/tests/src/helpers/test_minor_verification.py b/tests/src/helpers/test_minor_verification.py new file mode 100644 index 0000000..ec87f31 --- /dev/null +++ b/tests/src/helpers/test_minor_verification.py @@ -0,0 +1,443 @@ +import hashlib +import hmac +import json +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiohttp import ClientSession, ClientTimeout + +from src.helpers.minor_verification import ( + APPROVED, + CONSENT_VERIFIED, + DENIED, + PENDING, + assign_minor_role, + calculate_ban_duration, + check_parental_consent, + get_account_identifier_for_discord, + 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 parental consent check when consent exists.""" + account_id = "test-account-123" + + mock_response = MockResponse( + status=200, + text_data='{"exist": true}', # JSON string, not empty + json_data={"exist": True} + ) + + with ( + patch('src.helpers.minor_verification.settings') as mock_settings, + patch.object(ClientSession, 'post', return_value=mock_response) + ): + mock_settings.PARENTAL_CONSENT_CHECK_URL = "http://example.com/check" + mock_settings.PARENTAL_CONSENT_SECRET = "test_secret" + + result = await check_parental_consent(account_id) + + assert result is True + + @pytest.mark.asyncio + async def test_check_parental_consent_not_exists(self): + """Test parental consent check when consent doesn't exist.""" + account_id = "test-account-123" + + mock_response = MockResponse( + status=200, + json_data={"exist": False} + ) + + with ( + patch('src.helpers.minor_verification.settings') as mock_settings, + patch.object(ClientSession, 'post', return_value=mock_response) + ): + mock_settings.PARENTAL_CONSENT_CHECK_URL = "http://example.com/check" + mock_settings.PARENTAL_CONSENT_SECRET = "test_secret" + + result = await check_parental_consent(account_id) + + assert result is False + + @pytest.mark.asyncio + async def test_check_parental_consent_missing_config(self): + """Test consent check with missing configuration.""" + account_id = "test-account-123" + + with patch('src.helpers.minor_verification.settings') as mock_settings: + mock_settings.PARENTAL_CONSENT_CHECK_URL = None + mock_settings.PARENTAL_CONSENT_SECRET = "test_secret" + + result = await check_parental_consent(account_id) + + assert result is False + + @pytest.mark.asyncio + async def test_check_parental_consent_empty_identifier(self): + """Test consent check with empty account identifier.""" + result = await check_parental_consent("") + assert result is False + + result = await check_parental_consent(None) + assert result is False + + @pytest.mark.asyncio + async def test_check_parental_consent_http_error(self): + """Test consent check with HTTP error.""" + account_id = "test-account-123" + + 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.PARENTAL_CONSENT_CHECK_URL = "http://example.com/check" + mock_settings.PARENTAL_CONSENT_SECRET = "test_secret" + + result = await check_parental_consent(account_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..24c04bc --- /dev/null +++ b/tests/src/views/test_minorreportview.py @@ -0,0 +1,431 @@ +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_no_account(self, bot): + """Test _recheck_callback when user has no linked account.""" + view = MinorReportView(bot) + mock_report = MinorReport( + id=1, + user_id=999, + reporter_id=2, + suspected_age=15, + evidence="Evidence", + report_message_id=12345, + status="approved", + ) + interaction = AsyncMock(spec=Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.message = helpers.MockMessage(id=12345) + interaction.guild = helpers.MockGuild() + + with patch( + 'src.views.minorreportview.get_account_identifier_for_discord', + new_callable=AsyncMock, + return_value=None, + ): + await view._recheck_callback(interaction, mock_report) + + interaction.response.defer.assert_called_once_with(ephemeral=True) + interaction.followup.send.assert_called_once() + assert "linked account" in interaction.followup.send.call_args[0][0].lower() + + @pytest.mark.asyncio + async def test_recheck_callback_consent_not_found(self, bot): + """Test _recheck_callback when consent is not found.""" + 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.get_account_identifier_for_discord', + new_callable=AsyncMock, + return_value="uuid-123", + ), + 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 consent is found 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) + + # session.get returns a report-like object for build_minor_report_embed + 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.get_account_identifier_for_discord', + new_callable=AsyncMock, + return_value="uuid-123", + ), + 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.update_report_status', + new_callable=AsyncMock, + ), + 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_academy.py b/tests/src/webhooks/handlers/test_academy.py index fc34c63..51fb013 100644 --- a/tests/src/webhooks/handlers/test_academy.py +++ b/tests/src/webhooks/handlers/test_academy.py @@ -1,10 +1,13 @@ +from unittest.mock import AsyncMock, patch + import pytest -from unittest.mock import AsyncMock, patch, MagicMock +from fastapi import HTTPException from src.webhooks.handlers.academy import AcademyHandler -from src.webhooks.types import WebhookBody, Platform, WebhookEvent +from src.webhooks.types import Platform, WebhookBody, WebhookEvent from tests import helpers + class TestAcademyHandler: @pytest.mark.asyncio async def test_handle_certificate_awarded_success(self, bot): @@ -25,7 +28,8 @@ async def test_handle_certificate_awarded_success(self, bot): traits={}, ) with ( - patch.object(handler, "validate_common_properties", return_value=(discord_id, account_id)), + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), patch.object(handler, "validate_property", return_value=certificate_id), patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), patch("src.webhooks.handlers.academy.settings") as mock_settings, @@ -58,7 +62,8 @@ async def test_handle_certificate_awarded_no_role(self, bot): traits={}, ) with ( - patch.object(handler, "validate_common_properties", return_value=(discord_id, account_id)), + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), patch.object(handler, "validate_property", return_value=certificate_id), patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), patch("src.webhooks.handlers.academy.settings") as mock_settings, @@ -88,7 +93,8 @@ async def test_handle_certificate_awarded_add_roles_error(self, bot): traits={}, ) with ( - patch.object(handler, "validate_common_properties", return_value=(discord_id, account_id)), + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), patch.object(handler, "validate_property", return_value=certificate_id), patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), patch("src.webhooks.handlers.academy.settings") as mock_settings, @@ -102,131 +108,6 @@ async def test_handle_certificate_awarded_add_roles_error(self, bot): await handler._handle_certificate_awarded(body, bot) mock_log.assert_called() - @pytest.mark.asyncio - async def test_handle_subscription_change_success(self, bot): - handler = AcademyHandler() - discord_id = 123456789 - account_id = 987654321 - plan = "Silver Annual" - mock_member = helpers.MockMember(id=discord_id) - mock_member.roles = [] - mock_member.add_roles = AsyncMock() - mock_member.remove_roles = AsyncMock() - body = WebhookBody( - platform=Platform.ACADEMY, - event=WebhookEvent.SUBSCRIPTION_CHANGE, - properties={ - "discord_id": discord_id, - "account_id": account_id, - "plan": plan, - }, - traits={}, - ) - mock_role = MagicMock() - mock_role.id = 555 - with ( - patch.object(handler, "validate_common_properties", return_value=(discord_id, account_id)), - patch.object(handler, "validate_property", return_value=plan), - patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), - patch("src.webhooks.handlers.academy.settings") as mock_settings, - patch.object(handler.logger, "info") as mock_log, - ): - mock_settings.get_post_or_rank.return_value = 555 - mock_settings.role_groups = {"ALL_ACADEMY_SUBSCRIPTIONS": [555, 666, 777]} - mock_guild = helpers.MockGuild(id=1) - mock_guild.get_role.return_value = mock_role - bot.guilds = [mock_guild] - result = await handler._handle_subscription_change(body, bot) - mock_member.add_roles.assert_awaited_once() - mock_log.assert_called() - assert result == handler.success() - - @pytest.mark.asyncio - async def test_handle_subscription_change_no_role(self, bot): - handler = AcademyHandler() - discord_id = 123456789 - account_id = 987654321 - plan = "invalid_plan" - mock_member = helpers.MockMember(id=discord_id) - body = WebhookBody( - platform=Platform.ACADEMY, - event=WebhookEvent.SUBSCRIPTION_CHANGE, - properties={ - "discord_id": discord_id, - "account_id": account_id, - "plan": plan, - }, - traits={}, - ) - with ( - patch.object(handler, "validate_common_properties", return_value=(discord_id, account_id)), - patch.object(handler, "validate_property", return_value=plan), - patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), - patch("src.webhooks.handlers.academy.settings") as mock_settings, - patch.object(handler.logger, "warning") as mock_log, - ): - mock_settings.get_post_or_rank.return_value = None - result = await handler._handle_subscription_change(body, bot) - mock_log.assert_called() - assert result == handler.fail() - - @pytest.mark.asyncio - async def test_handle_subscription_change_role_swap(self, bot): - """Test that subscription change properly swaps roles""" - handler = AcademyHandler() - discord_id = 123456789 - account_id = 987654321 - plan = "professional" - - # Mock member with an existing academy subscription role - old_role = MagicMock() - old_role.id = 666 - mock_member = helpers.MockMember(id=discord_id) - mock_member.roles = [old_role] - mock_member.add_roles = AsyncMock() - mock_member.remove_roles = AsyncMock() - - body = WebhookBody( - platform=Platform.ACADEMY, - event=WebhookEvent.SUBSCRIPTION_CHANGE, - properties={ - "discord_id": discord_id, - "account_id": account_id, - "plan": plan, - }, - traits={}, - ) - - new_role = MagicMock() - new_role.id = 555 - - with ( - patch.object(handler, "validate_common_properties", return_value=(discord_id, account_id)), - patch.object(handler, "validate_property", return_value=plan), - patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), - patch("src.webhooks.handlers.academy.settings") as mock_settings, - ): - mock_settings.get_post_or_rank.return_value = 555 - mock_settings.role_groups = {"ALL_ACADEMY_SUBSCRIPTIONS": [555, 666, 777]} - mock_guild = helpers.MockGuild(id=1) - - def get_role_mock(role_id): - if role_id == 555: - return new_role - elif role_id == 666: - return old_role - return None - - mock_guild.get_role.side_effect = get_role_mock - bot.guilds = [mock_guild] - - result = await handler._handle_subscription_change(body, bot) - - # Verify old role was removed and new role was added - mock_member.remove_roles.assert_awaited_once_with(old_role, atomic=True) - mock_member.add_roles.assert_awaited_once_with(new_role, atomic=True) - assert result == handler.success() - @pytest.mark.asyncio async def test_handle_invalid_event(self, bot): handler = AcademyHandler() @@ -237,4 +118,4 @@ async def test_handle_invalid_event(self, bot): traits={}, ) with pytest.raises(ValueError, match="Invalid event"): - await handler.handle(body, bot) \ No newline at end of file + await handler.handle(body, bot) 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/handlers/test_mp.py b/tests/src/webhooks/handlers/test_mp.py index 2dad297..a7acdd4 100644 --- a/tests/src/webhooks/handlers/test_mp.py +++ b/tests/src/webhooks/handlers/test_mp.py @@ -1,11 +1,13 @@ +from unittest.mock import AsyncMock, MagicMock, patch + import pytest -from unittest.mock import AsyncMock, patch, MagicMock from fastapi import HTTPException from src.webhooks.handlers.mp import MPHandler -from src.webhooks.types import WebhookBody, Platform, WebhookEvent +from src.webhooks.types import Platform, WebhookBody, WebhookEvent from tests import helpers + class TestMPHandler: @pytest.mark.asyncio async def test_handle_invalid_event(self, bot): @@ -219,4 +221,4 @@ async def test_handle_rank_up_invalid_role(self, bot): mock_guild.get_role.return_value = None bot.guilds = [mock_guild] with pytest.raises(ValueError, match="Cannot find role for"): - await handler._handle_rank_up(body, bot) \ No newline at end of file + await handler._handle_rank_up(body, bot) 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)