Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add connections #227

Merged
merged 6 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
__pycache__
env*
!migrations/env.py
/files
/.idea
endpoints.*
Expand All @@ -16,7 +15,6 @@ tests/api/files
settings*_prod.py
other/ip_database.mmdb.old
migrations*
migrations
*.sqlite
*.sqlite-*
dist
19 changes: 18 additions & 1 deletion STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,24 @@
- [x] Remove
- [x] Block
- [x] Notes
- [ ] Connections
- [ ] Connections:
- [ ] PayPal
- [x] Reddit
- [ ] Steam
- [ ] TikTok
- [ ] Twitter
- [ ] eBay
- [ ] PlayStation Network
- [x] Spotify
- [ ] Xbox
- [ ] Battle.net
- [ ] Epic Games
- [ ] Facebook
- [x] Github
- [ ] League of Legends
- [ ] Riot Games
- [x] Twitch
- [ ] YouTube
- [x] OAuth2
- [ ] Bots:
- [x] Create, edit, delete
Expand Down
29 changes: 28 additions & 1 deletion config.example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,3 +89,30 @@
"secret": "",
},
}

# 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,
},
}
218 changes: 118 additions & 100 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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"]
Expand Down
202 changes: 202 additions & 0 deletions tests/api/test_connections.py
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions tests/httpx_mock_callbacks.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading