From 5e823893a3dcb1d4300caa727d56c79121769977 Mon Sep 17 00:00:00 2001 From: RuslanUC Date: Sun, 10 Mar 2024 17:24:09 +0200 Subject: [PATCH 1/6] adding connections --- .gitignore | 2 - STATUS.md | 19 +++- config.example.py | 7 ++ yepcord/asgi.py | 3 +- yepcord/gateway/events.py | 21 +++- yepcord/rest_api/main.py | 2 + yepcord/rest_api/models/connections.py | 28 ++++++ yepcord/rest_api/models/users_me.py | 4 + yepcord/rest_api/routes/connections.py | 103 ++++++++++++++++++++ yepcord/rest_api/routes/users_me.py | 49 ++++++++-- yepcord/yepcord/config.py | 10 ++ yepcord/yepcord/errors.py | 2 + yepcord/yepcord/models/connected_account.py | 12 ++- yepcord/yepcord/models/user.py | 3 +- 14 files changed, 248 insertions(+), 17 deletions(-) create mode 100644 yepcord/rest_api/models/connections.py create mode 100644 yepcord/rest_api/routes/connections.py diff --git a/.gitignore b/.gitignore index e539e71..76996a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ __pycache__ env* -!migrations/env.py /files /.idea endpoints.* @@ -16,7 +15,6 @@ tests/api/files settings*_prod.py other/ip_database.mmdb.old migrations* -migrations *.sqlite *.sqlite-* dist \ No newline at end of file diff --git a/STATUS.md b/STATUS.md index 54c8ad2..8ccb3e0 100644 --- a/STATUS.md +++ b/STATUS.md @@ -44,7 +44,24 @@ - [x] Remove - [x] Block - [x] Notes - - [ ] Connections + - [ ] Connections: + - [ ] PayPal + - [ ] Reddit + - [ ] Steam + - [ ] TikTok + - [ ] Twitter + - [ ] eBay + - [ ] PlayStation Network + - [ ] Spotify + - [ ] Xbox + - [ ] Battle.net + - [ ] Epic Games + - [ ] Facebook + - [x] Github + - [ ] League of Legends + - [ ] Riot Games + - [ ] Twitch + - [ ] YouTube - [x] OAuth2 - [ ] Bots: - [x] Create, edit, delete diff --git a/config.example.py b/config.example.py index f5e1a99..41cad45 100644 --- a/config.example.py +++ b/config.example.py @@ -89,3 +89,10 @@ "secret": "", }, } + +CONNECTIONS = { + "github": { + "client_id": None, + "client_secret": None, + } +} diff --git a/yepcord/asgi.py b/yepcord/asgi.py index b47f9ed..e5e2a1d 100644 --- a/yepcord/asgi.py +++ b/yepcord/asgi.py @@ -23,7 +23,7 @@ import yepcord.gateway.main as gateway import yepcord.cdn.main as cdn import yepcord.remote_auth.main as remote_auth -from yepcord.rest_api.routes import auth +from yepcord.rest_api.routes import auth, connections from yepcord.rest_api.routes import users_me from yepcord.rest_api.routes import users from yepcord.rest_api.routes import channels @@ -72,6 +72,7 @@ app.register_blueprint(teams.teams, url_prefix="/api/v9/teams") app.register_blueprint(oauth2.oauth2, url_prefix="/api/v9/oauth2") app.register_blueprint(interactions.interactions, url_prefix="/api/v9/interactions") +app.register_blueprint(connections.connections, url_prefix="/api/v9/connections") app.register_blueprint(other.other, url_prefix="/") app.route("/api/v9/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])(rest_api.other_api_endpoints) diff --git a/yepcord/gateway/events.py b/yepcord/gateway/events.py index aa448d9..890f113 100644 --- a/yepcord/gateway/events.py +++ b/yepcord/gateway/events.py @@ -24,7 +24,7 @@ from ..yepcord.config import Config from ..yepcord.enums import GatewayOp -from ..yepcord.models import Emoji, Application, Integration +from ..yepcord.models import Emoji, Application, Integration, ConnectedAccount from ..yepcord.models.interaction import Interaction from ..yepcord.snowflake import Snowflake @@ -108,7 +108,9 @@ async def json(self) -> dict: "session_id": self.client.sid, "presences": [], # TODO "relationships": await self.core.getRelationships(self.user), - "connected_accounts": [], # TODO + "connected_accounts": [ + conn.ds_json() for conn in await ConnectedAccount.filter(user=self.user, verified=True) + ], "consents": { "personalization": { "consented": settings.personalization @@ -1037,3 +1039,18 @@ class InteractionFailureEvent(InteractionSuccessEvent): NAME = "INTERACTION_FAILURE" +class UserConnectionsUpdate(DispatchEvent): + NAME = "USER_CONNECTIONS_UPDATE" + + def __init__(self, connection: ConnectedAccount): + self.connection = connection + + async def json(self) -> dict: + return { + "t": self.NAME, + "op": self.OP, + "d": { + **self.connection.ds_json(), + "token_data": None, + } + } diff --git a/yepcord/rest_api/main.py b/yepcord/rest_api/main.py index cc7be8c..30fdec8 100644 --- a/yepcord/rest_api/main.py +++ b/yepcord/rest_api/main.py @@ -25,6 +25,7 @@ from .routes.applications import applications from .routes.auth import auth from .routes.channels import channels +from .routes.connections import connections from .routes.gifs import gifs from .routes.guilds import guilds from .routes.hypesquad import hypesquad @@ -123,6 +124,7 @@ async def set_cors_headers(response: Response) -> Response: app.register_blueprint(teams, url_prefix="/api/v9/teams") app.register_blueprint(oauth2, url_prefix="/api/v9/oauth2") app.register_blueprint(interactions, url_prefix="/api/v9/interactions") +app.register_blueprint(connections, url_prefix="/api/v9/connections") app.register_blueprint(other, url_prefix="/") diff --git a/yepcord/rest_api/models/connections.py b/yepcord/rest_api/models/connections.py new file mode 100644 index 0000000..46395ac --- /dev/null +++ b/yepcord/rest_api/models/connections.py @@ -0,0 +1,28 @@ +""" + YEPCord: Free open source selfhostable fully discord-compatible chat + Copyright (C) 2022-2024 RuslanUC + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +""" + +from typing import Optional + +from pydantic import BaseModel + + +class ConnectionCallback(BaseModel): + state: str + insecure: bool + friend_sync: bool + code: Optional[str] = None diff --git a/yepcord/rest_api/models/users_me.py b/yepcord/rest_api/models/users_me.py index 131a42c..ae7cf19 100644 --- a/yepcord/rest_api/models/users_me.py +++ b/yepcord/rest_api/models/users_me.py @@ -198,3 +198,7 @@ def validate_handshake_token(cls, value: str) -> str: except ValueError: raise InvalidDataErr(404, Errors.make(10012)) return value + + +class EditConnection(BaseModel): + visibility: bool diff --git a/yepcord/rest_api/routes/connections.py b/yepcord/rest_api/routes/connections.py new file mode 100644 index 0000000..a4e6574 --- /dev/null +++ b/yepcord/rest_api/routes/connections.py @@ -0,0 +1,103 @@ +""" + YEPCord: Free open source selfhostable fully discord-compatible chat + Copyright (C) 2022-2024 RuslanUC + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +""" + +from typing import Optional +from urllib.parse import quote + +from httpx import AsyncClient + +from ..dependencies import DepUser +from ..models.connections import ConnectionCallback +from ..y_blueprint import YBlueprint +from ...gateway.events import UserConnectionsUpdate +from ...yepcord.config import Config +from ...yepcord.ctx import getGw +from ...yepcord.errors import InvalidDataErr, Errors +from ...yepcord.models import User, ConnectedAccount + +# Base path is /api/vX/connections +connections = YBlueprint("connections", __name__) + + +def get_service_settings(service_name: str, check_field: Optional[str] = None) -> dict: + settings = Config.CONNECTIONS[service_name] + if check_field is not None and settings[check_field] is None: + raise InvalidDataErr(400, Errors.make(50035, {"provider_id": { + "code": "BASE_TYPE_INVALID", "message": "This connection has been disabled server-side." + }})) + + return settings + + +def parse_state(state: str) -> tuple[Optional[int], Optional[int]]: + state = state.split(".") + if len(state) != 2: + return None, None + user_id, real_state = state + if not user_id.isdigit() or not real_state.isdigit(): + return None, None + + return int(user_id), int(real_state) + + +@connections.get("/github/authorize") +async def connection_github_authorize(user: User = DepUser): + client_id = get_service_settings("github", "client_id")["client_id"] + callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/github/callback") + + conn, _ = await ConnectedAccount.get_or_create(user=user, type="github", verified=False) + + url = (f"https://github.com/login/oauth/authorize?client_id={client_id}&redirect_uri={callback_url}" + f"&scope=read%3Auser&state={user.id}.{conn.state}") + + return {"url": url} + + +@connections.post("/github/callback", body_cls=ConnectionCallback) +async def connection_github_callback(data: ConnectionCallback): + settings = get_service_settings("github", "client_id") + client_id = settings["client_id"] + client_secret = settings["client_secret"] + user_id, state = parse_state(data.state) + if user_id is None: + return "", 204 + if (conn := await ConnectedAccount.get_or_none(user__id=user_id, state=state, verified=False, type="github")) \ + is None: + return "", 204 + + async with AsyncClient() as cl: + resp = await cl.post(f"https://github.com/login/oauth/access_token?client_id={client_id}" + f"&client_secret={client_secret}&code={data.code}", headers={"Accept": "application/json"}) + if resp.status_code >= 400 or "error" in (j := resp.json()): + raise InvalidDataErr(400, Errors.make(0)) + + access_token = j["access_token"] + + resp = await cl.get("https://api.github.com/user", headers={"Authorization": f"Bearer {access_token}"}) + if resp.status_code >= 400: + raise InvalidDataErr(400, Errors.make(0)) + j = resp.json() + + if await ConnectedAccount.filter(type="github", service_id=j["id"]).exists(): + return "", 204 + + await conn.update(service_id=j["id"], name=j["login"], access_token=access_token, verified=True) + + await getGw().dispatch(UserConnectionsUpdate(conn), user_ids=[user_id]) + + return "", 204 diff --git a/yepcord/rest_api/routes/users_me.py b/yepcord/rest_api/routes/users_me.py index c7642f8..eb7d528 100644 --- a/yepcord/rest_api/routes/users_me.py +++ b/yepcord/rest_api/routes/users_me.py @@ -24,16 +24,19 @@ from ..dependencies import DepUser, DepSession, DepGuildMember, DepGuild, DepUserO from ..models.users_me import UserUpdate, UserProfileUpdate, ConsentSettingsUpdate, SettingsUpdate, PutNote, \ RelationshipRequest, SettingsProtoUpdate, MfaEnable, MfaDisable, MfaCodesVerification, RelationshipPut, \ - DmChannelCreate, DeleteRequest, GetScheduledEventsQuery, RemoteAuthLogin, RemoteAuthFinish, RemoteAuthCancel + DmChannelCreate, DeleteRequest, GetScheduledEventsQuery, RemoteAuthLogin, RemoteAuthFinish, RemoteAuthCancel, \ + EditConnection from ..y_blueprint import YBlueprint from ...gateway.events import RelationshipAddEvent, DMChannelCreateEvent, RelationshipRemoveEvent, UserUpdateEvent, \ - UserNoteUpdateEvent, UserSettingsProtoUpdateEvent, GuildDeleteEvent, GuildMemberRemoveEvent, UserDeleteEvent + UserNoteUpdateEvent, UserSettingsProtoUpdateEvent, GuildDeleteEvent, GuildMemberRemoveEvent, UserDeleteEvent, \ + UserConnectionsUpdate from ...yepcord.classes.other import MFA +from ...yepcord.config import Config from ...yepcord.ctx import getCore, getCDNStorage, getGw from ...yepcord.enums import RelationshipType from ...yepcord.errors import InvalidDataErr, Errors from ...yepcord.models import User, UserSettingsProto, FrecencySettings, UserNote, Session, UserData, Guild, \ - GuildMember, RemoteAuthSession, Relationship, Authorization, Bot + GuildMember, RemoteAuthSession, Relationship, Authorization, Bot, ConnectedAccount from ...yepcord.models.remote_auth_session import time_plus_150s from ...yepcord.proto import FrecencyUserSettings, PreloadedUserSettings from ...yepcord.utils import execute_after, validImage, getImage @@ -189,10 +192,44 @@ async def update_protobuf_frecency_settings(data: SettingsProtoUpdate, user: Use return {"settings": proto_string} -# noinspection PyUnusedLocal @users_me.get("/connections", oauth_scopes=["connections"]) -async def get_connections(user: User = DepUser): # TODO: add connections - return [] +async def get_connections(user: User = DepUser): + connections = await ConnectedAccount.filter(user=user) + return [conn.ds_json() for conn in connections] + + +@users_me.patch("/connections//", body_cls=EditConnection) +async def edit_connection(service: str, ext_id: str, data: EditConnection, user: User = DepUser): + if service not in {"github"}: + raise InvalidDataErr(400, Errors.make(50035, {"provider_id": { + "code": "ENUM_TYPE_COERCE", "message": f"Value '{service}' is not a valid enum value." + }})) + + connection = await ConnectedAccount.get_or_none(user=user, service_id=ext_id, type=service, verified=True) + if connection is None: + raise InvalidDataErr(404, Errors.make(10017)) + + await connection.update(**data.model_dump()) + + await getGw().dispatch(UserConnectionsUpdate(connection), user_ids=[user.id]) + return connection.ds_json() + + +@users_me.delete("/connections//") +async def delete_connection(service: str, ext_id: str, user: User = DepUser): + if service not in Config.CONNECTIONS: + raise InvalidDataErr(400, Errors.make(50035, {"provider_id": { + "code": "ENUM_TYPE_COERCE", "message": f"Value '{service}' is not a valid enum value." + }})) + + connection = await ConnectedAccount.get_or_none(user=user, service_id=ext_id, type=service, verified=True) + if connection is None: + raise InvalidDataErr(404, Errors.make(10017)) + + await connection.delete() + await getGw().dispatch(UserConnectionsUpdate(connection), user_ids=[user.id]) + + return "", 204 @users_me.post("/relationships", body_cls=RelationshipRequest) diff --git a/yepcord/yepcord/config.py b/yepcord/yepcord/config.py index 9022ce8..b2f584b 100644 --- a/yepcord/yepcord/config.py +++ b/yepcord/yepcord/config.py @@ -106,6 +106,15 @@ class ConfigCaptcha(BaseModel): recaptcha: ConfigCaptchaService = Field(default_factory=ConfigCaptchaService) +class ConfigConnectionGithub(BaseModel): + client_id: Optional[str] = None + client_secret: Optional[str] = None + + +class ConfigConnections(BaseModel): + github: ConfigConnectionGithub = Field(default_factory=ConfigConnectionGithub) + + class ConfigModel(BaseModel): DB_CONNECT_STRING: str = "sqlite:///db.sqlite" MAIL_CONNECT_STRING: str = "smtp://127.0.0.1:10025?timeout=3" @@ -121,6 +130,7 @@ class ConfigModel(BaseModel): GATEWAY_KEEP_ALIVE_DELAY: int = 45 BCRYPT_ROUNDS: int = 15 CAPTCHA: ConfigCaptcha = Field(default_factory=ConfigCaptcha) + CONNECTIONS: ConfigConnections = Field(default_factory=ConfigConnections) @field_validator("KEY") def validate_key(cls, value: str) -> str: diff --git a/yepcord/yepcord/errors.py b/yepcord/yepcord/errors.py index 065107c..c83c997 100644 --- a/yepcord/yepcord/errors.py +++ b/yepcord/yepcord/errors.py @@ -46,6 +46,8 @@ def __init__(self, uid, sid, sig): class _Errors: _instance = None + err_0 = "General error" + err_10002 = "Unknown Application" err_10003 = "Unknown Channel" err_10004 = "Unknown Guild" diff --git a/yepcord/yepcord/models/connected_account.py b/yepcord/yepcord/models/connected_account.py index fac6a2c..ef180f7 100644 --- a/yepcord/yepcord/models/connected_account.py +++ b/yepcord/yepcord/models/connected_account.py @@ -1,6 +1,6 @@ """ YEPCord: Free open source selfhostable fully discord-compatible chat - Copyright (C) 2022-2023 RuslanUC + Copyright (C) 2022-2024 RuslanUC This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published @@ -21,16 +21,17 @@ from tortoise import fields from ._utils import ChoicesValidator, SnowflakeField, Model +from ..config import Config from ..snowflake import Snowflake import yepcord.yepcord.models as models class ConnectedAccount(Model): id: int = SnowflakeField(pk=True) - service_id: str = fields.CharField(max_length=128, unique=True) + service_id: str = fields.CharField(max_length=255, index=True, null=True, default=None) user: models.User = fields.ForeignKeyField("models.User") - name: str = fields.TextField() - type: str = fields.CharField(max_length=64, validators=[ChoicesValidator(set())]) + name: str = fields.TextField(null=True, default=None) + type: str = fields.CharField(max_length=64, validators=[ChoicesValidator({*Config.CONNECTIONS.keys()})]) revoked: bool = fields.BooleanField(default=False) show_activity: bool = fields.BooleanField(default=True) verified: bool = fields.BooleanField(default=False) @@ -38,6 +39,8 @@ class ConnectedAccount(Model): metadata_visibility: int = fields.IntField(default=1, validators=[ChoicesValidator({0, 1})]) metadata: dict = fields.JSONField(default={}) access_token: Optional[str] = fields.TextField(null=True, default=None) + refresh_token: Optional[str] = fields.TextField(null=True, default=None) + token_expires_at: Optional[int] = fields.BigIntField(null=True, default=None) state: int = fields.BigIntField(default=Snowflake.makeId) def ds_json(self) -> dict: @@ -52,4 +55,5 @@ def ds_json(self) -> dict: "metadata_visibility": self.metadata_visibility, "id": self.service_id, "friend_sync": False, + "integrations": [], } diff --git a/yepcord/yepcord/models/user.py b/yepcord/yepcord/models/user.py index cea528a..a20391a 100644 --- a/yepcord/yepcord/models/user.py +++ b/yepcord/yepcord/models/user.py @@ -80,6 +80,7 @@ async def profile_json(self, other_user: User, with_mutual_guilds: bool = False, guild_id: int = None) -> dict: data = await self.data premium_since = self.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") + connections = await models.ConnectedAccount.filter(user=self, verified=True, visibility=1) data = { "user": { "id": str(self.id), @@ -94,7 +95,7 @@ async def profile_json(self, other_user: User, with_mutual_guilds: bool = False, "accent_color": data.accent_color, "bio": data.bio }, - "connected_accounts": [], # TODO + "connected_accounts": [conn.ds_json() for conn in connections], "premium_since": premium_since, "premium_guild_since": premium_since, "user_profile": { From adae368425e086f0f0980280e5c499e8dd8ac2f9 Mon Sep 17 00:00:00 2001 From: RuslanUC Date: Sun, 10 Mar 2024 17:52:41 +0200 Subject: [PATCH 2/6] add reddit --- STATUS.md | 2 +- config.example.py | 6 ++- yepcord/rest_api/routes/connections.py | 54 +++++++++++++++++++++++++- yepcord/yepcord/config.py | 5 ++- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/STATUS.md b/STATUS.md index 8ccb3e0..1d1f7a5 100644 --- a/STATUS.md +++ b/STATUS.md @@ -46,7 +46,7 @@ - [x] Notes - [ ] Connections: - [ ] PayPal - - [ ] Reddit + - [x] Reddit - [ ] Steam - [ ] TikTok - [ ] Twitter diff --git a/config.example.py b/config.example.py index 41cad45..2865cdc 100644 --- a/config.example.py +++ b/config.example.py @@ -94,5 +94,9 @@ "github": { "client_id": None, "client_secret": None, - } + }, + "reddit": { + "client_id": None, + "client_secret": None, + }, } diff --git a/yepcord/rest_api/routes/connections.py b/yepcord/rest_api/routes/connections.py index a4e6574..2e0267a 100644 --- a/yepcord/rest_api/routes/connections.py +++ b/yepcord/rest_api/routes/connections.py @@ -15,7 +15,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ - +from base64 import b64encode from typing import Optional from urllib.parse import quote @@ -58,7 +58,7 @@ def parse_state(state: str) -> tuple[Optional[int], Optional[int]]: @connections.get("/github/authorize") async def connection_github_authorize(user: User = DepUser): client_id = get_service_settings("github", "client_id")["client_id"] - callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/github/callback") + callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/github/callback", safe="") conn, _ = await ConnectedAccount.get_or_create(user=user, type="github", verified=False) @@ -101,3 +101,53 @@ async def connection_github_callback(data: ConnectionCallback): await getGw().dispatch(UserConnectionsUpdate(conn), user_ids=[user_id]) return "", 204 + + +@connections.get("/reddit/authorize") +async def connection_reddit_authorize(user: User = DepUser): + client_id = get_service_settings("reddit", "client_id")["client_id"] + callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/reddit/callback", safe="") + + conn, _ = await ConnectedAccount.get_or_create(user=user, type="reddit", verified=False) + + url = (f"https://www.reddit.com/api/v1/authorize?client_id={client_id}&redirect_uri={callback_url}" + f"&scope=identity&state={user.id}.{conn.state}&response_type=code") + + return {"url": url} + + +@connections.post("/reddit/callback", body_cls=ConnectionCallback) +async def connection_reddit_callback(data: ConnectionCallback): + callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/reddit/callback", safe="") + settings = get_service_settings("reddit", "client_id") + client_id = settings["client_id"] + client_secret = settings["client_secret"] + user_id, state = parse_state(data.state) + if user_id is None: + return "", 204 + if (conn := await ConnectedAccount.get_or_none(user__id=user_id, state=state, verified=False, type="reddit")) \ + is None: + return "", 204 + + async with AsyncClient() as cl: + resp = await cl.post(f"https://www.reddit.com/api/v1/access_token", auth=(client_id, client_secret), + headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}, + content=f"grant_type=authorization_code&code={data.code}&redirect_uri={callback_url}") + if resp.status_code >= 400 or "error" in (j := resp.json()): + raise InvalidDataErr(400, Errors.make(0)) + + access_token = j["access_token"] + + resp = await cl.get("https://oauth.reddit.com/api/v1/me", headers={"Authorization": f"Bearer {access_token}"}) + if resp.status_code >= 400: + raise InvalidDataErr(400, Errors.make(0)) + j = resp.json() + + if await ConnectedAccount.filter(type="reddit", service_id=j["id"]).exists(): + return "", 204 + + await conn.update(service_id=j["id"], name=j["name"], access_token=access_token, verified=True) + + await getGw().dispatch(UserConnectionsUpdate(conn), user_ids=[user_id]) + + return "", 204 diff --git a/yepcord/yepcord/config.py b/yepcord/yepcord/config.py index b2f584b..1ea42c9 100644 --- a/yepcord/yepcord/config.py +++ b/yepcord/yepcord/config.py @@ -106,13 +106,14 @@ class ConfigCaptcha(BaseModel): recaptcha: ConfigCaptchaService = Field(default_factory=ConfigCaptchaService) -class ConfigConnectionGithub(BaseModel): +class ConfigConnectionBase(BaseModel): client_id: Optional[str] = None client_secret: Optional[str] = None class ConfigConnections(BaseModel): - github: ConfigConnectionGithub = Field(default_factory=ConfigConnectionGithub) + github: ConfigConnectionBase = Field(default_factory=ConfigConnectionBase) + reddit: ConfigConnectionBase = Field(default_factory=ConfigConnectionBase) class ConfigModel(BaseModel): From d09bb099e4ba85a9dccda349ee9e3e256dac64c6 Mon Sep 17 00:00:00 2001 From: RuslanUC Date: Sun, 10 Mar 2024 18:52:26 +0200 Subject: [PATCH 3/6] add twitch --- STATUS.md | 2 +- config.example.py | 4 + yepcord/cli.py | 1 + yepcord/rest_api/routes/connections.py | 125 ++++---------------- yepcord/yepcord/classes/connections.py | 155 +++++++++++++++++++++++++ yepcord/yepcord/config.py | 1 + 6 files changed, 186 insertions(+), 102 deletions(-) create mode 100644 yepcord/yepcord/classes/connections.py diff --git a/STATUS.md b/STATUS.md index 1d1f7a5..a6d263d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -60,7 +60,7 @@ - [x] Github - [ ] League of Legends - [ ] Riot Games - - [ ] Twitch + - [x] Twitch - [ ] YouTube - [x] OAuth2 - [ ] Bots: diff --git a/config.example.py b/config.example.py index 2865cdc..1c88935 100644 --- a/config.example.py +++ b/config.example.py @@ -99,4 +99,8 @@ "client_id": None, "client_secret": None, }, + "twitch": { + "client_id": None, + "client_secret": None, + }, } diff --git a/yepcord/cli.py b/yepcord/cli.py index d256b48..0bda9dc 100644 --- a/yepcord/cli.py +++ b/yepcord/cli.py @@ -76,6 +76,7 @@ def run_all(config: str, host: str, port: int, reload: bool, ssl: bool) -> None: "forwarded_allow_ips": "'*'", "host": host, "port": port, + "timeout_graceful_shutdown": 1, } if reload: diff --git a/yepcord/rest_api/routes/connections.py b/yepcord/rest_api/routes/connections.py index 2e0267a..ac8c3d1 100644 --- a/yepcord/rest_api/routes/connections.py +++ b/yepcord/rest_api/routes/connections.py @@ -15,139 +15,62 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ -from base64 import b64encode -from typing import Optional -from urllib.parse import quote - -from httpx import AsyncClient from ..dependencies import DepUser from ..models.connections import ConnectionCallback from ..y_blueprint import YBlueprint from ...gateway.events import UserConnectionsUpdate -from ...yepcord.config import Config +from ...yepcord.classes.connections import ConnectionGithub, ConnectionReddit, ConnectionTwitch, BaseConnection from ...yepcord.ctx import getGw -from ...yepcord.errors import InvalidDataErr, Errors from ...yepcord.models import User, ConnectedAccount # Base path is /api/vX/connections connections = YBlueprint("connections", __name__) -def get_service_settings(service_name: str, check_field: Optional[str] = None) -> dict: - settings = Config.CONNECTIONS[service_name] - if check_field is not None and settings[check_field] is None: - raise InvalidDataErr(400, Errors.make(50035, {"provider_id": { - "code": "BASE_TYPE_INVALID", "message": "This connection has been disabled server-side." - }})) - - return settings +async def unified_callback(connection_cls: type[BaseConnection], data: ConnectionCallback, + user_login_field: str = "login"): + if (conn := await connection_cls.get_connection_from_state(data.state)) is None: + return "", 204 + if (access_token := await connection_cls.exchange_code(data.code)) is None: + return "", 204 -def parse_state(state: str) -> tuple[Optional[int], Optional[int]]: - state = state.split(".") - if len(state) != 2: - return None, None - user_id, real_state = state - if not user_id.isdigit() or not real_state.isdigit(): - return None, None + user_info = await connection_cls.get_user_info(access_token) + if await ConnectedAccount.filter(type=connection_cls.SERVICE_NAME, service_id=user_info["id"]).exists(): + return "", 204 - return int(user_id), int(real_state) + await conn.update(service_id=user_info["id"], name=user_info[user_login_field], access_token=access_token, + verified=True) + await getGw().dispatch(UserConnectionsUpdate(conn), user_ids=[int(data.state.split(".")[0])]) + return "", 204 @connections.get("/github/authorize") async def connection_github_authorize(user: User = DepUser): - client_id = get_service_settings("github", "client_id")["client_id"] - callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/github/callback", safe="") - - conn, _ = await ConnectedAccount.get_or_create(user=user, type="github", verified=False) - - url = (f"https://github.com/login/oauth/authorize?client_id={client_id}&redirect_uri={callback_url}" - f"&scope=read%3Auser&state={user.id}.{conn.state}") - - return {"url": url} + return {"url": await ConnectionGithub.authorize_url(user)} @connections.post("/github/callback", body_cls=ConnectionCallback) async def connection_github_callback(data: ConnectionCallback): - settings = get_service_settings("github", "client_id") - client_id = settings["client_id"] - client_secret = settings["client_secret"] - user_id, state = parse_state(data.state) - if user_id is None: - return "", 204 - if (conn := await ConnectedAccount.get_or_none(user__id=user_id, state=state, verified=False, type="github")) \ - is None: - return "", 204 - - async with AsyncClient() as cl: - resp = await cl.post(f"https://github.com/login/oauth/access_token?client_id={client_id}" - f"&client_secret={client_secret}&code={data.code}", headers={"Accept": "application/json"}) - if resp.status_code >= 400 or "error" in (j := resp.json()): - raise InvalidDataErr(400, Errors.make(0)) - - access_token = j["access_token"] - - resp = await cl.get("https://api.github.com/user", headers={"Authorization": f"Bearer {access_token}"}) - if resp.status_code >= 400: - raise InvalidDataErr(400, Errors.make(0)) - j = resp.json() - - if await ConnectedAccount.filter(type="github", service_id=j["id"]).exists(): - return "", 204 - - await conn.update(service_id=j["id"], name=j["login"], access_token=access_token, verified=True) - - await getGw().dispatch(UserConnectionsUpdate(conn), user_ids=[user_id]) - - return "", 204 + return await unified_callback(ConnectionGithub, data) @connections.get("/reddit/authorize") async def connection_reddit_authorize(user: User = DepUser): - client_id = get_service_settings("reddit", "client_id")["client_id"] - callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/reddit/callback", safe="") - - conn, _ = await ConnectedAccount.get_or_create(user=user, type="reddit", verified=False) - - url = (f"https://www.reddit.com/api/v1/authorize?client_id={client_id}&redirect_uri={callback_url}" - f"&scope=identity&state={user.id}.{conn.state}&response_type=code") - - return {"url": url} + return {"url": await ConnectionReddit.authorize_url(user)} @connections.post("/reddit/callback", body_cls=ConnectionCallback) async def connection_reddit_callback(data: ConnectionCallback): - callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/reddit/callback", safe="") - settings = get_service_settings("reddit", "client_id") - client_id = settings["client_id"] - client_secret = settings["client_secret"] - user_id, state = parse_state(data.state) - if user_id is None: - return "", 204 - if (conn := await ConnectedAccount.get_or_none(user__id=user_id, state=state, verified=False, type="reddit")) \ - is None: - return "", 204 + return await unified_callback(ConnectionReddit, data, "name") - async with AsyncClient() as cl: - resp = await cl.post(f"https://www.reddit.com/api/v1/access_token", auth=(client_id, client_secret), - headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}, - content=f"grant_type=authorization_code&code={data.code}&redirect_uri={callback_url}") - if resp.status_code >= 400 or "error" in (j := resp.json()): - raise InvalidDataErr(400, Errors.make(0)) - access_token = j["access_token"] +@connections.get("/twitch/authorize") +async def connection_twitch_authorize(user: User = DepUser): + return {"url": await ConnectionTwitch.authorize_url(user)} - resp = await cl.get("https://oauth.reddit.com/api/v1/me", headers={"Authorization": f"Bearer {access_token}"}) - if resp.status_code >= 400: - raise InvalidDataErr(400, Errors.make(0)) - j = resp.json() - - if await ConnectedAccount.filter(type="reddit", service_id=j["id"]).exists(): - return "", 204 - await conn.update(service_id=j["id"], name=j["name"], access_token=access_token, verified=True) - - await getGw().dispatch(UserConnectionsUpdate(conn), user_ids=[user_id]) - - return "", 204 +@connections.post("/twitch/callback", body_cls=ConnectionCallback) +async def connection_twitch_callback(data: ConnectionCallback): + return await unified_callback(ConnectionTwitch, data) diff --git a/yepcord/yepcord/classes/connections.py b/yepcord/yepcord/classes/connections.py new file mode 100644 index 0000000..f70a8d5 --- /dev/null +++ b/yepcord/yepcord/classes/connections.py @@ -0,0 +1,155 @@ +from abc import ABC, abstractmethod +from typing import Optional +from urllib.parse import quote + +from httpx import AsyncClient + +from yepcord.yepcord.config import Config +from yepcord.yepcord.errors import InvalidDataErr, Errors +from yepcord.yepcord.models import User, ConnectedAccount + + +def get_service_settings(service_name: str, check_field: Optional[str] = None) -> dict: + settings = Config.CONNECTIONS[service_name] + if check_field is not None and settings[check_field] is None: + raise InvalidDataErr(400, Errors.make(50035, {"provider_id": { + "code": "BASE_TYPE_INVALID", "message": "This connection has been disabled server-side." + }})) + + return settings + + +def parse_state(state: str) -> tuple[Optional[int], Optional[int]]: + state = state.split(".") + if len(state) != 2: + return None, None + user_id, real_state = state + if not user_id.isdigit() or not real_state.isdigit(): + return None, None + + return int(user_id), int(real_state) + + +class BaseConnection(ABC): + SERVICE_NAME = "" + AUTHORIZE_URL = "" + TOKEN_URL = "" + USER_URL = "" + SCOPE: list[str] = [] + + @classmethod + async def authorize_url(cls, user: User) -> str: + client_id = get_service_settings(cls.SERVICE_NAME, "client_id")["client_id"] + callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/{cls.SERVICE_NAME}/callback", safe="") + + conn, _ = await ConnectedAccount.get_or_create(user=user, type=cls.SERVICE_NAME, verified=False) + + scope = quote(" ".join(cls.SCOPE)) + return (f"{cls.AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback_url}&scope={scope}" + f"&state={user.id}.{conn.state}") + + @classmethod + @abstractmethod + def exchange_code_req(cls, code: str, settings: dict[str, str]) -> tuple[str, dict]: ... + + @classmethod + async def get_connection_from_state(cls, state: str) -> Optional[ConnectedAccount]: + user_id, state = parse_state(state) + if user_id is None: + return + return await ConnectedAccount.get_or_none(user__id=user_id, state=state, verified=False, type=cls.SERVICE_NAME) + + @classmethod + async def exchange_code(cls, code: str) -> Optional[str]: + settings = get_service_settings(cls.SERVICE_NAME, "client_id") + + async with AsyncClient() as cl: + url, kwargs = cls.exchange_code_req(code, settings) + resp = await cl.post(url, **kwargs) + if resp.status_code >= 400 or "error" in (j := resp.json()): + raise InvalidDataErr(400, Errors.make(0)) + + return j["access_token"] + + @classmethod + def user_info_req(cls, access_token: str) -> tuple[str, dict]: + return cls.USER_URL, {"headers": {"Authorization": f"Bearer {access_token}"}} + + @classmethod + async def get_user_info(cls, access_token: str) -> dict: + url, kwargs = cls.user_info_req(access_token) + async with AsyncClient() as cl: + resp = await cl.get(url, **kwargs) + if resp.status_code >= 400: + raise InvalidDataErr(400, Errors.make(0)) + return resp.json() + + +class ConnectionGithub(BaseConnection): + SERVICE_NAME = "github" + AUTHORIZE_URL = "https://github.com/login/oauth/authorize" + TOKEN_URL = "https://github.com/login/oauth/access_token" + USER_URL = "https://api.github.com/user" + SCOPE: list[str] = ["read:user"] + + @classmethod + def exchange_code_req(cls, code: str, settings: dict[str, str]) -> tuple[str, dict]: + url = f"{cls.TOKEN_URL}?client_id={settings['client_id']}&client_secret={settings['client_secret']}&code={code}" + kwargs = {"headers": {"Accept": "application/json"}} + + return url, kwargs + + +class ConnectionReddit(BaseConnection): + SERVICE_NAME = "reddit" + AUTHORIZE_URL = "https://www.reddit.com/api/v1/authorize" + TOKEN_URL = "https://www.reddit.com/api/v1/access_token" + USER_URL = "https://oauth.reddit.com/api/v1/me" + SCOPE: list[str] = ["identity"] + + @classmethod + async def authorize_url(cls, user: User) -> str: + return f"{await super(cls, ConnectionReddit).authorize_url(user)}&response_type=code" + + @classmethod + def exchange_code_req(cls, code: str, settings: dict[str, str]) -> tuple[str, dict]: + callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/reddit/callback", safe="") + kwargs = { + "auth": (settings["client_id"], settings["client_secret"]), + "headers": {"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}, + "content": f"grant_type=authorization_code&code={code}&redirect_uri={callback_url}", + } + + return cls.TOKEN_URL, kwargs + + +class ConnectionTwitch(BaseConnection): + SERVICE_NAME = "twitch" + AUTHORIZE_URL = "https://id.twitch.tv/oauth2/authorize" + TOKEN_URL = "https://id.twitch.tv/oauth2/token" + USER_URL = "https://api.twitch.tv/helix/users" + SCOPE: list[str] = ["channel_subscriptions", "channel_check_subscription", "channel:read:subscriptions"] + + @classmethod + async def authorize_url(cls, user: User) -> str: + return f"{await super(cls, ConnectionTwitch).authorize_url(user)}&response_type=code" + + @classmethod + def exchange_code_req(cls, code: str, settings: dict[str, str]) -> tuple[str, dict]: + callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/twitch/callback", safe="") + kwargs = { + "headers": {"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}, + "content": f"grant_type=authorization_code&code={code}&redirect_uri={callback_url}" + f"&client_id={settings['client_id']}&client_secret={settings['client_secret']}", + } + + return cls.TOKEN_URL, kwargs + + @classmethod + def user_info_req(cls, access_token: str) -> tuple[str, dict]: + client_id = get_service_settings(cls.SERVICE_NAME, "client_id")["client_id"] + return cls.USER_URL, {"headers": {"Authorization": f"Bearer {access_token}", "Client-Id": client_id}} + + @classmethod + async def get_user_info(cls, access_token: str) -> dict: + return (await super(cls, ConnectionTwitch).get_user_info(access_token))["data"][0] diff --git a/yepcord/yepcord/config.py b/yepcord/yepcord/config.py index 1ea42c9..5bb385a 100644 --- a/yepcord/yepcord/config.py +++ b/yepcord/yepcord/config.py @@ -114,6 +114,7 @@ class ConfigConnectionBase(BaseModel): class ConfigConnections(BaseModel): github: ConfigConnectionBase = Field(default_factory=ConfigConnectionBase) reddit: ConfigConnectionBase = Field(default_factory=ConfigConnectionBase) + twitch: ConfigConnectionBase = Field(default_factory=ConfigConnectionBase) class ConfigModel(BaseModel): From 6a183690a9f42f754f76b3f5cf956072c37cd47f Mon Sep 17 00:00:00 2001 From: RuslanUC Date: Sun, 10 Mar 2024 19:02:13 +0200 Subject: [PATCH 4/6] add spotify --- STATUS.md | 2 +- config.example.py | 4 ++++ yepcord/rest_api/models/users_me.py | 3 ++- yepcord/rest_api/routes/connections.py | 13 ++++++++++++- yepcord/rest_api/routes/users_me.py | 4 ++-- yepcord/yepcord/classes/connections.py | 24 ++++++++++++++++++++++++ yepcord/yepcord/config.py | 1 + 7 files changed, 46 insertions(+), 5 deletions(-) diff --git a/STATUS.md b/STATUS.md index a6d263d..c8d9ef5 100644 --- a/STATUS.md +++ b/STATUS.md @@ -52,7 +52,7 @@ - [ ] Twitter - [ ] eBay - [ ] PlayStation Network - - [ ] Spotify + - [x] Spotify - [ ] Xbox - [ ] Battle.net - [ ] Epic Games diff --git a/config.example.py b/config.example.py index 1c88935..df55bb8 100644 --- a/config.example.py +++ b/config.example.py @@ -103,4 +103,8 @@ "client_id": None, "client_secret": None, }, + "spotify": { + "client_id": None, + "client_secret": None, + }, } diff --git a/yepcord/rest_api/models/users_me.py b/yepcord/rest_api/models/users_me.py index ae7cf19..bb7e2f6 100644 --- a/yepcord/rest_api/models/users_me.py +++ b/yepcord/rest_api/models/users_me.py @@ -201,4 +201,5 @@ def validate_handshake_token(cls, value: str) -> str: class EditConnection(BaseModel): - visibility: bool + visibility: Optional[bool] = None + show_activity: Optional[bool] = None diff --git a/yepcord/rest_api/routes/connections.py b/yepcord/rest_api/routes/connections.py index ac8c3d1..8406f64 100644 --- a/yepcord/rest_api/routes/connections.py +++ b/yepcord/rest_api/routes/connections.py @@ -20,7 +20,8 @@ from ..models.connections import ConnectionCallback from ..y_blueprint import YBlueprint from ...gateway.events import UserConnectionsUpdate -from ...yepcord.classes.connections import ConnectionGithub, ConnectionReddit, ConnectionTwitch, BaseConnection +from ...yepcord.classes.connections import ConnectionGithub, ConnectionReddit, ConnectionTwitch, BaseConnection, \ + ConnectionSpotify from ...yepcord.ctx import getGw from ...yepcord.models import User, ConnectedAccount @@ -74,3 +75,13 @@ async def connection_twitch_authorize(user: User = DepUser): @connections.post("/twitch/callback", body_cls=ConnectionCallback) async def connection_twitch_callback(data: ConnectionCallback): return await unified_callback(ConnectionTwitch, data) + + +@connections.get("/spotify/authorize") +async def connection_spotify_authorize(user: User = DepUser): + return {"url": await ConnectionSpotify.authorize_url(user)} + + +@connections.post("/spotify/callback", body_cls=ConnectionCallback) +async def connection_spotify_callback(data: ConnectionCallback): + return await unified_callback(ConnectionSpotify, data, "display_name") diff --git a/yepcord/rest_api/routes/users_me.py b/yepcord/rest_api/routes/users_me.py index eb7d528..e420791 100644 --- a/yepcord/rest_api/routes/users_me.py +++ b/yepcord/rest_api/routes/users_me.py @@ -200,7 +200,7 @@ async def get_connections(user: User = DepUser): @users_me.patch("/connections//", body_cls=EditConnection) async def edit_connection(service: str, ext_id: str, data: EditConnection, user: User = DepUser): - if service not in {"github"}: + if service not in Config.CONNECTIONS: raise InvalidDataErr(400, Errors.make(50035, {"provider_id": { "code": "ENUM_TYPE_COERCE", "message": f"Value '{service}' is not a valid enum value." }})) @@ -209,7 +209,7 @@ async def edit_connection(service: str, ext_id: str, data: EditConnection, user: if connection is None: raise InvalidDataErr(404, Errors.make(10017)) - await connection.update(**data.model_dump()) + await connection.update(**data.model_dump(exclude_none=True)) await getGw().dispatch(UserConnectionsUpdate(connection), user_ids=[user.id]) return connection.ds_json() diff --git a/yepcord/yepcord/classes/connections.py b/yepcord/yepcord/classes/connections.py index f70a8d5..2d33bf7 100644 --- a/yepcord/yepcord/classes/connections.py +++ b/yepcord/yepcord/classes/connections.py @@ -153,3 +153,27 @@ def user_info_req(cls, access_token: str) -> tuple[str, dict]: @classmethod async def get_user_info(cls, access_token: str) -> dict: return (await super(cls, ConnectionTwitch).get_user_info(access_token))["data"][0] + + +class ConnectionSpotify(BaseConnection): + SERVICE_NAME = "spotify" + AUTHORIZE_URL = "https://accounts.spotify.com/authorize" + TOKEN_URL = "https://accounts.spotify.com/api/token" + USER_URL = "https://api.spotify.com/v1/me" + SCOPE: list[str] = ["user-read-private", "user-read-playback-state", "user-modify-playback-state", + "user-read-currently-playing"] + + @classmethod + async def authorize_url(cls, user: User) -> str: + return f"{await super(cls, ConnectionSpotify).authorize_url(user)}&response_type=code" + + @classmethod + def exchange_code_req(cls, code: str, settings: dict[str, str]) -> tuple[str, dict]: + callback_url = quote(f"https://{Config.PUBLIC_HOST}/connections/spotify/callback", safe="") + kwargs = { + "auth": (settings["client_id"], settings["client_secret"]), + "headers": {"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}, + "content": f"grant_type=authorization_code&code={code}&redirect_uri={callback_url}", + } + + return cls.TOKEN_URL, kwargs diff --git a/yepcord/yepcord/config.py b/yepcord/yepcord/config.py index 5bb385a..6431da5 100644 --- a/yepcord/yepcord/config.py +++ b/yepcord/yepcord/config.py @@ -115,6 +115,7 @@ class ConfigConnections(BaseModel): github: ConfigConnectionBase = Field(default_factory=ConfigConnectionBase) reddit: ConfigConnectionBase = Field(default_factory=ConfigConnectionBase) twitch: ConfigConnectionBase = Field(default_factory=ConfigConnectionBase) + spotify: ConfigConnectionBase = Field(default_factory=ConfigConnectionBase) class ConfigModel(BaseModel): From 5a16898f511b53569e1cf5dc0ea1e825f819fdd7 Mon Sep 17 00:00:00 2001 From: RuslanUC Date: Sun, 10 Mar 2024 19:11:36 +0200 Subject: [PATCH 5/6] update example config --- config.example.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/config.example.py b/config.example.py index df55bb8..10661b0 100644 --- a/config.example.py +++ b/config.example.py @@ -36,7 +36,7 @@ } } -# Acquire tenor api key from https://developers.google.com/tenor/guides/quickstart and set this variable to enable gifs +# Acquire tenor api key from https://developers.google.com/tenor/guides/quickstart and set this variable to enable gifs TENOR_KEY = None # Message broker used for communication between the API server and Gateway server. By default, 'ws' type is used @@ -90,20 +90,28 @@ }, } +# Settings for external application connections +# For every application, use https://PUBLIC_HOST/connections/SERVICE_NAME/callback as redirect (callback) url, +# for example, if you need to create GitHub app and your yepcord instance (frontend) is running on 127.0.0.1:8888, +# redirect url will be https://127.0.0.1:8888/connections/github/callback CONNECTIONS = { "github": { + # Create at https://github.com/settings/applications/new "client_id": None, "client_secret": None, }, "reddit": { + # Create at https://www.reddit.com/prefs/apps "client_id": None, "client_secret": None, }, "twitch": { + # Create at https://dev.twitch.tv/console/apps/create "client_id": None, "client_secret": None, }, "spotify": { + # Create at https://developer.spotify.com/dashboard/create "client_id": None, "client_secret": None, }, From 5d393d688f6c64392a583603128b842f73ad52df Mon Sep 17 00:00:00 2001 From: RuslanUC Date: Mon, 11 Mar 2024 13:59:41 +0200 Subject: [PATCH 6/6] add tests for connections --- poetry.lock | 218 +++++++++++++------------ pyproject.toml | 3 +- tests/api/test_connections.py | 202 +++++++++++++++++++++++ tests/httpx_mock_callbacks.py | 92 +++++++++++ yepcord/rest_api/routes/connections.py | 3 +- yepcord/rest_api/routes/users_me.py | 2 +- yepcord/yepcord/classes/connections.py | 2 +- 7 files changed, 417 insertions(+), 105 deletions(-) create mode 100644 tests/api/test_connections.py create mode 100644 tests/httpx_mock_callbacks.py diff --git a/poetry.lock b/poetry.lock index 90d63fc..60634d0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -498,63 +498,63 @@ files = [ [[package]] name = "coverage" -version = "7.4.2" +version = "7.4.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf54c3e089179d9d23900e3efc86d46e4431188d9a657f345410eecdd0151f50"}, - {file = "coverage-7.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fe6e43c8b510719b48af7db9631b5fbac910ade4bd90e6378c85ac5ac706382c"}, - {file = "coverage-7.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b98c89db1b150d851a7840142d60d01d07677a18f0f46836e691c38134ed18b"}, - {file = "coverage-7.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5f9683be6a5b19cd776ee4e2f2ffb411424819c69afab6b2db3a0a364ec6642"}, - {file = "coverage-7.4.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78cdcbf7b9cb83fe047ee09298e25b1cd1636824067166dc97ad0543b079d22f"}, - {file = "coverage-7.4.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2599972b21911111114100d362aea9e70a88b258400672626efa2b9e2179609c"}, - {file = "coverage-7.4.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ef00d31b7569ed3cb2036f26565f1984b9fc08541731ce01012b02a4c238bf03"}, - {file = "coverage-7.4.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:20a875bfd8c282985c4720c32aa05056f77a68e6d8bbc5fe8632c5860ee0b49b"}, - {file = "coverage-7.4.2-cp310-cp310-win32.whl", hash = "sha256:b3f2b1eb229f23c82898eedfc3296137cf1f16bb145ceab3edfd17cbde273fb7"}, - {file = "coverage-7.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7df95fdd1432a5d2675ce630fef5f239939e2b3610fe2f2b5bf21fa505256fa3"}, - {file = "coverage-7.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8ddbd158e069dded57738ea69b9744525181e99974c899b39f75b2b29a624e2"}, - {file = "coverage-7.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81a5fb41b0d24447a47543b749adc34d45a2cf77b48ca74e5bf3de60a7bd9edc"}, - {file = "coverage-7.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2412e98e70f16243be41d20836abd5f3f32edef07cbf8f407f1b6e1ceae783ac"}, - {file = "coverage-7.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb79414c15c6f03f56cc68fa06994f047cf20207c31b5dad3f6bab54a0f66ef"}, - {file = "coverage-7.4.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf89ab85027427d351f1de918aff4b43f4eb5f33aff6835ed30322a86ac29c9e"}, - {file = "coverage-7.4.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a178b7b1ac0f1530bb28d2e51f88c0bab3e5949835851a60dda80bff6052510c"}, - {file = "coverage-7.4.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:06fe398145a2e91edaf1ab4eee66149c6776c6b25b136f4a86fcbbb09512fd10"}, - {file = "coverage-7.4.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:18cac867950943fe93d6cd56a67eb7dcd2d4a781a40f4c1e25d6f1ed98721a55"}, - {file = "coverage-7.4.2-cp311-cp311-win32.whl", hash = "sha256:f72cdd2586f9a769570d4b5714a3837b3a59a53b096bb954f1811f6a0afad305"}, - {file = "coverage-7.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:d779a48fac416387dd5673fc5b2d6bd903ed903faaa3247dc1865c65eaa5a93e"}, - {file = "coverage-7.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:adbdfcda2469d188d79771d5696dc54fab98a16d2ef7e0875013b5f56a251047"}, - {file = "coverage-7.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac4bab32f396b03ebecfcf2971668da9275b3bb5f81b3b6ba96622f4ef3f6e17"}, - {file = "coverage-7.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:006d220ba2e1a45f1de083d5022d4955abb0aedd78904cd5a779b955b019ec73"}, - {file = "coverage-7.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3733545eb294e5ad274abe131d1e7e7de4ba17a144505c12feca48803fea5f64"}, - {file = "coverage-7.4.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42a9e754aa250fe61f0f99986399cec086d7e7a01dd82fd863a20af34cbce962"}, - {file = "coverage-7.4.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2ed37e16cf35c8d6e0b430254574b8edd242a367a1b1531bd1adc99c6a5e00fe"}, - {file = "coverage-7.4.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b953275d4edfab6cc0ed7139fa773dfb89e81fee1569a932f6020ce7c6da0e8f"}, - {file = "coverage-7.4.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32b4ab7e6c924f945cbae5392832e93e4ceb81483fd6dc4aa8fb1a97b9d3e0e1"}, - {file = "coverage-7.4.2-cp312-cp312-win32.whl", hash = "sha256:f5df76c58977bc35a49515b2fbba84a1d952ff0ec784a4070334dfbec28a2def"}, - {file = "coverage-7.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:34423abbaad70fea9d0164add189eabaea679068ebdf693baa5c02d03e7db244"}, - {file = "coverage-7.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b11f9c6587668e495cc7365f85c93bed34c3a81f9f08b0920b87a89acc13469"}, - {file = "coverage-7.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:51593a1f05c39332f623d64d910445fdec3d2ac2d96b37ce7f331882d5678ddf"}, - {file = "coverage-7.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69f1665165ba2fe7614e2f0c1aed71e14d83510bf67e2ee13df467d1c08bf1e8"}, - {file = "coverage-7.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3c8bbb95a699c80a167478478efe5e09ad31680931ec280bf2087905e3b95ec"}, - {file = "coverage-7.4.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:175f56572f25e1e1201d2b3e07b71ca4d201bf0b9cb8fad3f1dfae6a4188de86"}, - {file = "coverage-7.4.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8562ca91e8c40864942615b1d0b12289d3e745e6b2da901d133f52f2d510a1e3"}, - {file = "coverage-7.4.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d9a1ef0f173e1a19738f154fb3644f90d0ada56fe6c9b422f992b04266c55d5a"}, - {file = "coverage-7.4.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f40ac873045db4fd98a6f40387d242bde2708a3f8167bd967ccd43ad46394ba2"}, - {file = "coverage-7.4.2-cp38-cp38-win32.whl", hash = "sha256:d1b750a8409bec61caa7824bfd64a8074b6d2d420433f64c161a8335796c7c6b"}, - {file = "coverage-7.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b4ae777bebaed89e3a7e80c4a03fac434a98a8abb5251b2a957d38fe3fd30088"}, - {file = "coverage-7.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ff7f92ae5a456101ca8f48387fd3c56eb96353588e686286f50633a611afc95"}, - {file = "coverage-7.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:861d75402269ffda0b33af94694b8e0703563116b04c681b1832903fac8fd647"}, - {file = "coverage-7.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3507427d83fa961cbd73f11140f4a5ce84208d31756f7238d6257b2d3d868405"}, - {file = "coverage-7.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf711d517e21fb5bc429f5c4308fbc430a8585ff2a43e88540264ae87871e36a"}, - {file = "coverage-7.4.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c00e54f0bd258ab25e7f731ca1d5144b0bf7bec0051abccd2bdcff65fa3262c9"}, - {file = "coverage-7.4.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f8e845d894e39fb53834da826078f6dc1a933b32b1478cf437007367efaf6f6a"}, - {file = "coverage-7.4.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:840456cb1067dc350af9080298c7c2cfdddcedc1cb1e0b30dceecdaf7be1a2d3"}, - {file = "coverage-7.4.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c11ca2df2206a4e3e4c4567f52594637392ed05d7c7fb73b4ea1c658ba560265"}, - {file = "coverage-7.4.2-cp39-cp39-win32.whl", hash = "sha256:3ff5bdb08d8938d336ce4088ca1a1e4b6c8cd3bef8bb3a4c0eb2f37406e49643"}, - {file = "coverage-7.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:ac9e95cefcf044c98d4e2c829cd0669918585755dd9a92e28a1a7012322d0a95"}, - {file = "coverage-7.4.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:f593a4a90118d99014517c2679e04a4ef5aee2d81aa05c26c734d271065efcb6"}, - {file = "coverage-7.4.2.tar.gz", hash = "sha256:1a5ee18e3a8d766075ce9314ed1cb695414bae67df6a4b0805f5137d93d6f1cb"}, + {file = "coverage-7.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8580b827d4746d47294c0e0b92854c85a92c2227927433998f0d3320ae8a71b6"}, + {file = "coverage-7.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:718187eeb9849fc6cc23e0d9b092bc2348821c5e1a901c9f8975df0bc785bfd4"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:767b35c3a246bcb55b8044fd3a43b8cd553dd1f9f2c1eeb87a302b1f8daa0524"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae7f19afe0cce50039e2c782bff379c7e347cba335429678450b8fe81c4ef96d"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba3a8aaed13770e970b3df46980cb068d1c24af1a1968b7818b69af8c4347efb"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ee866acc0861caebb4f2ab79f0b94dbfbdbfadc19f82e6e9c93930f74e11d7a0"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:506edb1dd49e13a2d4cac6a5173317b82a23c9d6e8df63efb4f0380de0fbccbc"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd6545d97c98a192c5ac995d21c894b581f1fd14cf389be90724d21808b657e2"}, + {file = "coverage-7.4.3-cp310-cp310-win32.whl", hash = "sha256:f6a09b360d67e589236a44f0c39218a8efba2593b6abdccc300a8862cffc2f94"}, + {file = "coverage-7.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:18d90523ce7553dd0b7e23cbb28865db23cddfd683a38fb224115f7826de78d0"}, + {file = "coverage-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbbe5e739d45a52f3200a771c6d2c7acf89eb2524890a4a3aa1a7fa0695d2a47"}, + {file = "coverage-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:489763b2d037b164846ebac0cbd368b8a4ca56385c4090807ff9fad817de4113"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:451f433ad901b3bb00184d83fd83d135fb682d780b38af7944c9faeecb1e0bfe"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcc66e222cf4c719fe7722a403888b1f5e1682d1679bd780e2b26c18bb648cdc"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ec74cfef2d985e145baae90d9b1b32f85e1741b04cd967aaf9cfa84c1334f3"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:abbbd8093c5229c72d4c2926afaee0e6e3140de69d5dcd918b2921f2f0c8baba"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:35eb581efdacf7b7422af677b92170da4ef34500467381e805944a3201df2079"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8249b1c7334be8f8c3abcaaa996e1e4927b0e5a23b65f5bf6cfe3180d8ca7840"}, + {file = "coverage-7.4.3-cp311-cp311-win32.whl", hash = "sha256:cf30900aa1ba595312ae41978b95e256e419d8a823af79ce670835409fc02ad3"}, + {file = "coverage-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:18c7320695c949de11a351742ee001849912fd57e62a706d83dfc1581897fa2e"}, + {file = "coverage-7.4.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b51bfc348925e92a9bd9b2e48dad13431b57011fd1038f08316e6bf1df107d10"}, + {file = "coverage-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cdecaedea1ea9e033d8adf6a0ab11107b49571bbb9737175444cea6eb72328"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b2eccb883368f9e972e216c7b4c7c06cabda925b5f06dde0650281cb7666a30"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c00cdc8fa4e50e1cc1f941a7f2e3e0f26cb2a1233c9696f26963ff58445bac7"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9a4a8dd3dcf4cbd3165737358e4d7dfbd9d59902ad11e3b15eebb6393b0446e"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:062b0a75d9261e2f9c6d071753f7eef0fc9caf3a2c82d36d76667ba7b6470003"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ebe7c9e67a2d15fa97b77ea6571ce5e1e1f6b0db71d1d5e96f8d2bf134303c1d"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c0a120238dd71c68484f02562f6d446d736adcc6ca0993712289b102705a9a3a"}, + {file = "coverage-7.4.3-cp312-cp312-win32.whl", hash = "sha256:37389611ba54fd6d278fde86eb2c013c8e50232e38f5c68235d09d0a3f8aa352"}, + {file = "coverage-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:d25b937a5d9ffa857d41be042b4238dd61db888533b53bc76dc082cb5a15e914"}, + {file = "coverage-7.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:28ca2098939eabab044ad68850aac8f8db6bf0b29bc7f2887d05889b17346454"}, + {file = "coverage-7.4.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:280459f0a03cecbe8800786cdc23067a8fc64c0bd51dc614008d9c36e1659d7e"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c0cdedd3500e0511eac1517bf560149764b7d8e65cb800d8bf1c63ebf39edd2"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a9babb9466fe1da12417a4aed923e90124a534736de6201794a3aea9d98484e"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dec9de46a33cf2dd87a5254af095a409ea3bf952d85ad339751e7de6d962cde6"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:16bae383a9cc5abab9bb05c10a3e5a52e0a788325dc9ba8499e821885928968c"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2c854ce44e1ee31bda4e318af1dbcfc929026d12c5ed030095ad98197eeeaed0"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ce8c50520f57ec57aa21a63ea4f325c7b657386b3f02ccaedeccf9ebe27686e1"}, + {file = "coverage-7.4.3-cp38-cp38-win32.whl", hash = "sha256:708a3369dcf055c00ddeeaa2b20f0dd1ce664eeabde6623e516c5228b753654f"}, + {file = "coverage-7.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:1bf25fbca0c8d121a3e92a2a0555c7e5bc981aee5c3fdaf4bb7809f410f696b9"}, + {file = "coverage-7.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b253094dbe1b431d3a4ac2f053b6d7ede2664ac559705a704f621742e034f1f"}, + {file = "coverage-7.4.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:77fbfc5720cceac9c200054b9fab50cb2a7d79660609200ab83f5db96162d20c"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6679060424faa9c11808598504c3ab472de4531c571ab2befa32f4971835788e"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4af154d617c875b52651dd8dd17a31270c495082f3d55f6128e7629658d63765"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8640f1fde5e1b8e3439fe482cdc2b0bb6c329f4bb161927c28d2e8879c6029ee"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:69b9f6f66c0af29642e73a520b6fed25ff9fd69a25975ebe6acb297234eda501"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0842571634f39016a6c03e9d4aba502be652a6e4455fadb73cd3a3a49173e38f"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a78ed23b08e8ab524551f52953a8a05d61c3a760781762aac49f8de6eede8c45"}, + {file = "coverage-7.4.3-cp39-cp39-win32.whl", hash = "sha256:c0524de3ff096e15fcbfe8f056fdb4ea0bf497d584454f344d59fce069d3e6e9"}, + {file = "coverage-7.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:0209a6369ccce576b43bb227dc8322d8ef9e323d089c6f3f26a597b09cb4d2aa"}, + {file = "coverage-7.4.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:7cbde573904625509a3f37b6fecea974e363460b556a627c60dc2f47e2fffa51"}, + {file = "coverage-7.4.3.tar.gz", hash = "sha256:276f6077a5c61447a48d133ed13e759c09e62aff0dc84274a68dc18660104d52"}, ] [package.dependencies] @@ -848,13 +848,13 @@ files = [ [[package]] name = "httpcore" -version = "1.0.3" +version = "1.0.4" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpcore-1.0.3-py3-none-any.whl", hash = "sha256:9a6a501c3099307d9fd76ac244e08503427679b1e81ceb1d922485e2f2462ad2"}, - {file = "httpcore-1.0.3.tar.gz", hash = "sha256:5c0f9546ad17dac4d0772b0808856eb616eb8b48ce94f49ed819fd6982a8a544"}, + {file = "httpcore-1.0.4-py3-none-any.whl", hash = "sha256:ac418c1db41bade2ad53ae2f3834a3a0f5ae76b56cf5aa497d2d033384fc7d73"}, + {file = "httpcore-1.0.4.tar.gz", hash = "sha256:cb2839ccfcba0d2d3c1131d3c3e26dfc327326fbe7a5dc0dbfe9f6c9151bb022"}, ] [package.dependencies] @@ -865,17 +865,17 @@ h11 = ">=0.13,<0.15" asyncio = ["anyio (>=4.0,<5.0)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.24.0)"] +trio = ["trio (>=0.22.0,<0.25.0)"] [[package]] name = "httpx" -version = "0.26.0" +version = "0.27.0" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"}, - {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, ] [package.dependencies] @@ -940,22 +940,22 @@ files = [ [[package]] name = "importlib-metadata" -version = "7.0.1" +version = "7.0.2" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, - {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, + {file = "importlib_metadata-7.0.2-py3-none-any.whl", hash = "sha256:f4bc4c0c070c490abf4ce96d715f68e95923320370efb66143df00199bb6c100"}, + {file = "importlib_metadata-7.0.2.tar.gz", hash = "sha256:198f568f3230878cb1b44fbd7975f87906c22336dba2e4a7f05278c281fbd792"}, ] [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] [[package]] name = "iniconfig" @@ -1316,13 +1316,13 @@ files = [ [[package]] name = "packaging" -version = "23.2" +version = "24.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] [[package]] @@ -1656,13 +1656,13 @@ files = [ [[package]] name = "pytest" -version = "8.0.1" +version = "8.1.1" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.0.1-py3-none-any.whl", hash = "sha256:3e4f16fe1c0a9dc9d9389161c127c3edc5d810c38d6793042fb81d9f48a59fca"}, - {file = "pytest-8.0.1.tar.gz", hash = "sha256:267f6563751877d772019b13aacbe4e860d73fe8f651f28112e9ac37de7513ae"}, + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, ] [package.dependencies] @@ -1670,11 +1670,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.3.0,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +pluggy = ">=1.4,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" @@ -1712,6 +1712,24 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +[[package]] +name = "pytest-httpx" +version = "0.30.0" +description = "Send responses to httpx." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest-httpx-0.30.0.tar.gz", hash = "sha256:755b8edca87c974dd4f3605c374fda11db84631de3d163b99c0df5807023a19a"}, + {file = "pytest_httpx-0.30.0-py3-none-any.whl", hash = "sha256:6d47849691faf11d2532565d0c8e0e02b9f4ee730da31687feae315581d7520c"}, +] + +[package.dependencies] +httpx = "==0.27.*" +pytest = ">=7,<9" + +[package.extras] +testing = ["pytest-asyncio (==0.23.*)", "pytest-cov (==4.*)"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -1798,17 +1816,17 @@ pydantic = ["pydantic (>=2)"] [[package]] name = "redis" -version = "5.0.1" +version = "5.0.3" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.7" files = [ - {file = "redis-5.0.1-py3-none-any.whl", hash = "sha256:ed4802971884ae19d640775ba3b03aa2e7bd5e8fb8dfaed2decce4d0fc48391f"}, - {file = "redis-5.0.1.tar.gz", hash = "sha256:0dab495cd5753069d3bc650a0dde8a8f9edde16fc5691b689a566eda58100d0f"}, + {file = "redis-5.0.3-py3-none-any.whl", hash = "sha256:5da9b8fe9e1254293756c16c008e8620b3d15fcc6dde6babde9541850e72a32d"}, + {file = "redis-5.0.3.tar.gz", hash = "sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580"}, ] [package.dependencies] -async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2\""} +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} [package.extras] hiredis = ["hiredis (>=1.0.0)"] @@ -1816,34 +1834,34 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" [[package]] name = "s3lite" -version = "0.1.3" +version = "0.1.4" description = "Minimal async s3 implementation." optional = false python-versions = ">=3.9,<4.0" files = [ - {file = "s3lite-0.1.3-py3-none-any.whl", hash = "sha256:6d452f84c7da633b0776239629293483133c2bb0043e8f58dc1e829315d3fc38"}, - {file = "s3lite-0.1.3.tar.gz", hash = "sha256:24fee0f6cc06d9fd74c77c5929b4e19d5770acf76de7583f99f7bf8ab0f665af"}, + {file = "s3lite-0.1.4-py3-none-any.whl", hash = "sha256:c9a7bd78f6fc4a8d18f6b63e2b00292eb25a20155b4c524d11621deb045c2bcd"}, + {file = "s3lite-0.1.4.tar.gz", hash = "sha256:04de2fb28828059c0309660528c504e9475fc4f3f754baaceadcfd21ca27bd93"}, ] [package.dependencies] -httpx = ">=0.26.0,<0.27.0" +httpx = ">=0.27.0,<0.28.0" python-dateutil = ">=2.8.2,<3.0.0" [[package]] name = "setuptools" -version = "69.1.0" +version = "69.1.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.1.0-py3-none-any.whl", hash = "sha256:c054629b81b946d63a9c6e732bc8b2513a7c3ea645f11d0139a2191d735c60c6"}, - {file = "setuptools-69.1.0.tar.gz", hash = "sha256:850894c4195f09c4ed30dba56213bf7c3f21d86ed6bdaafb5df5972593bfc401"}, + {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, + {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -1858,13 +1876,13 @@ files = [ [[package]] name = "sniffio" -version = "1.3.0" +version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" files = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] [[package]] @@ -1894,13 +1912,13 @@ files = [ [[package]] name = "tomlkit" -version = "0.12.3" +version = "0.12.4" description = "Style preserving TOML library" optional = false python-versions = ">=3.7" files = [ - {file = "tomlkit-0.12.3-py3-none-any.whl", hash = "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba"}, - {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"}, + {file = "tomlkit-0.12.4-py3-none-any.whl", hash = "sha256:5cd82d48a3dd89dee1f9d64420aa20ae65cfbd00668d6f094d7578a78efbb77b"}, + {file = "tomlkit-0.12.4.tar.gz", hash = "sha256:7ca1cfc12232806517a8515047ba66a19369e71edf2439d0f5824f91032b6cc3"}, ] [[package]] @@ -1952,24 +1970,24 @@ test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6. [[package]] name = "types-protobuf" -version = "4.24.0.20240129" +version = "4.24.0.20240311" description = "Typing stubs for protobuf" optional = false python-versions = ">=3.8" files = [ - {file = "types-protobuf-4.24.0.20240129.tar.gz", hash = "sha256:8a83dd3b9b76a33e08d8636c5daa212ace1396418ed91837635fcd564a624891"}, - {file = "types_protobuf-4.24.0.20240129-py3-none-any.whl", hash = "sha256:23be68cc29f3f5213b5c5878ac0151706182874040e220cfb11336f9ee642ead"}, + {file = "types-protobuf-4.24.0.20240311.tar.gz", hash = "sha256:c80426f9fb9b21aee514691e96ab32a5cd694a82e2ac07964b352c3e7e0182bc"}, + {file = "types_protobuf-4.24.0.20240311-py3-none-any.whl", hash = "sha256:8e039486df058141cb221ab99f88c5878c08cca4376db1d84f63279860aa09cd"}, ] [[package]] name = "typing-extensions" -version = "4.9.0" +version = "4.10.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] [[package]] @@ -2380,4 +2398,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "969f56f39a6f7838ccea02ce4c90d0ecdd8e27fd7ff8174e7fefa72e60583f55" +content-hash = "cf66a8b349ad55f00c9335bb5102e20f55ce87ae2c6cb648fbc16f81ef81b56d" diff --git a/pyproject.toml b/pyproject.toml index 3037cf9..4a23cac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ uvloop = "0.19.0" async-timeout = "^4.0.3" aerich = "^0.7.2" yc-protobuf3-to-dict = "^0.3.0" -s3lite = "^0.1.3" +s3lite = "^0.1.4" fast-depends = ">=2.1.1,<2.2.0" [tool.poetry.group.dev.dependencies] @@ -75,6 +75,7 @@ pytest-asyncio = "0.23.5" pyftpdlib = "1.5.8" fake-s3 = "1.0.2" types-protobuf = "^4.24.0.4" +pytest-httpx = "^0.30.0" [build-system] requires = ["poetry-core"] diff --git a/tests/api/test_connections.py b/tests/api/test_connections.py new file mode 100644 index 0000000..3f180c3 --- /dev/null +++ b/tests/api/test_connections.py @@ -0,0 +1,202 @@ +import re +from os import urandom +from urllib import parse + +import pytest as pt +import pytest_asyncio +from pytest_httpx import HTTPXMock + +from tests.api.utils import TestClientType, create_users +from tests.httpx_mock_callbacks import github_oauth_token_exchange, github_oauth_user_get, reddit_oauth_token_exchange, \ + reddit_oauth_user_get, twitch_oauth_token_exchange, spotify_oauth_token_exchange, twitch_oauth_user_get, \ + spotify_oauth_user_get +from yepcord.rest_api.main import app +from yepcord.yepcord.config import Config + + +@pytest_asyncio.fixture(autouse=True) +async def setup_db(): + for func in app.before_serving_funcs: + await app.ensure_async(func)() + yield + for func in app.after_serving_funcs: + await app.ensure_async(func)() + + +httpx_token_callbacks = { + "github": (github_oauth_token_exchange, {"url": re.compile(r'https://github.com/login/oauth/access_token?.+')}), + "reddit": (reddit_oauth_token_exchange, {"url": "https://www.reddit.com/api/v1/access_token"}), + "twitch": (twitch_oauth_token_exchange, {"url": "https://id.twitch.tv/oauth2/token"}), + "spotify": (spotify_oauth_token_exchange, {"url": "https://accounts.spotify.com/api/token"}), +} +httpx_user_callbacks = { + "github": (github_oauth_user_get, {"url": "https://api.github.com/user"}), + "reddit": (reddit_oauth_user_get, {"url": "https://oauth.reddit.com/api/v1/me"}), + "twitch": (twitch_oauth_user_get, {"url": "https://api.twitch.tv/helix/users"}), + "spotify": (spotify_oauth_user_get, {"url": "https://api.spotify.com/v1/me"}), +} + + +@pt.mark.parametrize("service_name", ["github", "reddit", "twitch", "spotify"]) +@pt.mark.asyncio +async def test_connection(service_name: str, httpx_mock: HTTPXMock): + Config.update({"CONNECTIONS": {service_name: {"client_id": urandom(8).hex(), "client_secret": urandom(8).hex()}}}) + code = urandom(8).hex() + access_token = urandom(8).hex() + + client: TestClientType = app.test_client() + user = (await create_users(client, 1))[0] + headers = {"Authorization": user["token"]} + + cb, kw = httpx_token_callbacks[service_name] + httpx_mock.add_callback(cb(**Config.CONNECTIONS[service_name], code=code, access_token=access_token), **kw) + cb, kw = httpx_user_callbacks[service_name] + httpx_mock.add_callback(cb(access_token=access_token), **kw) + + resp = await client.get(f"/api/v9/connections/{service_name}/authorize", headers=headers) + assert resp.status_code == 200 + j = await resp.get_json() + state = dict(parse.parse_qsl(parse.urlsplit(j["url"]).query))["state"] + + resp = await client.post(f"/api/v9/connections/{service_name}/callback", headers=headers, + json={"code": code, "state": state, "insecure": False, "friend_sync": False}) + assert resp.status_code == 204, await resp.get_json() + + resp = await client.get("/api/v9/users/@me/connections", headers=headers) + assert resp.status_code == 200 + j = await resp.get_json() + assert len(j) == 1 + + +@pt.mark.asyncio +async def test_connection_wrong_state(): + client: TestClientType = app.test_client() + user = (await create_users(client, 1))[0] + headers = {"Authorization": user["token"]} + + state = "123.456" + resp = await client.post(f"/api/v9/connections/github/callback", headers=headers, + json={"code": "123456", "state": state, "insecure": False, "friend_sync": False}) + assert resp.status_code == 204, await resp.get_json() + + state = "abc-456" + resp = await client.post(f"/api/v9/connections/github/callback", headers=headers, + json={"code": "123456", "state": state, "insecure": False, "friend_sync": False}) + assert resp.status_code == 204, await resp.get_json() + + resp = await client.get("/api/v9/users/@me/connections", headers=headers) + assert resp.status_code == 200 + j = await resp.get_json() + assert len(j) == 0 + + +@pt.mark.asyncio +async def test_connection_wrong_code(httpx_mock: HTTPXMock): + Config.update({"CONNECTIONS": {"github": {"client_id": urandom(8).hex(), "client_secret": urandom(8).hex()}}}) + code = urandom(8).hex() + access_token = urandom(8).hex() + + client: TestClientType = app.test_client() + user = (await create_users(client, 1))[0] + headers = {"Authorization": user["token"]} + + cb, kw = httpx_token_callbacks["github"] + httpx_mock.add_callback(cb(**Config.CONNECTIONS["github"], code=code, access_token=access_token), **kw) + + resp = await client.get(f"/api/v9/connections/github/authorize", headers=headers) + assert resp.status_code == 200 + j = await resp.get_json() + state = dict(parse.parse_qsl(parse.urlsplit(j["url"]).query))["state"] + + resp = await client.post(f"/api/v9/connections/github/callback", headers=headers, + json={"code": code+"1", "state": state, "insecure": False, "friend_sync": False}) + assert resp.status_code == 400 + + resp = await client.get("/api/v9/users/@me/connections", headers=headers) + assert resp.status_code == 200 + j = await resp.get_json() + assert len(j) == 0 + + +@pt.mark.asyncio +async def test_connection_add_same_account_twice(httpx_mock: HTTPXMock): + Config.update({"CONNECTIONS": {"github": {"client_id": urandom(8).hex(), "client_secret": urandom(8).hex()}}}) + code = urandom(8).hex() + access_token = urandom(8).hex() + + client: TestClientType = app.test_client() + user = (await create_users(client, 1))[0] + headers = {"Authorization": user["token"]} + + cb, kw = httpx_token_callbacks["github"] + httpx_mock.add_callback(cb(**Config.CONNECTIONS["github"], code=code, access_token=access_token), **kw) + cb, kw = httpx_user_callbacks["github"] + httpx_mock.add_callback(cb(access_token=access_token), **kw) + + for _ in range(2): + resp = await client.get(f"/api/v9/connections/github/authorize", headers=headers) + assert resp.status_code == 200 + j = await resp.get_json() + state = dict(parse.parse_qsl(parse.urlsplit(j["url"]).query))["state"] + + resp = await client.post(f"/api/v9/connections/github/callback", headers=headers, + json={"code": code, "state": state, "insecure": False, "friend_sync": False}) + assert resp.status_code == 204, await resp.get_json() + + resp = await client.get("/api/v9/users/@me/connections", headers=headers) + assert resp.status_code == 200 + j = await resp.get_json() + assert len(j) == 1 + + +@pt.mark.asyncio +async def test_connection_edit_delete(httpx_mock: HTTPXMock): + Config.update({"CONNECTIONS": {"github": {"client_id": urandom(8).hex(), "client_secret": urandom(8).hex()}}}) + code = urandom(8).hex() + access_token = urandom(8).hex() + + client: TestClientType = app.test_client() + user = (await create_users(client, 1))[0] + headers = {"Authorization": user["token"]} + + cb, kw = httpx_token_callbacks["github"] + httpx_mock.add_callback(cb(**Config.CONNECTIONS["github"], code=code, access_token=access_token), **kw) + cb, kw = httpx_user_callbacks["github"] + httpx_mock.add_callback(cb(access_token=access_token), **kw) + + resp = await client.get(f"/api/v9/connections/github/authorize", headers=headers) + assert resp.status_code == 200 + j = await resp.get_json() + state = dict(parse.parse_qsl(parse.urlsplit(j["url"]).query))["state"] + + resp = await client.post(f"/api/v9/connections/github/callback", headers=headers, + json={"code": code, "state": state, "insecure": False, "friend_sync": False}) + assert resp.status_code == 204, await resp.get_json() + + resp = await client.get("/api/v9/users/@me/connections", headers=headers) + assert resp.status_code == 200 + j = await resp.get_json() + assert len(j) == 1 + + conn_id = j[0]["id"] + + resp = await client.patch(f"/api/v9/users/@me/connections/github1/{conn_id}", headers=headers, + json={"visibility": False}) + assert resp.status_code == 400 + + resp = await client.patch(f"/api/v9/users/@me/connections/github/{conn_id}1", headers=headers, + json={"visibility": False}) + assert resp.status_code == 404 + + resp = await client.patch(f"/api/v9/users/@me/connections/github/{conn_id}", headers=headers, + json={"visibility": False}) + assert resp.status_code == 200 + + resp = await client.delete(f"/api/v9/users/@me/connections/github1/{conn_id}", headers=headers) + assert resp.status_code == 400 + + resp = await client.delete(f"/api/v9/users/@me/connections/github/{conn_id}1", headers=headers) + assert resp.status_code == 404 + + resp = await client.delete(f"/api/v9/users/@me/connections/github/{conn_id}", headers=headers) + assert resp.status_code == 204 diff --git a/tests/httpx_mock_callbacks.py b/tests/httpx_mock_callbacks.py new file mode 100644 index 0000000..292aad1 --- /dev/null +++ b/tests/httpx_mock_callbacks.py @@ -0,0 +1,92 @@ +from httpx import Request, Response + +from yepcord.yepcord.utils import b64decode + + +def github_oauth_token_exchange(client_id: str, client_secret: str, code: str, access_token: str): + def _github_oauth_token_exchange(request: Request) -> Response: + params = request.url.params + if params["client_id"] != client_id or params["client_secret"] != client_secret or params["code"] != code: + return Response(status_code=400, json={"error": ""}) + + return Response(status_code=200, json={"access_token": access_token}) + + return _github_oauth_token_exchange + + +def github_oauth_user_get(access_token: str): + def _github_oauth_user_get(request: Request) -> Response: + if request.headers["Authorization"] != f"Bearer {access_token}": + return Response(status_code=401, json={"error": ""}) + + return Response(status_code=200, json={"id": str(int(f"0x{access_token[:6]}", 16)), "login": access_token[:8]}) + + return _github_oauth_user_get + + +def reddit_oauth_token_exchange(client_id: str, client_secret: str, code: str, access_token: str): + def _reddit_oauth_token_exchange(request: Request) -> Response: + params = {k: v for k, v in [param.split("=") for param in request.content.decode("utf8").split("&")]} + client_id_, client_secret_ = b64decode(request.headers["Authorization"][6:]).decode("utf8").split(":") + if params["code"] != code or client_id_ != client_id or client_secret_ != client_secret: + return Response(status_code=400, json={"error": ""}) + + return Response(status_code=200, json={"access_token": access_token}) + + return _reddit_oauth_token_exchange + + +def reddit_oauth_user_get(access_token: str): + def _reddit_oauth_user_get(request: Request) -> Response: + if request.headers["Authorization"] != f"Bearer {access_token}": + return Response(status_code=401, json={"error": ""}) + + return Response(status_code=200, json={"id": str(int(f"0x{access_token[:6]}", 16)), "name": access_token[:8]}) + + return _reddit_oauth_user_get + + +def twitch_oauth_token_exchange(client_id: str, client_secret: str, code: str, access_token: str): + def _twitch_oauth_token_exchange(request: Request) -> Response: + params = {k: v for k, v in [param.split("=") for param in request.content.decode("utf8").split("&")]} + if params["code"] != code or params["client_id"] != client_id or params["client_secret"] != client_secret: + return Response(status_code=400, json={"error": ""}) + + return Response(status_code=200, json={"access_token": access_token}) + + return _twitch_oauth_token_exchange + + +def twitch_oauth_user_get(access_token: str): + def _twitch_oauth_user_get(request: Request) -> Response: + if request.headers["Authorization"] != f"Bearer {access_token}": + return Response(status_code=401, json={"error": ""}) + + return Response(status_code=200, json={"data": [ + {"id": str(int(f"0x{access_token[:6]}", 16)), "login": access_token[:8]} + ]}) + + return _twitch_oauth_user_get + + +def spotify_oauth_token_exchange(client_id: str, client_secret: str, code: str, access_token: str): + def _spotify_oauth_token_exchange(request: Request) -> Response: + params = {k: v for k, v in [param.split("=") for param in request.content.decode("utf8").split("&")]} + client_id_, client_secret_ = b64decode(request.headers["Authorization"][6:]).decode("utf8").split(":") + if params["code"] != code or client_id_ != client_id or client_secret_ != client_secret: + return Response(status_code=400, json={"error": ""}) + + return Response(status_code=200, json={"access_token": access_token}) + + return _spotify_oauth_token_exchange + + +def spotify_oauth_user_get(access_token: str): + def _spotify_oauth_user_get(request: Request) -> Response: + if request.headers["Authorization"] != f"Bearer {access_token}": + return Response(status_code=401, json={"error": ""}) + + return Response(status_code=200, json={"id": str(int(f"0x{access_token[:6]}", 16)), + "display_name": access_token[:8]}) + + return _spotify_oauth_user_get diff --git a/yepcord/rest_api/routes/connections.py b/yepcord/rest_api/routes/connections.py index 8406f64..2bda83e 100644 --- a/yepcord/rest_api/routes/connections.py +++ b/yepcord/rest_api/routes/connections.py @@ -34,8 +34,7 @@ async def unified_callback(connection_cls: type[BaseConnection], data: Connectio if (conn := await connection_cls.get_connection_from_state(data.state)) is None: return "", 204 - if (access_token := await connection_cls.exchange_code(data.code)) is None: - return "", 204 + access_token = await connection_cls.exchange_code(data.code) user_info = await connection_cls.get_user_info(access_token) if await ConnectedAccount.filter(type=connection_cls.SERVICE_NAME, service_id=user_info["id"]).exists(): diff --git a/yepcord/rest_api/routes/users_me.py b/yepcord/rest_api/routes/users_me.py index e420791..72140a8 100644 --- a/yepcord/rest_api/routes/users_me.py +++ b/yepcord/rest_api/routes/users_me.py @@ -194,7 +194,7 @@ async def update_protobuf_frecency_settings(data: SettingsProtoUpdate, user: Use @users_me.get("/connections", oauth_scopes=["connections"]) async def get_connections(user: User = DepUser): - connections = await ConnectedAccount.filter(user=user) + connections = await ConnectedAccount.filter(user=user, verified=True) return [conn.ds_json() for conn in connections] diff --git a/yepcord/yepcord/classes/connections.py b/yepcord/yepcord/classes/connections.py index 2d33bf7..9081fc8 100644 --- a/yepcord/yepcord/classes/connections.py +++ b/yepcord/yepcord/classes/connections.py @@ -80,7 +80,7 @@ async def get_user_info(cls, access_token: str) -> dict: url, kwargs = cls.user_info_req(access_token) async with AsyncClient() as cl: resp = await cl.get(url, **kwargs) - if resp.status_code >= 400: + if resp.status_code >= 400: # pragma: no cover raise InvalidDataErr(400, Errors.make(0)) return resp.json()