From 65fc19183eb4292d025698534905bee0c3981a3f Mon Sep 17 00:00:00 2001 From: Fruktus Date: Tue, 26 May 2026 23:58:35 +0200 Subject: [PATCH 1/2] feat: add api login endpoints --- server/setup.py | 1 + server/src/QRServer/api/api.py | 113 ++++++++++++++++++++ server/src/QRServer/api/auth.py | 28 +++++ server/src/QRServer/config.py | 25 ++++- server/tests/it/test_api_oauth.py | 170 ++++++++++++++++++++++++++++++ 5 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 server/src/QRServer/api/auth.py create mode 100644 server/tests/it/test_api_oauth.py diff --git a/server/setup.py b/server/setup.py index 9637571f..47e0e5e1 100644 --- a/server/setup.py +++ b/server/setup.py @@ -15,6 +15,7 @@ 'aiosqlite', 'aiohttp', 'discord.py', + 'pyjwt', ], extras_require={ 'dev': [ diff --git a/server/src/QRServer/api/api.py b/server/src/QRServer/api/api.py index 1a9d847b..954a9cc8 100644 --- a/server/src/QRServer/api/api.py +++ b/server/src/QRServer/api/api.py @@ -2,6 +2,7 @@ from enum import Enum import logging +from QRServer.api.auth import decode_token, make_access_token, make_refresh_token from QRServer.config import Config from QRServer.db.connector import DbConnector from QRServer.db.models import DbUser, Tournament, TournamentDuel, TournamentMatch @@ -35,6 +36,10 @@ def __init__(self, config: Config, connector: DbConnector, lobby_server: LobbySe self.lobby_server = lobby_server self.game_server = game_server self.runner = web.AppRunner(self.app) + self.origin = self.config.origin.get() + self.api_token_secret = self.config.api_token_secret.get() + self.api_access_token_lifetime_sec = self.config.api_access_token_lifetime_sec.get() + self.api_refresh_token_lifetime_sec = self.config.api_refresh_token_lifetime_sec.get() def api_socks(self): if self.site: @@ -64,10 +69,16 @@ def _add_routes(self): web.get('/api/v1/game/stats', self._v1_game_stats), web.get('/api/v1/health', self._v1_health), web.get('/api/v1/lobby/stats', self._v1_lobby_stats), + + # Tournaments web.get('/api/v1/tournaments/{id}', self._v1_tournaments), web.get('/api/v1/tournaments/{id}/duels', self._v1_tournament_duels), web.get('/api/v1/tournaments/{id}/matches', self._v1_tournament_matches), web.get('/api/v1/tournaments/{id}/users', self._v1_tournament_users), + + # OAuth2 + web.get('/.well-known/openid-configuration', self._wellknown_openid_config), + web.post('/oauth/token', self._oauth_token), ]) async def _v1_game_stats(self, _request: web.Request) -> web.Response: @@ -160,3 +171,105 @@ async def _v1_tournament_matches(self, request) -> web.Response: }) return web.json_response(data={'tournament_matches': tournament_matches_view}, status=200) + + async def _wellknown_openid_config(self, request: web.Request) -> web.Response: + """Endpoint for oidc client autodiscovery""" + base = f'{self.origin}' + return web.json_response({ + 'issuer': base, + 'token_endpoint': f'{base}/oauth/token', + 'userinfo_endpoint': f'{base}/oauth/userinfo', + 'response_types_supported': ['token'], + 'grant_types_supported': ['password', 'refresh_token'], + 'token_endpoint_auth_methods_supported': ['none'], + }) + + async def _oauth_token(self, request: web.Request) -> web.Response: + content_type = request.content_type + + if 'application/json' in content_type: + # json sent when using raw requests + try: + body = await request.json() + except Exception: + return web.json_response({'error': 'invalid_request'}, status=400) + elif 'application/x-www-form-urlencoded': + # form-encoded, which is what oidc-client-ts sends + body = await request.post() + else: + return web.json_response({'error': 'unsupported_media_type'}, status=400) + + if not body: + return web.json_response({'error': 'invalid_request'}, status=400) + + grant_type = body.get('grant_type') + + match grant_type: + case 'password': + username = body.get('username') or '' + password = body.get('password') or '' + return await self._oauth_password_grant(username, password) + + case 'refresh_token': + refresh_token = body.get('refresh_token') or '' + return await self._oauth_refresh_token_grant(refresh_token) + case _: return web.json_response({'error': 'unsupported_grant_type'}, status=400) + + async def _oauth_password_grant(self, username: str, password: str) -> web.Response: + """password is expected in swf format, provided by frontend""" + if not username or not password: + return web.json_response({'error': 'invalid_request'}, status=400) + + user: DbUser | None = await self.connector.authenticate_user( + username=username, + password=password.encode() + ) + if user is None: + return web.json_response({'error': 'invalid_grant'}, status=401) + + access = make_access_token( + self.api_token_secret, + user.user_id, + user.username, + self.api_access_token_lifetime_sec, + ) + refresh = make_refresh_token( + self.api_token_secret, + user.user_id, + self.api_refresh_token_lifetime_sec + ) + + return web.json_response({ + 'access_token': access, + 'refresh_token': refresh, + 'token_type': 'Bearer', + 'expires_in': self.api_access_token_lifetime_sec, + }) + + async def _oauth_refresh_token_grant(self, refresh_token: str) -> web.Response: + if not refresh_token: + return web.json_response({'error': 'invalid_request'}, status=400) + + try: + claims = decode_token(self.api_token_secret, refresh_token) + except Exception: + return web.json_response({'error': 'invalid_grant'}, status=401) + + if claims.get('type') != 'refresh': + return web.json_response({'error': 'invalid_grant'}, status=401) + + user: DbUser | None = await self.connector.get_user(claims['sub']) + if user is None: + return web.json_response({'error': 'invalid_grant'}, status=401) + + access = make_access_token( + self.api_token_secret, + user.user_id, + user.username, + self.api_access_token_lifetime_sec, + ) + return web.json_response({ + 'access_token': access, + 'token_type': 'Bearer', + 'expires_in': self.api_access_token_lifetime_sec, + }) diff --git a/server/src/QRServer/api/auth.py b/server/src/QRServer/api/auth.py new file mode 100644 index 00000000..01903099 --- /dev/null +++ b/server/src/QRServer/api/auth.py @@ -0,0 +1,28 @@ +from datetime import datetime, timedelta, timezone +from secrets import token_hex + +import jwt + + +def make_access_token(secret: str, user_id: str, username: str, lifetime_sec: int) -> str: + payload = { + 'sub': user_id, + 'username': username, + 'type': 'access', + 'exp': datetime.now(timezone.utc) + timedelta(seconds=lifetime_sec), + } + return jwt.encode(payload, secret, algorithm='HS256') + + +def make_refresh_token(secret: str, user_id: str, lifetime_sec: int) -> str: + payload = { + 'sub': user_id, + 'type': 'refresh', + 'exp': datetime.now(timezone.utc) + timedelta(seconds=lifetime_sec), + 'jti': token_hex(16), + } + return jwt.encode(payload, secret, algorithm='HS256') + + +def decode_token(secret: str, token: str) -> dict: + return jwt.decode(token, secret, algorithms=['HS256']) diff --git a/server/src/QRServer/config.py b/server/src/QRServer/config.py index e40c5ae9..8060c95a 100644 --- a/server/src/QRServer/config.py +++ b/server/src/QRServer/config.py @@ -116,7 +116,30 @@ def __init__(self): cli_args=[], description='welcome message sent after joining the lobby', default_value='') - + self.origin = ConfigKey( + config=self, + name='origin', + cli_args=[], + description='The origin where server is hosted at. Used for creating invite links and oauth', + default_value='http://localhost') + self.api_token_secret = ConfigKey( + config=self, + name='api.token_secret', + cli_args=[], + description='Secret key used to sign other tokens. Recommended length: 32 bytes', + default_value='CHANGE_ME!') + self.api_access_token_lifetime_sec = ConfigKey( + config=self, + name='api.access_token_lifetime_sec', + cli_args=[], + description='Lifetime (in seconds) of the access token', + default_value=15 * 60) # 15 minutes + self.api_refresh_token_lifetime_sec = ConfigKey( + config=self, + name='api.refresh_token_lifetime_sec', + cli_args=[], + description='Lifetime (in seconds) of the refresh token', + default_value=6 * 30 * 24 * 60 * 60) # 6 months self.discord_webhook_lobby_joined_url = ConfigKey( config=self, name='discord.webhook.lobby_joined.url', diff --git a/server/tests/it/test_api_oauth.py b/server/tests/it/test_api_oauth.py new file mode 100644 index 00000000..a02e040a --- /dev/null +++ b/server/tests/it/test_api_oauth.py @@ -0,0 +1,170 @@ +from datetime import datetime, timezone +from hashlib import md5 +from unittest.mock import patch +import jwt +from . import QuadradiusIntegrationTestCase + + +class ApiOauthIT(QuadradiusIntegrationTestCase): + async def itSetUpConfig(self, config): + self.secret_key = 'TEST_KEY' + config.set('origin', 'http://localhost') + config.set('api.enabled', True) + config.set('api.token_secret', self.secret_key) + config.set('api.access_token_lifetime_sec', 60) + config.set('api.refresh_token_lifetime_sec', 120) + + async def _create_test_user(self): + with patch('uuid.uuid4') as mock_uuid: + mock_uuid.return_value = '1234' + await self.connector.create_member( + username='testuser', + password=md5('++TESTUSER++asd'.encode()).hexdigest().encode(), + discord_user_id='@asd', + ) + + async def _login(self, client, username='testuser', password='asd'): + async with client.post('/oauth/token', json={ + 'grant_type': 'password', + 'username': username, + 'password': md5(f'++{username.upper()}++{password}'.encode()).hexdigest(), + }) as r: + return r.status, await r.json() + + async def test_wellknown(self): + client = await self.new_api_client('v1') + + async with client.get('/.well-known/openid-configuration') as r: + self.assertEqual(r.status, 200) + doc = await r.json() + + self.assertEqual(doc['issuer'], 'http://localhost') + self.assertEqual(doc['token_endpoint'], 'http://localhost/oauth/token') + self.assertEqual(doc['userinfo_endpoint'], 'http://localhost/oauth/userinfo') + self.assertEqual(doc['response_types_supported'], ['token']) + self.assertEqual(doc['grant_types_supported'], ['password', 'refresh_token']) + self.assertEqual(doc['token_endpoint_auth_methods_supported'], ['none']) + + # password grant + async def test_password_grant_missing_body(self): + client = await self.new_api_client('v1') + + async with client.post('/oauth/token') as r: + self.assertEqual(r.status, 400) + self.assertEqual((await r.json())['error'], 'invalid_request') + + async def test_password_grant_missing_fields(self): + client = await self.new_api_client('v1') + + # Missing field + async with client.post('/oauth/token', json={ + 'grant_type': 'password', + 'username': 'testuser', + }) as r: + self.assertEqual(r.status, 400) + self.assertEqual((await r.json())['error'], 'invalid_request') + + # Value present but set to None + async with client.post('/oauth/token', json={ + 'grant_type': 'password', + 'username': 'testuser', + 'password': None + }) as r: + self.assertEqual(r.status, 400) + self.assertEqual((await r.json())['error'], 'invalid_request') + + async def test_password_grant_nonexistent_user(self): + client = await self.new_api_client('v1') + + status, body = await self._login(client, username='nonexistent', password='asd') + self.assertEqual(status, 401) + self.assertEqual(body['error'], 'invalid_grant') + + async def test_password_grant_wrong_password(self): + client = await self.new_api_client('v1') + await self._create_test_user() + + status, body = await self._login(client, password='wrongpassword') + + self.assertEqual(status, 401) + self.assertEqual(body['error'], 'invalid_grant') + + async def test_password_grant_success(self): + client = await self.new_api_client('v1') + await self._create_test_user() + + with patch('QRServer.api.auth.datetime') as mock_datetime: + mock_datetime.now.return_value = datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + status, body = await self._login(client) + + self.assertEqual(status, 200) + self.assertEqual(body['token_type'], 'Bearer') + self.assertEqual(body['expires_in'], 60) + + # verify access token (skip datetime validation) + payload = jwt.decode( + body['access_token'], + self.secret_key, + algorithms=['HS256'], + options={"verify_exp": False} + ) + self.assertEqual(payload['username'], 'testuser') + self.assertEqual(payload['sub'], '1234') + self.assertEqual(payload['type'], 'access') + + # verify refresh token (skip datetime validation) + payload = jwt.decode( + body['refresh_token'], + self.secret_key, algorithms=['HS256'], + options={"verify_exp": False} + ) + self.assertEqual(payload['sub'], '1234') + self.assertEqual(payload['type'], 'refresh') + self.assertIn('jti', payload) + + async def test_unsupported_grant_type(self): + client = await self.new_api_client('v1') + + async with client.post('/oauth/token', json={ + 'grant_type': 'authorization_code', + 'code': 'asd', + }) as r: + self.assertEqual(r.status, 400) + self.assertEqual((await r.json())['error'], 'unsupported_grant_type') + + # refresh token grant + async def test_refresh_grant_success(self): + client = await self.new_api_client('v1') + await self._create_test_user() + + status, body = await self._login(client) + self.assertEqual(status, 200) + refresh_token = body['refresh_token'] + + async with client.post('/oauth/token', json={ + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + }) as r: + self.assertEqual(r.status, 200) + body = await r.json() + + self.assertEqual(body['token_type'], 'Bearer') + # refresh grants do not issue a new refresh token + self.assertNotIn('refresh_token', body) + + # No need for datetime override since the token is both generated and tested + # at the current time + payload = jwt.decode(body['access_token'], self.secret_key, algorithms=['HS256']) + self.assertEqual(payload['type'], 'access') + self.assertEqual(payload['username'], 'testuser') + + async def test_refresh_grant_invalid_token(self): + client = await self.new_api_client('v1') + + async with client.post('/oauth/token', json={ + 'grant_type': 'refresh_token', + 'refresh_token': 'asd', + }) as r: + self.assertEqual(r.status, 401) + self.assertEqual((await r.json())['error'], 'invalid_grant') From 85d1a7266d2c3b44025befd461aa9c05797e5419 Mon Sep 17 00:00:00 2001 From: Fruktus Date: Mon, 18 May 2026 13:19:29 +0200 Subject: [PATCH 2/2] feat: add direct invites --- server/src/QRServer/api/api.py | 10 +++ server/src/QRServer/config.py | 7 ++ server/src/QRServer/db/connector.py | 66 ++++++++++++++- server/src/QRServer/db/migrations.py | 19 +++++ server/src/QRServer/db/models.py | 11 +++ server/src/QRServer/discord/bot.py | 120 +++++++++++++++++++++++++++ server/tests/test_db.py | 46 ++++++++++ 7 files changed, 276 insertions(+), 3 deletions(-) diff --git a/server/src/QRServer/api/api.py b/server/src/QRServer/api/api.py index 954a9cc8..bf1356c5 100644 --- a/server/src/QRServer/api/api.py +++ b/server/src/QRServer/api/api.py @@ -75,6 +75,7 @@ def _add_routes(self): web.get('/api/v1/tournaments/{id}/duels', self._v1_tournament_duels), web.get('/api/v1/tournaments/{id}/matches', self._v1_tournament_matches), web.get('/api/v1/tournaments/{id}/users', self._v1_tournament_users), + web.post('/api/v1/challenges/{id}', self._v1_join_match_challenge), # OAuth2 web.get('/.well-known/openid-configuration', self._wellknown_openid_config), @@ -172,6 +173,15 @@ async def _v1_tournament_matches(self, request) -> web.Response: return web.json_response(data={'tournament_matches': tournament_matches_view}, status=200) + async def _v1_join_match_challenge(self, request) -> web.Response: + """ + Based on invite id and user credentials generates presigned match url that can be used by front-end + to load the game with specific opponent. + Note that the presigned url WILL contain the hash of the user's password + (this is an swf limitation) + """ + raise NotImplementedError + async def _wellknown_openid_config(self, request: web.Request) -> web.Response: """Endpoint for oidc client autodiscovery""" base = f'{self.origin}' diff --git a/server/src/QRServer/config.py b/server/src/QRServer/config.py index 8060c95a..45e2a594 100644 --- a/server/src/QRServer/config.py +++ b/server/src/QRServer/config.py @@ -228,6 +228,13 @@ def __init__(self): cli_args=[], description='Maximum number of aliases per user', default_value=1) + self.challenge_invite_duration = ConfigKey( + config=self, + name='discord.bot.challenge.invite_duration', + cli_args=[], + description='Direct challenge invite duration in minutes. Likely reliant on the swf\'s capability to wait' + ' for the other party', + default_value='1') 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 d091acde..2e826186 100644 --- a/server/src/QRServer/db/connector.py +++ b/server/src/QRServer/db/connector.py @@ -1,7 +1,8 @@ import logging import os +import random import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from QRServer.config import Config from QRServer.db.common import UpdateCollisionError, retry_on_update_collision @@ -10,8 +11,8 @@ from QRServer.common.classes import GameResultHistory, RankingEntry from QRServer.common import utils from QRServer.db import migrations -from QRServer.db.models import DbUser, DbMatchReport, TournamentDuel, TournamentMatch, TournamentParticipant, \ - Tournament, UserRating +from QRServer.db.models import DbUser, DbMatchReport, MatchInvite, TournamentDuel, TournamentMatch, \ + TournamentParticipant, Tournament, UserRating from QRServer.db.password import password_verify, password_hash log = logging.getLogger('qr.dbconnector') @@ -837,6 +838,65 @@ 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 get_match_invite(self, invite_id: str) -> MatchInvite | None: + c = await self.conn.cursor() + await c.execute( + "select match_invites.id, u1.username, u2.username, challenger_auth, challenged_auth, issued_at_timestamp," + " active_until_timestamp" + " from match_invites" + " left join users u1" + " on challenger_id = u1.id" + " left join users u2" + " on challenged_id = u2.id" + " where match_invites.id = ?", + ( + invite_id, + )) + row = await c.fetchone() + if row is None: + return None + + return MatchInvite( + invite_id=row[0], + challenger_username=row[1], + challenged_username=row[2], + challenger_auth=row[3], + challenged_auth=row[4], + issued_at=datetime.fromtimestamp(row[5], tz=timezone.utc), + active_until=datetime.fromtimestamp(row[6], tz=timezone.utc), + ) + + async def create_match_invite(self, challenger_id: str, challenged_id: str) -> bool: + """ + Returns: + bool: True if successfully created the invite + """ + c = await self.conn.cursor() + now = datetime.now() + await c.execute( + "insert or ignore into match_invites (" + " id," + " challenger_id," + " challenged_id," + " challenger_auth," + " challenged_auth," + " issued_at_timestamp," + " active_until_timestamp" + ")" + "values (?, ?, ?, ?, ?, ?, ?)", + ( + str(uuid.uuid4()), + challenger_id, + challenged_id, + random.randint(65535), + random.randint(65535), + int(now.timestamp()), + int((now + timedelta(minutes=int(self.config.challenge_invite_duration.get()))).timestamp()) + ) + ) + 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..eed26b56 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,21 @@ async def _migration_upgrade_to_v8(c, _config): ) await _set_version(c, 8) + + +async def _migration_upgrade_to_v9(c, _config): + await c.execute( + "create table match_invites (" + " id varchar primary key," + " challenger_id varchar," + " challenged_id varchar," + " challenger_auth integer," + " challenged_auth integer," + " issued_at_timestamp integer," + " active_until_timestamp integer," + " foreign key(challenger_id) references users (id)," + " foreign key(challenged_id) references users (id)" + ")" + ) + + await _set_version(c, 9) diff --git a/server/src/QRServer/db/models.py b/server/src/QRServer/db/models.py index 43f2eb99..02e13e31 100644 --- a/server/src/QRServer/db/models.py +++ b/server/src/QRServer/db/models.py @@ -75,3 +75,14 @@ class TournamentDuel: class TournamentMatch: duel_idx: int match: DbMatchReport + + +@dataclass +class MatchInvite: + invite_id: str + challenger_username: str + challenged_username: str + challenger_auth: int + challenged_auth: int + issued_at: datetime + active_until: datetime diff --git a/server/src/QRServer/discord/bot.py b/server/src/QRServer/discord/bot.py index 358ad970..d7fd107c 100644 --- a/server/src/QRServer/discord/bot.py +++ b/server/src/QRServer/discord/bot.py @@ -59,6 +59,11 @@ async def claim(interaction, username: str): async def reset_password(interaction, username: str): await self._reset_password(interaction, username) + @self.tree.command(name="challenge", description="Challenge the specified member to a match") + @discord.app_commands.describe(username="The in-game member username to challenge") + async def challenge_member(interaction, username: str): + await self._challenge_member(interaction, username) + @self.client.event async def on_ready(): await self._on_ready() @@ -326,3 +331,118 @@ async def _send_user_notification(self, message: str) -> None: log.warning( 'User notifications channel not found or not accepting messages: ' + self.user_notifications_channel_id) + + async def _challenge_member(self, interaction: discord.Interaction, username: str) -> None: + """ + Generates invite links for direct matches. + Note that challenger and challenged are similar, pay attention to code. + """ + # 1.a Check if challenger has account + log.debug(f"challenge command received from '{interaction.user}'") + username = username.strip() + + user_account_list = await self.connector.get_users_by_discord_id(str(interaction.user.id)) + if user_account_list is None: + log.debug(f"Unregistered user '{interaction.user}' tried to challenge player: '{username}'") + + await interaction.response.send_message( + "You need to register first.", + ephemeral=True) + return + user_accounts = {user.username: user for user in user_account_list} + + # 1.b Check if challenger have not challenged their own account + if username in user_accounts: + log.debug(f"User '{interaction.user}' tried to challenge own account: '{username}'") + + # TODO what do we do if the user tries to challenge one of own accounts? + # the assumption was that one dc user may own more than one qr account as parent or smth. + # but only one person can be reasonably logged into discord, so i guess we can discard all of those. + await interaction.user.send( + "You cannot challenge a different account tied to the same Discord account." + ) + return + + # 2. Check if the challenged user exist + challenged_user = await self.connector.get_user_by_username(username) + if challenged_user is None: + log.debug(f"User'{interaction.user}' tried to challenge unknown player: '{username}'") + + await interaction.response.send_message( + f"Failed to find a player with username: {username}", + ephemeral=True) + return + + # If it does, use dc library to get a Discord user object + challenged_user_dc = await self.client.fetch_user(challenged_user.discord_user_id) + challenger_user_dc = interaction.user + + # 3. Generate challenge data (player 1 and 2 order ints) + # Generate an invite link via db connector (save it to db) + # Send to challenged first, if this succeeds, send to challenger + # If first send failed, notify challenger in response to ephemeral + # If second failed, notify challenger in response to ephemeral + # If went well, notify in ephemeral that it worked and link is in dm + # TODO update connector + + # 4. Store the challenge in the db (save issuer, date issued etc) + + # 5. (opt) Save the date that challenge was used (if it was) + # 6. Once players click link, open the website, show login screen + # 7. Use login data to authenticate the player and load the game.swf + # (get additional info via api - maybe smth like post to /challenge/{id} + # with credentials to get the actual link) + + + + # 8. Let players play + # TODO + + challenge_url = f'{self.config.origin.get()}/challenge/{ids}' + + # Send invites to challenged + await challenged_user_dc.send( + "### Match Invite" + "- You have been invited to a match!" + f"- Your opponent is: <@{user_account_list[0].discord_user_id}> - `{user_account_list[0].username}`" + f"- Match link: {challenge_url}" + f"- The match link will be valid for the next {self.config.challenge_invite_duration} minute" + f"{'s' if self.config.challenge_invite_duration != 1 else ''}" + ) + + await challenger_user_dc.send( + "### Match Invite" + "- You have been invited to a match!" + f"- Your opponent is: <@{user_account_list[0].discord_user_id}> - `{user_account_list[0].username}`" + f"- Match link: {challenge_url}" + f"- The match link will be valid for the next {self.config.challenge_invite_duration} minute" + f"{'s' if self.config.challenge_invite_duration != 1 else ''}" + ) + + + # FIXME old stuff + try: + await interaction.user.send( + "### Challenge has been sent\n" + f"Password was reset for account: `{username}`.\n" + f"Your new password is: ||`{password}`||.") + await interaction.response.send_message( + f"Password for account was reset: `{username}`, credentials have been sent via DM.", + ephemeral=True) + except Exception as e: + log.warning("Failed to send new password via DM", exc_info=e) + await interaction.response.send_message( + f"Password for account was reset: `{username}`, failed to send credentials - check privacy settings.", + ephemeral=True) + + # FIXME this is the place to generate an invite link + + await challenged_user_dc.send( + "### Match Invite" + "- You have been invited to a match!" + f"- Your opponent is: <@{user_account_list[0].discord_user_id}> - `{user_account_list[0].username}`" + f"- Match link: https://quadradius.com/challenge/{ids}" + f"- The match link will be valid for the next {self.config.challenge_invite_duration} minute" + f"{'s' if self.config.challenge_invite_duration != 1 else ''}" + ) + \ No newline at end of file diff --git a/server/tests/test_db.py b/server/tests/test_db.py index 8261c37c..3ccf0851 100644 --- a/server/tests/test_db.py +++ b/server/tests/test_db.py @@ -433,6 +433,31 @@ 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_match_invite(self): + with patch('uuid.uuid4') as mock_uuid, \ + patch('QRServer.db.connector.datetime') as mock_datetime, \ + patch('random.randint') as mock_randint: + mock_datetime.now.return_value = datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + mock_uuid.return_value = '0' + user_1 = await self.conn.authenticate_user('test_user_0', b'password', auto_create=True) + mock_uuid.return_value = '1' + user_2 = await self.conn.authenticate_user('test_user_1', b'password', auto_create=True) + mock_uuid.return_value = '1234' + + mock_randint.side_effect = [123, 456] + await self.conn.create_match_invite(user_1.user_id, user_2.user_id) + + invite = await self.conn.get_match_invite('1234') + + self.assertEqual(invite.invite_id, '1234') + self.assertEqual(invite.challenger_username, 'test_user_0') + self.assertEqual(invite.challenged_username, 'test_user_1') + self.assertEqual(invite.challenger_auth, 123) + self.assertEqual(invite.challenged_auth, 456) + self.assertEqual(invite.issued_at, datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) + self.assertEqual(invite.active_until, datetime(2020, 1, 1, 0, 1, 0, tzinfo=timezone.utc)) + class DbMigrationTest(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): @@ -679,6 +704,27 @@ 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_names = await self.get_table_names() + self.assertNotIn('match_invites', table_names) + + await migrations.execute_migrations(self.c, self.dbconn.config, 9) + + table_names = await self.get_table_names() + self.assertIn('match_invites', table_names) + + table_info = await self.get_table_info('match_invites') + self.assertEqual(len(table_info), 7) + self.assertEqual(table_info[0][:3], (0, 'id', 'varchar')) + self.assertEqual(table_info[1][:3], (1, 'challenger_id', 'varchar')) + self.assertEqual(table_info[2][:3], (2, 'challenged_id', 'varchar')) + self.assertEqual(table_info[3][:3], (3, 'challenger_auth', 'INTEGER')) + self.assertEqual(table_info[4][:3], (4, 'challenged_auth', 'INTEGER')) + self.assertEqual(table_info[5][:3], (5, 'issued_at_timestamp', 'INTEGER')) + self.assertEqual(table_info[6][:3], (6, 'active_until_timestamp', 'INTEGER')) + class DbTournamentsTest(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self):