diff --git a/server/src/QRServer/config.py b/server/src/QRServer/config.py index e40c5ae9..cf324f14 100644 --- a/server/src/QRServer/config.py +++ b/server/src/QRServer/config.py @@ -197,7 +197,7 @@ def __init__(self): config=self, name='discord.bot.channel_user_notifications.id', cli_args=[], - description='Discord Channel ID for user notifications such as registration, claiming of account or bans', + description='Discord Channel ID for user notifications such as registration or claiming of account', default_value='') self.discord_bot_max_aliases = ConfigKey( config=self, @@ -205,6 +205,12 @@ def __init__(self): cli_args=[], description='Maximum number of aliases per user', default_value=1) + self.discord_bot_channel_ban_notifications_id = ConfigKey( + config=self, + name='discord.bot.channel_ban_notifications.id', + cli_args=[], + description='Discord Channel ID for sending notifications about user bans/unbans', + default_value='') def get_key(self, name: str): by_name = self.keys_by_name() diff --git a/server/src/QRServer/db/connector.py b/server/src/QRServer/db/connector.py index 81cc546b..7931cf1f 100644 --- a/server/src/QRServer/db/connector.py +++ b/server/src/QRServer/db/connector.py @@ -36,7 +36,8 @@ async def close(self): async def get_user(self, user_id: str) -> DbUser | None: c = await self.conn.cursor() await c.execute( - "select id, username, password, created_at, discord_user_id from users where id = ?", ( + "select id, username, password, created_at, discord_user_id," + " is_banned, banned_at, banned_by_dc_id, ban_reason from users where id = ?", ( user_id, )) row = await c.fetchone() @@ -48,12 +49,17 @@ async def get_user(self, user_id: str) -> DbUser | None: password=row[2], created_at=row[3], discord_user_id=row[4], + is_banned=bool(row[5]), + banned_at=datetime.fromtimestamp(row[6], tz=timezone.utc) if row[6] else None, + banned_by_dc_id=row[7], + ban_reason=row[8], ) async def get_user_by_username(self, username) -> DbUser | None: c = await self.conn.cursor() await c.execute( - "select id, username, password, created_at, discord_user_id from users where username = ?", ( + "select id, username, password, created_at, discord_user_id," + " is_banned, banned_at, banned_by_dc_id, ban_reason from users where username = ?", ( username, )) row = await c.fetchone() @@ -65,12 +71,17 @@ async def get_user_by_username(self, username) -> DbUser | None: password=row[2], created_at=row[3], discord_user_id=row[4], + is_banned=bool(row[5]), + banned_at=datetime.fromtimestamp(row[6], tz=timezone.utc) if row[6] else None, + banned_by_dc_id=row[7], + ban_reason=row[8], ) async def get_users_by_discord_id(self, discord_user_id: str) -> list[DbUser]: c = await self.conn.cursor() await c.execute( - "select id, username, password, created_at, discord_user_id from users where discord_user_id = ?", ( + "select id, username, password, created_at, discord_user_id," + "is_banned, banned_at, banned_by_dc_id, ban_reason from users where discord_user_id = ?", ( discord_user_id, )) rows = await c.fetchall() @@ -85,7 +96,11 @@ async def get_users_by_discord_id(self, discord_user_id: str) -> list[DbUser]: username=row[1], password=row[2], created_at=row[3], - discord_user_id=row[4] + discord_user_id=row[4], + is_banned=bool(row[5]), + banned_at=datetime.fromtimestamp(row[6], tz=timezone.utc) if row[6] else None, + banned_by_dc_id=row[7], + ban_reason=row[8], ) ) return result @@ -123,8 +138,10 @@ async def authenticate_user(self, username: str, password: bytes | None, auto_cr ) await self.conn.commit() - await c.execute("select id, username, password, created_at, discord_user_id from users where username = ?", - (username,)) + await c.execute( + "select id, username, password, created_at, discord_user_id," + "is_banned, banned_at, banned_by_dc_id, ban_reason from users where username = ?", + (username,)) row = await c.fetchone() if row is None: @@ -136,6 +153,10 @@ async def authenticate_user(self, username: str, password: bytes | None, auto_cr password=row[2], created_at=row[3], discord_user_id=row[4], + is_banned=bool(row[5]), + banned_at=datetime.fromtimestamp(row[6], tz=timezone.utc) if row[6] else None, + banned_by_dc_id=row[7], + ban_reason=row[8], ) if not db_user.is_guest and verify_password and not password_verify(password, db_user.password): @@ -143,7 +164,11 @@ async def authenticate_user(self, username: str, password: bytes | None, auto_cr return db_user - async def change_user_password(self, user_id: str, password: bytes | None): + async def change_user_password(self, user_id: str, password: bytes | None) -> bool: + """ + Returns: + bool: True if successfully changed password + """ c = await self.conn.cursor() await c.execute( "update users set password = ? where id = ?", ( @@ -151,6 +176,7 @@ async def change_user_password(self, user_id: str, password: bytes | None): user_id )) await self.conn.commit() + return bool(c.rowcount) async def claim_member(self, user_id: str, password: bytes | None, discord_user_id: str): c = await self.conn.cursor() @@ -719,6 +745,50 @@ async def add_duel_match(self, tournament_id: str, duel_idx: int, match_id: str) await self.conn.commit() return bool(c.rowcount) + async def ban_user(self, user_id: str, banned_by_dc_id: str, ban_reason: str) -> bool: + """ + Returns: + bool: True if succesfully banned + """ + + c = await self.conn.cursor() + await c.execute( + "update users" + " set is_banned = ?," + " banned_at = ?," + " banned_by_dc_id = ?," + " ban_reason = ?" + " where id = ? and is_banned is null", + ( + True, + int(datetime.now(timezone.utc).timestamp()), + banned_by_dc_id, + ban_reason, + user_id, + ) + ) + await self.conn.commit() + return bool(c.rowcount) + + async def unban_user(self, user_id: str) -> bool: + """ + Returns: + bool: True if succesfully unbanned + """ + + c = await self.conn.cursor() + await c.execute( + "update users" + " set is_banned = null," + " banned_at = null," + " banned_by_dc_id = null," + " ban_reason = null" + " where id = ? and is_banned is not null", + (user_id, ) + ) + await self.conn.commit() + return bool(c.rowcount) + async def create_connector(config) -> DbConnector: data_dir = os.path.abspath(config.data_dir.get()) diff --git a/server/src/QRServer/db/migrations.py b/server/src/QRServer/db/migrations.py index 767e611c..82b1a037 100644 --- a/server/src/QRServer/db/migrations.py +++ b/server/src/QRServer/db/migrations.py @@ -27,6 +27,7 @@ async def execute_migrations(c, config: Config, max_version=None): _migration_upgrade_to_v6, _migration_upgrade_to_v7, _migration_upgrade_to_v8, + _migration_upgrade_to_v9, ] for i in range(max_version if max_version and max_version <= len(migrations) else len(migrations)): @@ -255,3 +256,23 @@ async def _migration_upgrade_to_v8(c, _config): ) await _set_version(c, 8) + + +async def _migration_upgrade_to_v9(c, config): + await c.execute( + "alter table users" + " add column is_banned integer" + ) + await c.execute( + "alter table users" + " add column banned_at integer" + ) + await c.execute( + "alter table users" + " add column banned_by_dc_id varchar" + ) + await c.execute( + "alter table users" + " add column ban_reason varchar" + ) + await _set_version(c, 9) diff --git a/server/src/QRServer/db/models.py b/server/src/QRServer/db/models.py index 43b1d2e3..19184dce 100644 --- a/server/src/QRServer/db/models.py +++ b/server/src/QRServer/db/models.py @@ -13,6 +13,10 @@ class DbUser: password: str created_at: str discord_user_id: str + is_banned: bool + banned_at: datetime | None + banned_by_dc_id: str | None + ban_reason: str | None @property def is_guest(self): diff --git a/server/src/QRServer/discord/bot.py b/server/src/QRServer/discord/bot.py index 2841379e..1e20a7b6 100644 --- a/server/src/QRServer/discord/bot.py +++ b/server/src/QRServer/discord/bot.py @@ -22,6 +22,7 @@ def __init__(self, config: Config, connector: DbConnector): self.token = self.config.discord_bot_token.get() self.guild_id = self.config.guild_id.get() self.user_notifications_channel_id = self.config.discord_bot_channel_user_notifications_id.get() + self.ban_notifications_channel_id = self.config.discord_bot_channel_ban_notifications_id.get() self.max_aliases = self.config.discord_bot_max_aliases.get() self.username_regex = re.compile(r'^[a-zA-Z0-9.\-_][a-zA-Z0-9.\-_\s]{,14}$') @@ -61,6 +62,19 @@ async def claim(interaction, username: str): async def reset_password(interaction, username: str): await self._reset_password(interaction, username) + @self.tree.command(name="banuser", description="Ban specified user", guild=discord.Object(id=self.guild_id)) + @discord.app_commands.describe(username="The username to ban", reason="Ban reason") + async def ban_user(interaction, username: str, reason: str): + await self._ban_user(interaction, username, reason) + # This makes the command unavailable to anyone unless overriden by admin + ban_user.default_permissions = discord.Permissions(permissions=0) + + @self.tree.command(name="unbanuser", description="Unban specified user", guild=discord.Object(id=self.guild_id)) + @discord.app_commands.describe(username="The username to unban") + async def unban_user(interaction, username: str): + await self._unban_user(interaction, username) + unban_user.default_permissions = discord.Permissions(permissions=0) + @self.client.event async def on_ready(): await self._on_ready() @@ -123,10 +137,12 @@ async def _register(self, interaction: discord.Interaction, username: str) -> No discord_user_id=str(interaction.user.id), ) - await self._send_user_notification( + await self._send_notification( "### Account registered\n" f"- Owner: <@{interaction.user.id}>\n" - f"- Username: `{username}`") + f"- Username: `{username}`", + self.user_notifications_channel_id, + ) # Respond to interaction so it doesn't show as command fail # and send credentials via DM @@ -182,10 +198,12 @@ async def _claim(self, interaction: discord.Interaction, username: str) -> None: discord_user_id=str(interaction.user.id), ) - await self._send_user_notification( + await self._send_notification( "### Account claimed\n" f"- Owner: <@{interaction.user.id}>\n" - f"- Username: `{username}`") + f"- Username: `{username}`", + self.user_notifications_channel_id, + ) # Respond to interaction so it doesn't show as command fail # and send credentials via DM @@ -247,6 +265,104 @@ async def _reset_password(self, interaction: discord.Interaction, username: str) f"Password for account was reset: `{username}`, failed to send credentials - check privacy settings.", ephemeral=True) + async def _ban_user(self, interaction: discord.Interaction, username: str, reason: str) -> None: + log.debug(f"banuser command received from '{interaction.user}'") + username = username.strip() + + # Get user's accounts + user = await self.connector.get_user_by_username(username) + if not user: + await interaction.response.send_message( + f'User with username "{username}" has not been found', + ephemeral=True, + ) + return + + if user.is_banned: + await interaction.response.send_message( + f"This user was already banned by <@{user.banned_by_dc_id}>," + f" at: `{user.banned_at}`," + f" for reason: `{user.ban_reason}`", + ephemeral=True, + ) + return + + if not user.discord_user_id: + await interaction.response.send_message( + "This user has no Discord ID", + ephemeral=True, + ) + return + + result = await self.connector.ban_user(user.user_id, str(interaction.user.id), reason) + if not result: + warn = f'Something went wrong while banning user. Username: {username}, user_id: {user.user_id}' + log.warning(warn) + await interaction.response.send_message(warn, ephemeral=True) + return + + discord_user = await self.client.fetch_user(int(user.discord_user_id)) + await discord_user.send( + f"### Ban\n" + f"- Your account: `{username}` has been banned.\n" + f"- Reason: *{reason}*\n" + ) + + await self._send_notification( + "### Account banned\n" + f"- Banned user: `{username}`\n" + f"- Banned by: <@{interaction.user.id}>\n" + f"- Banned for: *{reason}*\n", + self.ban_notifications_channel_id, + ) + + async def _unban_user(self, interaction: discord.Interaction, username: str): + log.debug(f"banuser command received from '{interaction.user}'") + username = username.strip() + + # Get user's accounts + user = await self.connector.get_user_by_username(username) + if not user: + await interaction.response.send_message( + f'User with username "{username}" has not been found', + ephemeral=True, + ) + return + + if not user.is_banned: + await interaction.response.send_message( + "This user is not currently banned", + ephemeral=True, + ) + return + + if not user.discord_user_id: + await interaction.response.send_message( + "This user has no Discord ID", + ephemeral=True, + ) + return + + result = await self.connector.unban_user(user.user_id) + if not result: + warn = f'Something went wrong while unbanning user. Username: {username}, user_id: {user.user_id}' + log.warning(warn) + await interaction.response.send_message(warn, ephemeral=True) + return + + discord_user = await self.client.fetch_user(int(user.discord_user_id)) + await discord_user.send( + f"### Unban\n" + f"- Your account: `{username}` has been unbanned.\n" + ) + + await self._send_notification( + "### Account unbanned\n" + f"- Unbanned user: `{username}`\n" + f"- Unbanned by: <@{interaction.user.id}>\n", + self.ban_notifications_channel_id + ) + def _validate_username(self, username: str) -> tuple[bool, str]: # Checks if the username is of correct length and format # Returns a tuple of (valid, error message) @@ -314,17 +430,16 @@ async def _basic_validations_passed( return (True, "") - async def _send_user_notification(self, message: str) -> None: - # Send a message to the user notifications channel - if not self.user_notifications_channel_id: + async def _send_notification(self, message: str, channel: str) -> None: + if not self.ban_notifications_channel_id: return - channel = self.client.get_channel(int(self.user_notifications_channel_id)) + discord_channel = self.client.get_channel(int(channel)) allowed_channels = (discord.VoiceChannel, discord.StageChannel, discord.TextChannel, discord.Thread) if isinstance(channel, allowed_channels): - log.debug(f"Sending user notification: {repr(message)}") - await channel.send(message) + log.debug(f"Sending ban notification: {repr(message)}") + await discord_channel.send(message) else: log.warning( - 'User notifications channel not found or not accepting messages: ' + - self.user_notifications_channel_id) + 'notifications channel not found or not accepting messages: ' + + str(discord_channel)) diff --git a/server/src/QRServer/lobby/lobbyclient.py b/server/src/QRServer/lobby/lobbyclient.py index bd202262..30199f1a 100644 --- a/server/src/QRServer/lobby/lobbyclient.py +++ b/server/src/QRServer/lobby/lobbyclient.py @@ -82,6 +82,12 @@ async def _handle_join_lobby(self, message: JoinLobbyRequest): self.close_and_stop() # FIXME it seems that the connection shouldnt be completely closed return + if db_user.is_banned: + await self.send_msg(LobbyStateResponse.new([])) + await self.send_msg(LobbyChatMessage.new(None, f"You have been banned. Reason: {db_user.ban_reason}")) + self.close_and_stop() + return + # user authenticated successfully, register with lobbyserver self.player.user_id = db_user.user_id self.player.username = username diff --git a/server/tests/it/test_lobby.py b/server/tests/it/test_lobby.py index 740bba08..d933ec4b 100644 --- a/server/tests/it/test_lobby.py +++ b/server/tests/it/test_lobby.py @@ -2,9 +2,9 @@ from unittest.mock import patch from QRServer.common.classes import LobbyPlayer -from QRServer.common.messages import JoinLobbyRequest, LobbyStateResponse, LobbyDuplicateResponse, SetCommentRequest, \ - BroadcastCommentResponse, NameTakenRequest, NameTakenResponseYes, NameTakenResponseNo, ChangePasswordRequest, \ - ChangePasswordResponseOk, LobbyBadMemberResponse +from QRServer.common.messages import JoinLobbyRequest, LobbyChatMessage, LobbyStateResponse, LobbyDuplicateResponse, \ + SetCommentRequest, BroadcastCommentResponse, NameTakenRequest, NameTakenResponseYes, NameTakenResponseNo, \ + ChangePasswordRequest, ChangePasswordResponseOk, LobbyBadMemberResponse from . import QuadradiusIntegrationTestCase @@ -282,3 +282,15 @@ async def test_lobby_password_change_no_login(self): client = await self.new_lobby_client() await client.send_message(ChangePasswordRequest.new('912ec803b2ce49e4a541068d495ab570')) await client.assert_no_more_messages() + + async def test_lobby_user_banned(self): + client = await self.new_lobby_client() + await client.join_lobby('Player', 'cf585d509bf09ce1d2ff5d4226b7dacb') + await client.close() + + user = await self.server.connector.get_user_by_username('Player') + await self.server.connector.ban_user(user.user_id, '123', 'Test banned') + + client = await self.new_lobby_client() + await client.join_lobby('Player', 'cf585d509bf09ce1d2ff5d4226b7dacb') + await client.assert_received_message(LobbyChatMessage.new(None, 'You have been banned. Reason: Test banned')) diff --git a/server/tests/test_db.py b/server/tests/test_db.py index e699b7e8..6b7ffb2b 100644 --- a/server/tests/test_db.py +++ b/server/tests/test_db.py @@ -430,6 +430,66 @@ async def perform_conflicting_update(): self.assertEqual(loser_rating.rating, 420) self.assertEqual(loser_rating.revision, 2) # updated 3 times -> revision == 2 + async def test_ban_user(self): + user_id = await self.conn.create_member('test_user', b'password', '11111111111') + user = await self.conn.get_user(user_id) + self.assertFalse(user.is_banned) + self.assertIsNone(user.banned_at) + self.assertIsNone(user.banned_by_dc_id) + self.assertIsNone(user.ban_reason) + + with patch('QRServer.db.connector.datetime') as dt_mock: + dt_mock.now.return_value = datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + result = await self.conn.ban_user(user_id, '123', 'Test Ban') + self.assertTrue(result) + + user = await self.conn.get_user(user_id) + + self.assertEqual(user.is_banned, True) + self.assertEqual(user.banned_by_dc_id, '123') + self.assertEqual(user.banned_at, datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) + self.assertEqual(user.ban_reason, 'Test Ban') + + async def test_ban_nonexistent_user(self): + result = await self.conn.ban_user('asd', '123', 'Test Ban') + self.assertFalse(result) + + async def test_ban_banned_user(self): + user_id = await self.conn.create_member('test_user', b'password', '11111111111') + + result = await self.conn.ban_user(user_id, '123', 'Test Ban') + self.assertTrue(result) + + result = await self.conn.ban_user(user_id, '123', 'Test Ban') + self.assertFalse(result) + + async def test_unban_user(self): + user_id = await self.conn.create_member('test_user', b'password', '11111111111') + user = await self.conn.get_user(user_id) + + await self.conn.ban_user(user_id, '123', 'Test Ban') + user = await self.conn.get_user(user_id) + self.assertEqual(user.is_banned, True) + + result = await self.conn.unban_user(user_id) + self.assertTrue(result) + + user = await self.conn.get_user(user_id) + self.assertFalse(user.is_banned) + self.assertIsNone(user.banned_at) + self.assertIsNone(user.banned_by_dc_id) + self.assertIsNone(user.ban_reason) + + async def test_unban_nonexistent_user(self): + result = await self.conn.unban_user('asd') + self.assertFalse(result) + + async def test_unban_nonbanned_user(self): + user_id = await self.conn.create_member('test_user', b'password', '11111111111') + + result = await self.conn.unban_user(user_id) + self.assertFalse(result) + class DbMigrationTest(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): @@ -532,62 +592,6 @@ async def test_migration_v5(self): async def test_migration_v6(self): await migrations.execute_migrations(self.c, self.dbconn.config, 5) - with patch('uuid.uuid4') as mock_uuid: - mock_uuid.return_value = '1' - winner = await self.dbconn.authenticate_user('test_user_1', b'password', auto_create=True) - - mock_uuid.return_value = '2' - loser = await self.dbconn.authenticate_user('test_user_2', b'password', auto_create=True) - - mock_uuid.return_value = '1234' - test_match = DbMatchReport( - winner_id=winner.user_id, - loser_id=loser.user_id, - winner_pieces_left=10, - loser_pieces_left=5, - move_counter=20, - grid_size='small', - squadron_size='medium', - started_at=datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - finished_at=datetime(2020, 1, 1, 1, 0, 0, tzinfo=timezone.utc), - is_ranked=True, - is_void=False, - ) - await self.c.execute( - "insert into matches (" - " id," - " winner_id," - " loser_id," - " winner_pieces_left," - " loser_pieces_left," - " move_counter," - " grid_size," - " squadron_size," - " started_at," - " finished_at," - " is_ranked," - " is_void" - ") values (" - "?, ?, ?, ?, ?, ?," - "?, ?, ?, ?, ?, ?" - ")", ( - test_match.match_id, - test_match.winner_id, - test_match.loser_id, - test_match.winner_pieces_left, - test_match.loser_pieces_left, - test_match.move_counter, - test_match.grid_size, - test_match.squadron_size, - test_match.started_at.timestamp(), - test_match.finished_at.timestamp(), - test_match.is_ranked, - test_match.is_void - ) - ) - - await self.dbconn.conn.commit() - self.assertNotIn('rankings', await self.get_table_names()) await migrations.execute_migrations(self.c, self.dbconn.config, 6) @@ -602,13 +606,6 @@ async def test_migration_v6(self): self.assertEqual(table_info[4][:3], (4, 'wins', 'INTEGER')) self.assertEqual(table_info[5][:3], (5, 'total_games', 'INTEGER')) - # TODO self.get_ranking, compare - await self.c.execute('select * from rankings') - ranking_data = await self.c.fetchall() - self.assertEqual(len(ranking_data), 2) - self.assertEqual(ranking_data[0], (2020, 1, 1, '1', 1, 1)) - self.assertEqual(ranking_data[1], (2020, 1, 2, '2', 0, 1)) - async def test_migration_v7(self): await migrations.execute_migrations(self.c, self.dbconn.config, 6) @@ -673,6 +670,22 @@ async def test_migration_v8(self): self.assertEqual(table_info[1][:3], (1, 'duel_idx', 'INTEGER')) self.assertEqual(table_info[2][:3], (2, 'match_id', 'varchar')) + async def test_migration_v9(self): + await migrations.execute_migrations(self.c, self.dbconn.config, 8) + + table_info = await self.get_table_info('users') + self.assertEqual(len(table_info), 6) + self.assertEqual(table_info[5][:3], (5, 'discord_user_id', 'varchar')) + + await migrations.execute_migrations(self.c, self.dbconn.config, 9) + + table_info = await self.get_table_info('users') + self.assertEqual(len(table_info), 10) + self.assertEqual(table_info[6][:3], (6, 'is_banned', 'INTEGER')) + self.assertEqual(table_info[7][:3], (7, 'banned_at', 'INTEGER')) + self.assertEqual(table_info[8][:3], (8, 'banned_by_dc_id', 'varchar')) + self.assertEqual(table_info[9][:3], (9, 'ban_reason', 'varchar')) + class DbTournamentsTest(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): diff --git a/server/tests/test_discord_bot.py b/server/tests/test_discord_bot.py index abf40d95..e29f850c 100644 --- a/server/tests/test_discord_bot.py +++ b/server/tests/test_discord_bot.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone import unittest from unittest.mock import AsyncMock, patch @@ -14,6 +15,7 @@ async def asyncSetUp(self) -> None: self.config.set('discord.bot.guild_id', '123') self.config.set('discord.bot.max_aliases', 1) self.config.set('discord.bot.channel_user_notifications.id', '111') + self.config.set('discord.bot.channel_ban_notifications.id', '222') self.conn = DbConnector(':memory:', self.config) await self.conn.connect() @@ -32,7 +34,7 @@ async def asyncTearDown(self): async def test_register_new_user(self): with patch('QRServer.discord.bot.utils.generate_random_password', return_value='123asd4567'): - self.bot._send_user_notification = AsyncMock() + self.bot._send_notification = AsyncMock() await self.bot._register(self.interaction, self.username) user = await self.conn.get_user_by_username(self.username) @@ -52,10 +54,11 @@ async def test_register_new_user(self): "You can change it in the game.\n" "If you forget it, you can run `/resetpassword test_user` to reset it.") - self.bot._send_user_notification.assert_called_once_with( + self.bot._send_notification.assert_called_once_with( "### Account registered\n" "- Owner: <@123>\n" - "- Username: `test_user`") + "- Username: `test_user`", + '111') async def test_register_user_in_wrong_guild(self): self.interaction.user.guild.id = '1111111' @@ -118,7 +121,7 @@ async def test_register_user_with_existing_autoregistered_username(self): async def test_claim_user(self): with patch('QRServer.discord.bot.utils.generate_random_password', return_value='123asd4567'): - self.bot._send_user_notification = AsyncMock() + self.bot._send_notification = AsyncMock() await self.conn.authenticate_user(self.username, password=None, verify_password=False, auto_create=True) await self.bot._claim(self.interaction, self.username) @@ -135,10 +138,11 @@ async def test_claim_user(self): "You can change it in the game.\n" "If you forget it, you can run `/resetpassword test_user` to reset it.") - self.bot._send_user_notification.assert_called_once_with( + self.bot._send_notification.assert_called_once_with( "### Account claimed\n" "- Owner: <@123>\n" - "- Username: `test_user`") + "- Username: `test_user`", + '111') async def test_claim_user_in_wrong_guild(self): self.interaction.user.guild.id = '1111111' @@ -224,3 +228,105 @@ async def test_reset_password_unowned_user(self): self.interaction.response.send_message.assert_called_once_with( "You do not have an account with username: `test_user`.\nOwned accounts: `test_user2`, `test_user3`", ephemeral=True) + + async def test_ban_user(self): + self.bot._send_notification = AsyncMock() + user_sender_mock = AsyncMock() + self.bot.client.fetch_user = AsyncMock() + self.bot.client.fetch_user.return_value = user_sender_mock + + await self.conn.create_member(self.username, 'asd'.encode(), discord_user_id='123') + + await self.bot._ban_user(self.interaction, self.username, 'Test Ban') + + self.interaction.response.send_message.assert_not_called() + self.interaction.user.send.assert_not_called() + + self.bot._send_notification.assert_called_once_with( + "### Account banned\n" + "- Banned user: `test_user`\n" + "- Banned by: <@123>\n" + "- Banned for: *Test Ban*\n", '222') + + user_sender_mock.send.assert_called_once_with( + "### Ban\n" + "- Your account: `test_user` has been banned.\n" + "- Reason: *Test Ban*\n" + ) + + async def test_ban_nonexistent_user(self): + self.bot._send_notification = AsyncMock() + + await self.bot._ban_user(self.interaction, self.username, 'Test Ban') + + self.interaction.response.send_message.assert_called_once_with( + 'User with username "test_user" has not been found', ephemeral=True) + self.interaction.user.send.assert_not_called() + + self.bot._send_notification.assert_not_called() + + async def test_ban_banned_user(self): + self.bot._send_notification = AsyncMock() + user_id = await self.conn.create_member(self.username, 'asd'.encode(), discord_user_id='123') + + with patch('QRServer.db.connector.datetime') as mock_dt: + mock_dt.now.return_value = datetime(2020, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + result = await self.conn.ban_user(user_id, '123', 'Test Ban') + self.assertTrue(result) + + await self.bot._ban_user(self.interaction, self.username, 'Test Ban') + + self.interaction.response.send_message.assert_called_once_with( + 'This user was already banned by <@123>, at: `2020-01-02 03:04:05+00:00`, for reason: `Test Ban`', + ephemeral=True) + self.interaction.user.send.assert_not_called() + + self.bot._send_notification.assert_not_called() + + async def test_unban_user(self): + self.bot._send_notification = AsyncMock() + user_sender_mock = AsyncMock() + self.bot.client.fetch_user = AsyncMock() + self.bot.client.fetch_user.return_value = user_sender_mock + + user_id = await self.conn.create_member(self.username, 'asd'.encode(), discord_user_id='123') + await self.conn.ban_user(user_id, '123', 'Test Ban') + + await self.bot._unban_user(self.interaction, self.username) + + self.interaction.response.send_message.assert_not_called() + self.interaction.user.send.assert_not_called() + + self.bot._send_notification.assert_called_once_with( + "### Account unbanned\n" + "- Unbanned user: `test_user`\n" + "- Unbanned by: <@123>\n", '222') + + user_sender_mock.send.assert_called_once_with( + "### Unban\n" + "- Your account: `test_user` has been unbanned.\n" + ) + + async def test_unban_nonexistent_user(self): + self.bot._send_notification = AsyncMock() + + await self.bot._unban_user(self.interaction, self.username) + + self.interaction.response.send_message.assert_called_once_with( + 'User with username "test_user" has not been found', ephemeral=True) + self.interaction.user.send.assert_not_called() + + self.bot._send_notification.assert_not_called() + + async def test_unban_nonbanned_user(self): + self.bot._send_notification = AsyncMock() + await self.conn.create_member(self.username, 'asd'.encode(), discord_user_id='123') + + await self.bot._unban_user(self.interaction, self.username) + + self.interaction.response.send_message.assert_called_once_with( + 'This user is not currently banned', + ephemeral=True) + self.interaction.user.send.assert_not_called() + + self.bot._send_notification.assert_not_called()