From 3dc2e01c28c177fd538a0af26494a6f5eabccae8 Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 21 Apr 2019 17:21:31 -0300 Subject: [PATCH 01/21] admin_api.guilds: add PATCH /api/v6/admin/guilds/:id currently does not do anything else other than updating and returning the guild, as we don't store the availability of it on the database as of rn. --- docs/admin_api.md | 11 +++++++++++ litecord/admin_schemas.py | 4 ++++ litecord/blueprints/admin_api/guilds.py | 18 +++++++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/admin_api.md b/docs/admin_api.md index d19658f..6a04aff 100644 --- a/docs/admin_api.md +++ b/docs/admin_api.md @@ -132,6 +132,17 @@ Returns empty body with 204 status code on success. Returns a partial guild object. +### PATCH `/guilds/` + +Update a single guild. + +Dispatches `GUILD_UPDATE` to subscribers of the guild, returns the guild object +on success. + +| field | type | description | +| --: | :-- | :-- | +| unavailable | bool | if the guild is unavailable | + ## Guild features The currently supported features are: diff --git a/litecord/admin_schemas.py b/litecord/admin_schemas.py index d4dba63..fc96dc9 100644 --- a/litecord/admin_schemas.py +++ b/litecord/admin_schemas.py @@ -50,3 +50,7 @@ USER_CREATE = { INSTANCE_INVITE = { 'max_uses': {'type': 'integer', 'required': True} } + +GUILD_UPDATE = { + 'unavailable': {'type': 'boolean', 'required': False} +} diff --git a/litecord/blueprints/admin_api/guilds.py b/litecord/blueprints/admin_api/guilds.py index d779c02..f658cf6 100644 --- a/litecord/blueprints/admin_api/guilds.py +++ b/litecord/blueprints/admin_api/guilds.py @@ -17,9 +17,11 @@ along with this program. If not, see . """ -from quart import Blueprint, jsonify, current_app as app +from quart import Blueprint, jsonify, current_app as app, request from litecord.auth import admin_check +# from litecord.schemas import validate +# from litecord.admin_schemas import GUILD_UPDATE bp = Blueprint('guilds_admin', __name__) @@ -31,3 +33,17 @@ async def get_guild(guild_id: int): return jsonify( await app.storage.get_guild(guild_id) ) + +@bp.route('/', methods=['PATCH']) +async def update_guild(guild_id: int): + await admin_check() + + # j = validate(await request.get_json(), GUILD_UPDATE) + + # TODO: add guild availability update, we don't store it, should we? + # TODO: what happens to the other guild attributes when its + # unavailable? do they vanish? + + guild = await app.storage.get_guild(guild_id) + await app.dispatcher.dispatch_guild(guild_id, 'GUILD_UPDATE', guild) + return jsonify(guild) From 9aaac5d99436212d1d710bda8995c87ef8e90556 Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 21 Apr 2019 17:29:21 -0300 Subject: [PATCH 02/21] admin_api.users: add PATCH /api/v6/admin/users/:id - admin_schemas: add USER_UPDATE - users: return a tuple with public and private user dicts on mass_user_update() --- litecord/admin_schemas.py | 6 +++++- litecord/blueprints/admin_api/users.py | 24 ++++++++++++++++++++++-- litecord/blueprints/users.py | 4 ++-- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/litecord/admin_schemas.py b/litecord/admin_schemas.py index fc96dc9..2796ba1 100644 --- a/litecord/admin_schemas.py +++ b/litecord/admin_schemas.py @@ -17,7 +17,7 @@ along with this program. If not, see . """ -from litecord.enums import Feature +from litecord.enums import Feature, UserFlags VOICE_SERVER = { 'hostname': {'type': 'string', 'maxlength': 255, 'required': True} @@ -54,3 +54,7 @@ INSTANCE_INVITE = { GUILD_UPDATE = { 'unavailable': {'type': 'boolean', 'required': False} } + +USER_UPDATE = { + 'flags': {'required': False, 'coerce': UserFlags} +} diff --git a/litecord/blueprints/admin_api/users.py b/litecord/blueprints/admin_api/users.py index 6f309d6..8a84be8 100644 --- a/litecord/blueprints/admin_api/users.py +++ b/litecord/blueprints/admin_api/users.py @@ -22,10 +22,12 @@ from quart import Blueprint, jsonify, current_app as app, request from litecord.auth import admin_check from litecord.blueprints.auth import create_user from litecord.schemas import validate -from litecord.admin_schemas import USER_CREATE +from litecord.admin_schemas import USER_CREATE, USER_UPDATE from litecord.errors import BadRequest from litecord.utils import async_map -from litecord.blueprints.users import delete_user, user_disconnect +from litecord.blueprints.users import ( + delete_user, user_disconnect, mass_user_update +) bp = Blueprint('users_admin', __name__) @@ -116,3 +118,21 @@ async def _delete_single_user(user_id: int): 'old': old_user, 'new': new_user }) + +@bp.route('/', methods=['PATCH']) +async def patch_user(user_id: int): + await admin_check() + + j = validate(await request.get_json(), USER_UPDATE) + + # TODO: finish, at least flags. + # TODO: we MUST have a check so that users don't + # privilege escalate other users to the staff badge, since + # that just grants access to the admin api. + + if 'flags' in j: + pass + + # TODO: decide if we return the public or private user. + _public_user, private_user = await mass_user_update(user_id, app) + return jsonify(private_user) diff --git a/litecord/blueprints/users.py b/litecord/blueprints/users.py index a3d7672..bea5b89 100644 --- a/litecord/blueprints/users.py +++ b/litecord/blueprints/users.py @@ -83,7 +83,7 @@ async def mass_user_update(user_id, app_=None): 'lazy_guild', guild_ids, 'update_user', user_id ) - return private_user + return public_user, private_user @bp.route('/@me', methods=['GET']) @@ -257,7 +257,7 @@ async def patch_me(): user.pop('password_hash') - private_user = await mass_user_update(user_id, app) + _, private_user = await mass_user_update(user_id, app) return jsonify(private_user) From d69e732b80ad190a2fe9ce0be6f80a1f5080378a Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 21 Apr 2019 17:31:18 -0300 Subject: [PATCH 03/21] admin_api.guilds: remove unused import --- litecord/blueprints/admin_api/guilds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litecord/blueprints/admin_api/guilds.py b/litecord/blueprints/admin_api/guilds.py index f658cf6..4e64fb5 100644 --- a/litecord/blueprints/admin_api/guilds.py +++ b/litecord/blueprints/admin_api/guilds.py @@ -17,7 +17,7 @@ along with this program. If not, see . """ -from quart import Blueprint, jsonify, current_app as app, request +from quart import Blueprint, jsonify, current_app as app from litecord.auth import admin_check # from litecord.schemas import validate From 85749f2c8eca0f32cb407c7222eab93adb884c7a Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 21 Apr 2019 21:33:26 -0300 Subject: [PATCH 04/21] admin_api.users: add basic flag change support --- litecord/blueprints/admin_api/users.py | 28 +++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/litecord/blueprints/admin_api/users.py b/litecord/blueprints/admin_api/users.py index 8a84be8..3260970 100644 --- a/litecord/blueprints/admin_api/users.py +++ b/litecord/blueprints/admin_api/users.py @@ -23,11 +23,12 @@ from litecord.auth import admin_check from litecord.blueprints.auth import create_user from litecord.schemas import validate from litecord.admin_schemas import USER_CREATE, USER_UPDATE -from litecord.errors import BadRequest +from litecord.errors import BadRequest, Forbidden from litecord.utils import async_map from litecord.blueprints.users import ( delete_user, user_disconnect, mass_user_update ) +from litecord.enums import UserFlags bp = Blueprint('users_admin', __name__) @@ -119,20 +120,29 @@ async def _delete_single_user(user_id: int): 'new': new_user }) + @bp.route('/', methods=['PATCH']) async def patch_user(user_id: int): await admin_check() j = validate(await request.get_json(), USER_UPDATE) - # TODO: finish, at least flags. - # TODO: we MUST have a check so that users don't - # privilege escalate other users to the staff badge, since - # that just grants access to the admin api. + # get the original user for flags checking + user = await app.storage.get_user(user_id) + old_flags = UserFlags(user['flags']) if 'flags' in j: - pass + new_flags = UserFlags(j['flags']) - # TODO: decide if we return the public or private user. - _public_user, private_user = await mass_user_update(user_id, app) - return jsonify(private_user) + # disallow any changes to the staff badge + if new_flags.staff != old_flags.staff: + raise Forbidden('you can not change a users staff badge') + + await app.db.execute(""" + UPDATE users + SET flags = $1 + WHERE id = $2 + """, j['flags'], user_id) + + public_user, _ = await mass_user_update(user_id, app) + return jsonify(public_user) From 4a50ceeb679ab77ee899ac9a3d37d9af18d3e508 Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 21 Apr 2019 22:38:41 -0300 Subject: [PATCH 05/21] Add basic guild available updates - add litecord.guild_memory_store - storage: add fetch of unavailable field --- litecord/blueprints/admin_api/guilds.py | 13 ++++++++----- litecord/guild_memory_store.py | 17 +++++++++++++++++ litecord/storage.py | 6 ++++++ run.py | 2 ++ 4 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 litecord/guild_memory_store.py diff --git a/litecord/blueprints/admin_api/guilds.py b/litecord/blueprints/admin_api/guilds.py index 4e64fb5..0efe9ca 100644 --- a/litecord/blueprints/admin_api/guilds.py +++ b/litecord/blueprints/admin_api/guilds.py @@ -17,11 +17,11 @@ along with this program. If not, see . """ -from quart import Blueprint, jsonify, current_app as app +from quart import Blueprint, jsonify, current_app as app, request from litecord.auth import admin_check -# from litecord.schemas import validate -# from litecord.admin_schemas import GUILD_UPDATE +from litecord.schemas import validate +from litecord.admin_schemas import GUILD_UPDATE bp = Blueprint('guilds_admin', __name__) @@ -34,16 +34,19 @@ async def get_guild(guild_id: int): await app.storage.get_guild(guild_id) ) + @bp.route('/', methods=['PATCH']) async def update_guild(guild_id: int): await admin_check() - # j = validate(await request.get_json(), GUILD_UPDATE) + j = validate(await request.get_json(), GUILD_UPDATE) - # TODO: add guild availability update, we don't store it, should we? # TODO: what happens to the other guild attributes when its # unavailable? do they vanish? + if 'unavailable' in j: + app.guild_store.set(guild_id, 'unavailable', True) + guild = await app.storage.get_guild(guild_id) await app.dispatcher.dispatch_guild(guild_id, 'GUILD_UPDATE', guild) return jsonify(guild) diff --git a/litecord/guild_memory_store.py b/litecord/guild_memory_store.py new file mode 100644 index 0000000..f092f19 --- /dev/null +++ b/litecord/guild_memory_store.py @@ -0,0 +1,17 @@ + +class GuildMemoryStore: + """Store in-memory properties about guilds. + + I could have just used Redis... probably too overkill to add + aioredis to the already long depedency list, plus, I don't need + """ + def __init__(self): + self._store = {} + + def get(self, guild_id: int, attribute: str, default=None): + """get a key""" + return self._store.get(f'{guild_id}:{attribute}', default) + + def set(self, guild_id: int, attribute: str, value): + """set a key""" + self._store[f'{guild_id}:{attribute}'] = value diff --git a/litecord/storage.py b/litecord/storage.py index 3446fe2..136f84f 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -192,6 +192,12 @@ class Storage: drow['max_presences'] = 1000 drow['max_members'] = 1000 + # this is kept in memory + drow['unavailable'] = self.app.guild_store.get( + guild_id, 'unavailable', False) + + # TODO: strip everything when unavailable + return drow async def _member_basic(self, guild_id: int, member_id: int): diff --git a/run.py b/run.py index ef5e118..d851fdc 100644 --- a/run.py +++ b/run.py @@ -75,6 +75,7 @@ from litecord.presence import PresenceManager from litecord.images import IconManager from litecord.jobs import JobManager from litecord.voice.manager import VoiceManager +from litecord.guild_memory_store import GuildMemoryStore from litecord.gateway.gateway import websocket_handler @@ -246,6 +247,7 @@ def init_app_managers(app_): app_.storage.presence = app_.presence app_.voice = VoiceManager(app_) + app_.guild_store = GuildMemoryStore() async def api_index(app_): From e886a745b54395aaddc4518415e9ad299937bf75 Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 21 Apr 2019 22:39:34 -0300 Subject: [PATCH 06/21] guild_memory_store: add gpl header --- litecord/guild_memory_store.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/litecord/guild_memory_store.py b/litecord/guild_memory_store.py index f092f19..a2e3457 100644 --- a/litecord/guild_memory_store.py +++ b/litecord/guild_memory_store.py @@ -1,3 +1,21 @@ +""" + +Litecord +Copyright (C) 2018-2019 Luna Mendes + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +""" class GuildMemoryStore: """Store in-memory properties about guilds. From bb6055f009524835bdb73c572b3e9d971b33dd0f Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 21 Apr 2019 22:53:21 -0300 Subject: [PATCH 07/21] admin_api.guilds: switch between GUILD_CREATE / UPDATE --- litecord/blueprints/admin_api/guilds.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/litecord/blueprints/admin_api/guilds.py b/litecord/blueprints/admin_api/guilds.py index 0efe9ca..1de09b2 100644 --- a/litecord/blueprints/admin_api/guilds.py +++ b/litecord/blueprints/admin_api/guilds.py @@ -43,10 +43,24 @@ async def update_guild(guild_id: int): # TODO: what happens to the other guild attributes when its # unavailable? do they vanish? + old_unavailable = app.guild_store.get(guild_id, 'unavailable') + new_unavailable = j.get('unavailable', old_unavailable) - if 'unavailable' in j: - app.guild_store.set(guild_id, 'unavailable', True) + # always set unavailable status since new_unavailable will be + # old_unavailable when not provided, so we don't need to check if + # j.unavailable is there + app.guild_store.set(guild_id, 'unavailable', j['unavailable']) + # if this was unavailable but now its not, we must dispatch a + # GUILD_CREATE to the subscribers, not GUILD_UPDATE. GUILD_UPDATE + # is used on the reverse scenario. guild = await app.storage.get_guild(guild_id) - await app.dispatcher.dispatch_guild(guild_id, 'GUILD_UPDATE', guild) + + # TODO: maybe we can just check guild['unavailable']...? + + if old_unavailable and not new_unavailable: + await app.dispatcher.dispatch_guild(guild_id, 'GUILD_CREATE', guild) + else: + await app.dispatcher.dispatch_guild(guild_id, 'GUILD_UPDATE', guild) + return jsonify(guild) From ea9b06182d0b8ec74d1bfbe3072dfc404a0ea195 Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 21 Apr 2019 23:07:05 -0300 Subject: [PATCH 08/21] storage: rewrite the guild object as unavailable more at https://discordapp.com/developers/docs/resources/guild#unavailable-guild-object --- litecord/storage.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/litecord/storage.py b/litecord/storage.py index 136f84f..ce58025 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -192,11 +192,16 @@ class Storage: drow['max_presences'] = 1000 drow['max_members'] = 1000 - # this is kept in memory + # a guild's unavailable state is kept in memory, and we remove every + # other guild related field when its unavailable. drow['unavailable'] = self.app.guild_store.get( guild_id, 'unavailable', False) - # TODO: strip everything when unavailable + if drow['unavailable']: + drow = { + 'id': drow['id'], + 'unavailable': True + } return drow @@ -669,6 +674,9 @@ class Storage: if guild is None: return None + if guild['unavailable']: + return guild + extra = await self.get_guild_extra(guild_id, user_id, large_count) return {**guild, **extra} From a2e55d80e834b8e889435374e3fe4d4f73e1fbac Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 21 Apr 2019 23:08:45 -0300 Subject: [PATCH 09/21] storage: do unavailable guild object rewrite earlier on prevent unecessary db calls as soon as the guild is determined to be unavailable --- litecord/storage.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/litecord/storage.py b/litecord/storage.py index ce58025..79d07dd 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -180,6 +180,18 @@ class Storage: drow = dict(row) + # a guild's unavailable state is kept in memory, and we remove every + # other guild related field when its unavailable. + drow['unavailable'] = self.app.guild_store.get( + guild_id, 'unavailable', False) + + if drow['unavailable']: + drow = { + 'id': drow['id'], + 'unavailable': True + } + + # guild.owner is dependant of the user doing the get_guild call. if user_id: drow['owner'] = drow['owner_id'] == str(user_id) @@ -192,17 +204,6 @@ class Storage: drow['max_presences'] = 1000 drow['max_members'] = 1000 - # a guild's unavailable state is kept in memory, and we remove every - # other guild related field when its unavailable. - drow['unavailable'] = self.app.guild_store.get( - guild_id, 'unavailable', False) - - if drow['unavailable']: - drow = { - 'id': drow['id'], - 'unavailable': True - } - return drow async def _member_basic(self, guild_id: int, member_id: int): From f6bf8be94d036336656f696f2ecfd43834e67770 Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 21 Apr 2019 23:17:39 -0300 Subject: [PATCH 10/21] admin_api.guilds: use GUILD_DELETE instead of GUILD_UPDATE --- litecord/blueprints/admin_api/guilds.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/litecord/blueprints/admin_api/guilds.py b/litecord/blueprints/admin_api/guilds.py index 1de09b2..711ea26 100644 --- a/litecord/blueprints/admin_api/guilds.py +++ b/litecord/blueprints/admin_api/guilds.py @@ -51,16 +51,15 @@ async def update_guild(guild_id: int): # j.unavailable is there app.guild_store.set(guild_id, 'unavailable', j['unavailable']) - # if this was unavailable but now its not, we must dispatch a - # GUILD_CREATE to the subscribers, not GUILD_UPDATE. GUILD_UPDATE - # is used on the reverse scenario. guild = await app.storage.get_guild(guild_id) # TODO: maybe we can just check guild['unavailable']...? if old_unavailable and not new_unavailable: + # guild became available await app.dispatcher.dispatch_guild(guild_id, 'GUILD_CREATE', guild) else: - await app.dispatcher.dispatch_guild(guild_id, 'GUILD_UPDATE', guild) + # guild became unavailable + await app.dispatcher.dispatch_guild(guild_id, 'GUILD_DELETE', guild) return jsonify(guild) From aff4653454ff45f7ccd994f1a14ddbac93e8394c Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 00:31:54 -0300 Subject: [PATCH 11/21] docs/admin_api.md: add PATCH /users/:id to docs --- docs/admin_api.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/admin_api.md b/docs/admin_api.md index 6a04aff..1dd37a4 100644 --- a/docs/admin_api.md +++ b/docs/admin_api.md @@ -48,6 +48,26 @@ Output: | old | user object | old user object pre-delete | | new | user object | new user object post-delete | +### PATCH `/users/` + +Update a single user's information. + +Returns a user object on success. + +**Note:** You can not change any user's staff badge state (neither adding +it or removing it) to not cause privilege escalation/de-escalation (where +a staff makes more staff or a staff removes staff privileges of someone else). +Keep in mind the staff badge is what grants access to the Admin API, so. + +**Note:** Changing a user's nitro badge is not defined via the flags. +Plus that would require adding an interface to user payments +through the Admin API. + +[UserFlags]: https://discordapp.com/developers/docs/resources/user#user-object-user-flags + +| field | type | description | +| --: | :-- | :-- | +| flags | [UserFlags] | user flags/badges | ## Instance invites From 644d755332026eaf6d2264334c735199473e07e7 Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 01:11:34 -0300 Subject: [PATCH 12/21] test_admin_api/test_users: decouple user creation and deletion - remove hardcoding of 'big_girl' username on test_list_users --- tests/test_admin_api/test_users.py | 55 +++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/tests/test_admin_api/test_users.py b/tests/test_admin_api/test_users.py index 3ad51f7..75f914e 100644 --- a/tests/test_admin_api/test_users.py +++ b/tests/test_admin_api/test_users.py @@ -22,6 +22,8 @@ import secrets import pytest from tests.common import login +from tests.credentials import CREDS + async def _search(test_cli, *, username='', discrim='', token=None): if token is None: @@ -40,8 +42,7 @@ async def _search(test_cli, *, username='', discrim='', token=None): @pytest.mark.asyncio async def test_list_users(test_cli): """Try to list as many users as possible.""" - # NOTE: replace here if admin username changes - resp = await _search(test_cli, username='big_girl') + resp = await _search(test_cli, username=CREDS['admin']['username']) assert resp.status_code == 200 rjson = await resp.json @@ -49,11 +50,8 @@ async def test_list_users(test_cli): assert rjson -@pytest.mark.asyncio -async def test_create_delete(test_cli): - """Create a user. Then delete them.""" - token = await login('admin', test_cli) - +async def _setup_user(test_cli, *, token=None) -> dict: + token = token or await login('admin', test_cli) genned = secrets.token_hex(7) resp = await test_cli.post('/api/v6/admin/users', headers={ @@ -69,23 +67,46 @@ async def test_create_delete(test_cli): assert isinstance(rjson, dict) assert rjson['username'] == genned - genned_uid = rjson['id'] + return rjson - # check if side-effects went through with a search - resp = await _search(test_cli, username=genned, token=token) - assert resp.status_code == 200 - rjson = await resp.json - assert isinstance(rjson, list) - assert rjson[0]['id'] == genned_uid +async def _del_user(test_cli, user_id, *, token=None): + """Delete a user.""" + token = token or await login('admin', test_cli) - # delete - resp = await test_cli.delete(f'/api/v6/admin/users/{genned_uid}', headers={ + resp = await test_cli.delete(f'/api/v6/admin/users/{user_id}', headers={ 'Authorization': token }) assert resp.status_code == 200 rjson = await resp.json assert isinstance(rjson, dict) - assert rjson['new']['id'] == genned_uid + assert rjson['new']['id'] == user_id assert rjson['old']['id'] == rjson['new']['id'] + + +@pytest.mark.asyncio +async def test_create_delete(test_cli): + """Create a user. Then delete them.""" + token = await login('admin', test_cli) + + rjson = await _setup_user(test_cli, token=token) + + genned = rjson['username'] + genned_uid = rjson['id'] + + try: + # check if side-effects went through with a search + resp = await _search(test_cli, username=genned, token=token) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, list) + assert rjson[0]['id'] == genned_uid + finally: + await _del_user(test_cli, genned_uid, token=token) + + +async def test_user_update(test_cli): + """Test user update.""" + pass From e65db52c6213fe5dbbd5241f9e96b6eb03203abe Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 01:27:43 -0300 Subject: [PATCH 13/21] test_admin_api/test_users: add impl for test_user_update --- tests/test_admin_api/test_users.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/test_admin_api/test_users.py b/tests/test_admin_api/test_users.py index 75f914e..eb71368 100644 --- a/tests/test_admin_api/test_users.py +++ b/tests/test_admin_api/test_users.py @@ -23,6 +23,7 @@ import pytest from tests.common import login from tests.credentials import CREDS +from litecord.enums import UserFlags async def _search(test_cli, *, username='', discrim='', token=None): @@ -84,6 +85,9 @@ async def _del_user(test_cli, user_id, *, token=None): assert rjson['new']['id'] == user_id assert rjson['old']['id'] == rjson['new']['id'] + # TODO: remove from database at this point? it'll just keep being + # filled up every time we run a test.. + @pytest.mark.asyncio async def test_create_delete(test_cli): @@ -107,6 +111,30 @@ async def test_create_delete(test_cli): await _del_user(test_cli, genned_uid, token=token) +@pytest.mark.asyncio async def test_user_update(test_cli): """Test user update.""" - pass + token = await login('admin', test_cli) + rjson = await _setup_user(test_cli, token=token) + + user_id = rjson['id'] + + # test update + + try: + # set them as partner flag + resp = await test_cli.patch(f'/api/v6/admin/users/{user_id}', headers={ + 'Authorization': token + }, json={ + 'flags': UserFlags.partner, + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert rjson['id'] == user_id + assert rjson['flags'] == UserFlags.partner + + # TODO: maybe we can check for side effects by fetching the + # user manually too... + finally: + await _del_user(test_cli, user_id, token=token) From 717c02bdd7de304f5674badf1fbd73d703b3f4cd Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 01:45:26 -0300 Subject: [PATCH 14/21] admin_schemas: fix USER_UPDATE's coerce - admin_api.users: fix missing attrs / wrong calls - enums: add Flags.value attr for an instantiated Flags --- litecord/admin_schemas.py | 2 +- litecord/blueprints/admin_api/users.py | 9 +++++---- litecord/enums.py | 2 ++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/litecord/admin_schemas.py b/litecord/admin_schemas.py index 2796ba1..22d6bdb 100644 --- a/litecord/admin_schemas.py +++ b/litecord/admin_schemas.py @@ -56,5 +56,5 @@ GUILD_UPDATE = { } USER_UPDATE = { - 'flags': {'required': False, 'coerce': UserFlags} + 'flags': {'required': False, 'coerce': UserFlags.from_int} } diff --git a/litecord/blueprints/admin_api/users.py b/litecord/blueprints/admin_api/users.py index 3260970..ab3ba37 100644 --- a/litecord/blueprints/admin_api/users.py +++ b/litecord/blueprints/admin_api/users.py @@ -129,20 +129,21 @@ async def patch_user(user_id: int): # get the original user for flags checking user = await app.storage.get_user(user_id) - old_flags = UserFlags(user['flags']) + old_flags = UserFlags.from_int(user['flags']) + # j.flags is already a UserFlags since we coerce it. if 'flags' in j: - new_flags = UserFlags(j['flags']) + new_flags = j['flags'] # disallow any changes to the staff badge - if new_flags.staff != old_flags.staff: + if new_flags.is_staff != old_flags.is_staff: raise Forbidden('you can not change a users staff badge') await app.db.execute(""" UPDATE users SET flags = $1 WHERE id = $2 - """, j['flags'], user_id) + """, new_flags.value, user_id) public_user, _ = await mass_user_update(user_id, app) return jsonify(public_user) diff --git a/litecord/enums.py b/litecord/enums.py index 52cd742..98e036c 100644 --- a/litecord/enums.py +++ b/litecord/enums.py @@ -58,6 +58,8 @@ class Flags: def _make_int(value): res = Flags() + setattr(res, 'value', value) + for attr, val in attrs: # get only the ones that represent a field in the # number's bits From d8b889c1a9ffcb1a8385f816f48ec20b9ee34d25 Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 02:13:53 -0300 Subject: [PATCH 15/21] add tests/test_admin_api/test_guilds.py - guilds: decouple endpoint logic into a delete_guild() function --- litecord/blueprints/guilds.py | 25 ++++++----- tests/test_admin_api/test_guilds.py | 64 +++++++++++++++++++++++++++++ tests/test_admin_api/test_users.py | 3 +- 3 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 tests/test_admin_api/test_guilds.py diff --git a/litecord/blueprints/guilds.py b/litecord/blueprints/guilds.py index 781b1be..0bc7585 100644 --- a/litecord/blueprints/guilds.py +++ b/litecord/blueprints/guilds.py @@ -350,21 +350,17 @@ async def _update_guild(guild_id): return jsonify(guild) -@bp.route('/', methods=['DELETE']) -# this endpoint is not documented, but used by the official client. -@bp.route('//delete', methods=['POST']) -async def delete_guild(guild_id): - """Delete a guild.""" - user_id = await token_check() - await guild_owner_check(user_id, guild_id) +async def delete_guild(guild_id: int, *, app_=None): + """Delete a single guild.""" + app_ = app_ or app - await app.db.execute(""" + await app_.db.execute(""" DELETE FROM guilds WHERE guilds.id = $1 """, guild_id) # Discord's client expects IDs being string - await app.dispatcher.dispatch('guild', guild_id, 'GUILD_DELETE', { + await app_.dispatcher.dispatch('guild', guild_id, 'GUILD_DELETE', { 'guild_id': str(guild_id), 'id': str(guild_id), # 'unavailable': False, @@ -373,8 +369,17 @@ async def delete_guild(guild_id): # remove from the dispatcher so nobody # becomes the little memer that tries to fuck up with # everybody's gateway - await app.dispatcher.remove('guild', guild_id) + await app_.dispatcher.remove('guild', guild_id) + +@bp.route('/', methods=['DELETE']) +# this endpoint is not documented, but used by the official client. +@bp.route('//delete', methods=['POST']) +async def delete_guild_handler(guild_id): + """Delete a guild.""" + user_id = await token_check() + await guild_owner_check(user_id, guild_id) + await delete_guild(guild_id) return '', 204 diff --git a/tests/test_admin_api/test_guilds.py b/tests/test_admin_api/test_guilds.py new file mode 100644 index 0000000..86424da --- /dev/null +++ b/tests/test_admin_api/test_guilds.py @@ -0,0 +1,64 @@ +""" + +Litecord +Copyright (C) 2018-2019 Luna Mendes + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +""" + +import secrets + +import pytest + +from tests.common import login +from litecord.blueprints.guilds import delete_guild + +async def _create_guild(test_cli, *, token=None): + token = token or await login('admin', test_cli) + + genned_name = secrets.token_hex(6) + + resp = await test_cli.post('/api/v6/guilds', headers={ + 'Authorization': token + }, json={ + 'name': genned_name, + 'region': None + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['name'] == genned_name + + return rjson + + +@pytest.mark.asyncio +async def test_guild_fetch(test_cli): + """Test the creation and fetching of a guild via the Admin API.""" + token = await login('admin', test_cli) + rjson = await _create_guild(test_cli, token=token) + guild_id = rjson['id'] + + try: + resp = await test_cli.get(f'/api/v6/admin/guilds/{guild_id}', headers={ + 'Authorization': token + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['id'] == guild_id + finally: + await delete_guild(int(guild_id), app_=test_cli.app) diff --git a/tests/test_admin_api/test_users.py b/tests/test_admin_api/test_users.py index eb71368..832fb9b 100644 --- a/tests/test_admin_api/test_users.py +++ b/tests/test_admin_api/test_users.py @@ -27,8 +27,7 @@ from litecord.enums import UserFlags async def _search(test_cli, *, username='', discrim='', token=None): - if token is None: - token = await login('admin', test_cli) + token = token or await login('admin', test_cli) query_string = { 'username': username, From a6ecc84b9f7f3bddb22aa89b20b0e33c4f9ba5db Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 02:17:51 -0300 Subject: [PATCH 16/21] test_admin_api/test_guilds: add test_guild_update - docs/admin_api.md: add note on unavailable guild object being returned --- docs/admin_api.md | 4 ++-- tests/test_admin_api/test_guilds.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/admin_api.md b/docs/admin_api.md index 1dd37a4..9c4b87c 100644 --- a/docs/admin_api.md +++ b/docs/admin_api.md @@ -155,9 +155,9 @@ Returns a partial guild object. ### PATCH `/guilds/` Update a single guild. +Dispatches `GUILD_UPDATE` to subscribers of the guild. -Dispatches `GUILD_UPDATE` to subscribers of the guild, returns the guild object -on success. +Returns a guild object or an unavailable guild object on success. | field | type | description | | --: | :-- | :-- | diff --git a/tests/test_admin_api/test_guilds.py b/tests/test_admin_api/test_guilds.py index 86424da..c89e484 100644 --- a/tests/test_admin_api/test_guilds.py +++ b/tests/test_admin_api/test_guilds.py @@ -62,3 +62,33 @@ async def test_guild_fetch(test_cli): assert rjson['id'] == guild_id finally: await delete_guild(int(guild_id), app_=test_cli.app) + + +@pytest.mark.asyncio +async def test_guild_update(test_cli): + """Test the update of a guild via the Admin API.""" + token = await login('admin', test_cli) + rjson = await _create_guild(test_cli, token=token) + guild_id = rjson['id'] + assert not rjson['unavailable'] + + try: + # I believe setting up an entire gateway client registered to the guild + # would be overkill to test the side-effects, so... I'm not + # testing them. Yes, I know its a bad idea, but if someone has an easier + # way to write that, do send an MR. + resp = await test_cli.patch( + f'/api/v6/admin/guilds/{guild_id}', + headers={ + 'Authorization': token + }, json={ + 'unavailable': True + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['id'] == guild_id + assert rjson['unavailable'] + finally: + await delete_guild(int(guild_id), app_=test_cli.app) From b0c65c88dd28884e7527c0f2b00ccf99ca3074e8 Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 02:29:55 -0300 Subject: [PATCH 17/21] admin_api.guilds: add DELETE /api/v6/admin/guilds/:id --- litecord/blueprints/admin_api/guilds.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/litecord/blueprints/admin_api/guilds.py b/litecord/blueprints/admin_api/guilds.py index 711ea26..0c0f469 100644 --- a/litecord/blueprints/admin_api/guilds.py +++ b/litecord/blueprints/admin_api/guilds.py @@ -22,6 +22,7 @@ from quart import Blueprint, jsonify, current_app as app, request from litecord.auth import admin_check from litecord.schemas import validate from litecord.admin_schemas import GUILD_UPDATE +from litecord.blueprints.guilds import delete_guild bp = Blueprint('guilds_admin', __name__) @@ -63,3 +64,11 @@ async def update_guild(guild_id: int): await app.dispatcher.dispatch_guild(guild_id, 'GUILD_DELETE', guild) return jsonify(guild) + + +@bp.route('/', methods=['DELETE']) +async def delete_guild_as_admin(guild_id): + """Delete a single guild via the admin API without ownership checks.""" + await admin_check() + await delete_guild(guild_id) + return '', 204 From 4142baa84b12a1f3aca72fb361e2e2b71bd09144 Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 02:37:29 -0300 Subject: [PATCH 18/21] test_admin_api/test_guilds: add test_guild_delete --- tests/test_admin_api/test_guilds.py | 51 ++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/tests/test_admin_api/test_guilds.py b/tests/test_admin_api/test_guilds.py index c89e484..0e38602 100644 --- a/tests/test_admin_api/test_guilds.py +++ b/tests/test_admin_api/test_guilds.py @@ -44,6 +44,24 @@ async def _create_guild(test_cli, *, token=None): return rjson +async def _fetch_guild(test_cli, guild_id, *, token=None, ret_early=False): + token = token or await login('admin', test_cli) + + resp = await test_cli.get(f'/api/v6/admin/guilds/{guild_id}', headers={ + 'Authorization': token + }) + + if ret_early: + return resp + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['id'] == guild_id + + return rjson + + @pytest.mark.asyncio async def test_guild_fetch(test_cli): """Test the creation and fetching of a guild via the Admin API.""" @@ -52,14 +70,7 @@ async def test_guild_fetch(test_cli): guild_id = rjson['id'] try: - resp = await test_cli.get(f'/api/v6/admin/guilds/{guild_id}', headers={ - 'Authorization': token - }) - - assert resp.status_code == 200 - rjson = await resp.json - assert isinstance(rjson, dict) - assert rjson['id'] == guild_id + await _fetch_guild(test_cli, guild_id) finally: await delete_guild(int(guild_id), app_=test_cli.app) @@ -92,3 +103,27 @@ async def test_guild_update(test_cli): assert rjson['unavailable'] finally: await delete_guild(int(guild_id), app_=test_cli.app) + + +@pytest.mark.asyncio +async def test_guild_delete(test_cli): + """Test the update of a guild via the Admin API.""" + token = await login('admin', test_cli) + rjson = await _create_guild(test_cli, token=token) + guild_id = rjson['id'] + + try: + resp = await test_cli.delete( + f'/api/v6/admin/guilds/{guild_id}', + headers={ + 'Authorization': token + }) + + assert resp.status_code == 204 + + resp = await _fetch_guild(test_cli, guild_id, token=token, + ret_early=True) + + assert resp.status_code == 404 + finally: + await delete_guild(int(guild_id), app_=test_cli.app) From daee044a4fb93c6a3cd71ef17feb265c49286140 Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 02:41:24 -0300 Subject: [PATCH 19/21] admin_api.guilds: make get guild route raise GuildNotFound before it was returning 200 but with a `null` inside, I don't think that's reasonable API design. - test_admin_api/test_guilds: add checks for GuildNotFound error code --- docs/admin_api.md | 2 +- litecord/blueprints/admin_api/guilds.py | 10 +++++++--- tests/test_admin_api/test_guilds.py | 5 +++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/admin_api.md b/docs/admin_api.md index 9c4b87c..292bb9d 100644 --- a/docs/admin_api.md +++ b/docs/admin_api.md @@ -150,7 +150,7 @@ Returns empty body with 204 status code on success. ### GET `/guilds/` -Returns a partial guild object. +Returns a partial guild object. Gives a 404 when the guild is not found. ### PATCH `/guilds/` diff --git a/litecord/blueprints/admin_api/guilds.py b/litecord/blueprints/admin_api/guilds.py index 0c0f469..e27f544 100644 --- a/litecord/blueprints/admin_api/guilds.py +++ b/litecord/blueprints/admin_api/guilds.py @@ -23,6 +23,7 @@ from litecord.auth import admin_check from litecord.schemas import validate from litecord.admin_schemas import GUILD_UPDATE from litecord.blueprints.guilds import delete_guild +from litecord.errors import GuildNotFound bp = Blueprint('guilds_admin', __name__) @@ -31,9 +32,12 @@ async def get_guild(guild_id: int): """Get a basic guild payload.""" await admin_check() - return jsonify( - await app.storage.get_guild(guild_id) - ) + guild = await app.storage.get_guild(guild_id) + + if not guild: + raise GuildNotFound() + + return jsonify(guild) @bp.route('/', methods=['PATCH']) diff --git a/tests/test_admin_api/test_guilds.py b/tests/test_admin_api/test_guilds.py index 0e38602..c3b32f9 100644 --- a/tests/test_admin_api/test_guilds.py +++ b/tests/test_admin_api/test_guilds.py @@ -23,6 +23,7 @@ import pytest from tests.common import login from litecord.blueprints.guilds import delete_guild +from litecord.errors import GuildNotFound async def _create_guild(test_cli, *, token=None): token = token or await login('admin', test_cli) @@ -125,5 +126,9 @@ async def test_guild_delete(test_cli): ret_early=True) assert resp.status_code == 404 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['error'] + assert rjson['code'] == GuildNotFound.error_code finally: await delete_guild(int(guild_id), app_=test_cli.app) From 1507d422eb3f8625d99599636398afc0189104e5 Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 03:46:52 -0300 Subject: [PATCH 20/21] docs/admin_api.md: add missing guild delete endpoint --- docs/admin_api.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/admin_api.md b/docs/admin_api.md index 292bb9d..1627b57 100644 --- a/docs/admin_api.md +++ b/docs/admin_api.md @@ -163,6 +163,10 @@ Returns a guild object or an unavailable guild object on success. | --: | :-- | :-- | | unavailable | bool | if the guild is unavailable | +### DELETE `/guilds/` + +Delete a single guild. Returns 204 on success. + ## Guild features The currently supported features are: From eeaec19b2f33ef005054bd854bc0cebe6298445c Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 22 Apr 2019 03:51:39 -0300 Subject: [PATCH 21/21] test_admin_api.test_guilds: add another check for unavailable guild --- tests/test_admin_api/test_guilds.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_admin_api/test_guilds.py b/tests/test_admin_api/test_guilds.py index c3b32f9..e3183f5 100644 --- a/tests/test_admin_api/test_guilds.py +++ b/tests/test_admin_api/test_guilds.py @@ -102,6 +102,9 @@ async def test_guild_update(test_cli): assert isinstance(rjson, dict) assert rjson['id'] == guild_id assert rjson['unavailable'] + + rjson = await _fetch_guild(test_cli, guild_id, token=token) + assert rjson['unavailable'] finally: await delete_guild(int(guild_id), app_=test_cli.app)