diff --git a/.test.env b/.test.env index c0731ef..334e21e 100644 --- a/.test.env +++ b/.test.env @@ -63,6 +63,7 @@ ROLE_VIP_PLUS=9583772910112769506 ROLE_CHALLENGE_CREATOR=8215461011276950716 ROLE_BOX_CREATOR=8215471011276950716 +ROLE_SHERLOCK_CREATOR=1384037506349011044 ROLE_RANK_ONE=7419955631011276950 ROLE_RANK_TEN=7419955611011276950 diff --git a/src/core/config.py b/src/core/config.py index 1cbd491..1f5494f 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -119,6 +119,7 @@ class Roles(BaseSettings): # Content Creation CHALLENGE_CREATOR: int BOX_CREATOR: int + SHERLOCK_CREATOR: int # Positions RANK_ONE: int @@ -247,6 +248,7 @@ def get_post_or_rank(self, what: str) -> Optional[int]: "dedivip": self.roles.VIP_PLUS, "Challenge Creator": self.roles.CHALLENGE_CREATOR, "Box Creator": self.roles.BOX_CREATOR, + "Sherlock Creator": self.roles.SHERLOCK_CREATOR, }.get(what) def get_season(self, what: str): @@ -346,6 +348,7 @@ def load_settings(env_file: str | None = None): "ALL_CREATORS": [ global_settings.roles.BOX_CREATOR, global_settings.roles.CHALLENGE_CREATOR, + global_settings.roles.SHERLOCK_CREATOR, ], "ALL_POSITIONS": [ global_settings.roles.RANK_ONE, diff --git a/src/helpers/verification.py b/src/helpers/verification.py index 5a7de9b..f67f43f 100644 --- a/src/helpers/verification.py +++ b/src/helpers/verification.py @@ -473,6 +473,12 @@ def _process_creator_roles(htb_user_content: dict, guild: Guild) -> list[Role]: if challenge_creator_role: logger.debug("Adding challenge creator role to user.") roles.append(challenge_creator_role) + + if htb_user_content.get("sherlocks"): + sherlock_creator_role = guild.get_role(settings.roles.SHERLOCK_CREATOR) + if sherlock_creator_role: + logger.debug("Adding sherlock creator role to user.") + roles.append(sherlock_creator_role) except Exception as e: logger.error(f"Error processing creator roles: {e}") return roles diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index d941f8b..6df8573 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -31,12 +31,8 @@ async def _handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account linked event. """ - 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") - ) + 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")) member = await self.get_guild_member(discord_id, bot) await process_account_identification( @@ -53,9 +49,7 @@ async def _handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: f"Account linked: {account_id} -> ({member.mention} ({member.id})", ) else: - self.logger.warning( - f"Verify logs channel {settings.channels.VERIFY_LOGS} not found" - ) + self.logger.warning(f"Verify logs channel {settings.channels.VERIFY_LOGS} not found") except Exception as e: self.logger.error(f"Failed to send verification log: {e}") # Don't raise - this is not critical @@ -71,17 +65,14 @@ async def _handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account unlinked event. """ - 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") - ) + 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")) member = await self.get_guild_member(discord_id, bot) await member.remove_roles( - bot.guilds[0].get_role(settings.roles.VERIFIED), atomic=True # type: ignore + bot.guilds[0].get_role(settings.roles.VERIFIED), + atomic=True, # type: ignore ) # type: ignore return self.success() @@ -102,15 +93,9 @@ async def _handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account banned event. """ - 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") - ) - expires_at = self.validate_property( - self.get_property_or_trait(body, "expires_at"), "expires_at" - ) + 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")) + expires_at = self.validate_property(self.get_property_or_trait(body, "expires_at"), "expires_at") reason = body.properties.get("reason") notes = body.properties.get("notes") created_by = body.properties.get("created_by") @@ -120,9 +105,7 @@ async def _handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: member = await self.get_guild_member(discord_id, bot) if not member: - self.logger.warning( - f"Cannot ban user {discord_id}- not found in guild", extra=extra - ) + self.logger.warning(f"Cannot ban user {discord_id}- not found in guild", extra=extra) return self.fail() # Use the generic ban helper to handle all the complex logic @@ -140,9 +123,7 @@ async def _handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: extra_log_data=extra, ) - self.logger.debug( - f"Platform ban handling result: {result['action']}", extra=extra - ) + self.logger.debug(f"Platform ban handling result: {result['action']}", extra=extra) return self.success() @@ -162,7 +143,8 @@ async def _handle_account_deleted(self, body: WebhookBody, bot: Bot) -> dict: return self.fail() await member.remove_roles( - bot.guilds[0].get_role(settings.roles.VERIFIED), atomic=True # type: ignore + bot.guilds[0].get_role(settings.roles.VERIFIED), + atomic=True, # type: ignore ) # type: ignore return self.success() diff --git a/tests/src/core/test_config.py b/tests/src/core/test_config.py new file mode 100644 index 0000000..567e0e4 --- /dev/null +++ b/tests/src/core/test_config.py @@ -0,0 +1,40 @@ +import unittest + +from src.core import settings + + +class TestConfig(unittest.TestCase): + def test_sherlock_creator_role_in_all_creators(self): + """Test that SHERLOCK_CREATOR role is included in ALL_CREATORS group.""" + all_creators = settings.role_groups.get("ALL_CREATORS", []) + self.assertIn(settings.roles.SHERLOCK_CREATOR, all_creators) + self.assertIn(settings.roles.CHALLENGE_CREATOR, all_creators) + self.assertIn(settings.roles.BOX_CREATOR, all_creators) + + def test_get_post_or_rank_sherlock_creator(self): + """Test that get_post_or_rank returns correct role for Sherlock Creator.""" + result = settings.get_post_or_rank("Sherlock Creator") + self.assertEqual(result, settings.roles.SHERLOCK_CREATOR) + + def test_get_post_or_rank_other_creators(self): + """Test that get_post_or_rank works for all creator types.""" + test_cases = [ + ("Challenge Creator", settings.roles.CHALLENGE_CREATOR), + ("Box Creator", settings.roles.BOX_CREATOR), + ("Sherlock Creator", settings.roles.SHERLOCK_CREATOR), + ] + + for role_name, expected_role in test_cases: + with self.subTest(role_name=role_name): + result = settings.get_post_or_rank(role_name) + self.assertEqual(result, expected_role) + + def test_get_post_or_rank_invalid_role(self): + """Test that get_post_or_rank returns None for invalid role.""" + result = settings.get_post_or_rank("Invalid Role") + self.assertIsNone(result) + + def test_sherlock_creator_role_configured(self): + """Test that SHERLOCK_CREATOR role is properly configured.""" + self.assertIsNotNone(settings.roles.SHERLOCK_CREATOR) + self.assertIsInstance(settings.roles.SHERLOCK_CREATOR, int) diff --git a/tests/src/helpers/test_verification.py b/tests/src/helpers/test_verification.py index 1c949da..225bd2d 100644 --- a/tests/src/helpers/test_verification.py +++ b/tests/src/helpers/test_verification.py @@ -1,73 +1,217 @@ import unittest +from unittest.mock import AsyncMock, MagicMock import aioresponses +import discord import pytest from src.core import settings -from src.helpers.verification import get_user_details +from src.helpers.verification import get_user_details, process_labs_identification class TestGetUserDetails(unittest.IsolatedAsyncioTestCase): - @pytest.mark.asyncio async def test_get_user_details_success(self): - account_identifier = "some_identifier" + labs_id = "12345" with aioresponses.aioresponses() as m: # Mock the profile API call m.get( - f"{settings.API_V4_URL}/user/profile/basic/{account_identifier}", + f"{settings.API_V4_URL}/user/profile/basic/{labs_id}", status=200, - payload={"profile": {"some_key": "some_value"}}, + payload={"profile": {"username": "test_user", "rank": "Hacker"}}, ) # Mock the content API call m.get( - f"{settings.API_V4_URL}/user/profile/content/{account_identifier}", + f"{settings.API_V4_URL}/user/profile/content/{labs_id}", status=200, - payload={"profile": {"content": {"content_key": "content_value"}}}, + payload={"profile": {"content": {"sherlocks": True, "challenges": False}}}, ) - result = await get_user_details(account_identifier) - expected = { - "some_key": "some_value", - "content": {"content_key": "content_value"} - } + result = await get_user_details(labs_id) + expected = {"username": "test_user", "rank": "Hacker", "content": {"sherlocks": True, "challenges": False}} self.assertEqual(result, expected) @pytest.mark.asyncio async def test_get_user_details_404(self): - account_identifier = "some_identifier" + labs_id = "12345" with aioresponses.aioresponses() as m: - # Mock the profile API call with404 + # Mock the profile API call with 404 m.get( - f"{settings.API_V4_URL}/user/profile/basic/{account_identifier}", + f"{settings.API_V4_URL}/user/profile/basic/{labs_id}", status=404, ) - # Mock the content API call with404 + # Mock the content API call - won't be reached due to 404 above m.get( - f"{settings.API_V4_URL}/user/profile/content/{account_identifier}", - status=404, + f"{settings.API_V4_URL}/user/profile/content/{labs_id}", + status=200, + payload={"profile": {"content": {}}}, ) - result = await get_user_details(account_identifier) + result = await get_user_details(labs_id) + # Function returns empty dict with content when basic profile fails self.assertEqual(result, {"content": {}}) @pytest.mark.asyncio async def test_get_user_details_other_status(self): - account_identifier = "some_identifier" + labs_id = "12345" with aioresponses.aioresponses() as m: - # Mock the profile API call with500 + # Mock the profile API call with 500 error m.get( - f"{settings.API_V4_URL}/user/profile/basic/{account_identifier}", + f"{settings.API_V4_URL}/user/profile/basic/{labs_id}", status=500, ) - # Mock the content API call with500 + # Mock the content API call - won't be reached due to 500 above m.get( - f"{settings.API_V4_URL}/user/profile/content/{account_identifier}", - status=500, + f"{settings.API_V4_URL}/user/profile/content/{labs_id}", + status=200, + payload={"profile": {"content": {}}}, ) - result = await get_user_details(account_identifier) + result = await get_user_details(labs_id) + # Function returns empty dict with content when basic profile fails self.assertEqual(result, {"content": {}}) + + +class TestProcessLabsIdentification(unittest.IsolatedAsyncioTestCase): + def setUp(self): + """Set up test fixtures.""" + self.bot = MagicMock() + self.guild = MagicMock() + self.member = MagicMock(spec=discord.Member) + self.member.guild = self.guild + self.user = MagicMock(spec=discord.User) + + # Mock roles + self.sherlock_role = MagicMock(spec=discord.Role) + self.challenge_role = MagicMock(spec=discord.Role) + self.box_role = MagicMock(spec=discord.Role) + self.rank_role = MagicMock(spec=discord.Role) + + # Set up guild.get_role to return appropriate roles + self.guild.get_role.side_effect = lambda role_id: { + settings.roles.SHERLOCK_CREATOR: self.sherlock_role, + settings.roles.CHALLENGE_CREATOR: self.challenge_role, + settings.roles.BOX_CREATOR: self.box_role, + settings.roles.HACKER: self.rank_role, + }.get(role_id) + + @pytest.mark.asyncio + async def test_process_identification_with_sherlocks(self): + """Test that Sherlock creator role is assigned when user has sherlocks.""" + htb_user_details = { + "id": "12345", + "username": "test_user", + "rank": "Hacker", + "content": { + "sherlocks": True, + "challenges": False, + "machines": False, + }, + "isVip": False, + "isDedicatedVip": False, + "ranking": "unranked", + } + + # Mock the member edit method + self.member.edit = AsyncMock() + self.member.nick = "test_user" # Same as username, so no edit needed + + result = await process_labs_identification(htb_user_details, self.member, self.bot) + + # Verify that the Sherlock creator role is in the result + self.assertIn(self.sherlock_role, result) + self.guild.get_role.assert_any_call(settings.roles.SHERLOCK_CREATOR) + + @pytest.mark.asyncio + async def test_process_identification_with_challenges_and_sherlocks(self): + """Test that both Challenge and Sherlock creator roles are assigned.""" + htb_user_details = { + "id": "12345", + "username": "test_user", + "rank": "Hacker", + "content": { + "sherlocks": True, + "challenges": True, + "machines": False, + }, + "isVip": False, + "isDedicatedVip": False, + "ranking": "unranked", + } + + # Mock the member edit method + self.member.edit = AsyncMock() + self.member.nick = "test_user" + + result = await process_labs_identification(htb_user_details, self.member, self.bot) + + # Verify that both roles are in the result + self.assertIn(self.sherlock_role, result) + self.assertIn(self.challenge_role, result) + self.guild.get_role.assert_any_call(settings.roles.SHERLOCK_CREATOR) + self.guild.get_role.assert_any_call(settings.roles.CHALLENGE_CREATOR) + + @pytest.mark.asyncio + async def test_process_identification_without_sherlocks(self): + """Test that Sherlock creator role is not assigned when user has no sherlocks.""" + htb_user_details = { + "user_id": "12345", + "user_name": "test_user", + "rank": "Hacker", + "content": { + "sherlocks": False, + "challenges": False, + "machines": False, + }, + "isVip": False, + "isDedicatedVip": False, + "ranking": "unranked", + } + + # Mock the member edit method + self.member.edit = AsyncMock() + self.member.nick = "test_user" + + result = await process_labs_identification(htb_user_details, self.member, self.bot) + + # Verify that the Sherlock creator role is not in the result + self.assertNotIn(self.sherlock_role, result) + # Verify get_role was not called for Sherlock creator + calls = [call[0][0] for call in self.guild.get_role.call_args_list] + self.assertNotIn(settings.roles.SHERLOCK_CREATOR, calls) + + @pytest.mark.asyncio + async def test_process_identification_all_creator_roles(self): + """Test that all creator roles are assigned when user qualifies for all.""" + htb_user_details = { + "id": "12345", + "username": "test_user", + "rank": "Hacker", + "content": { + "sherlocks": True, + "challenges": True, + "machines": True, + }, + "isVip": False, + "isDedicatedVip": False, + "ranking": "unranked", + } + + # Mock the member edit method + self.member.edit = AsyncMock() + self.member.nick = "test_user" + + result = await process_labs_identification(htb_user_details, self.member, self.bot) + + # Verify that all three creator roles are in the result + self.assertIn(self.sherlock_role, result) + self.assertIn(self.challenge_role, result) + self.assertIn(self.box_role, result) + + # Verify all get_role calls were made + self.guild.get_role.assert_any_call(settings.roles.SHERLOCK_CREATOR) + self.guild.get_role.assert_any_call(settings.roles.CHALLENGE_CREATOR) + self.guild.get_role.assert_any_call(settings.roles.BOX_CREATOR)