Lets a signed-in user connect their Discord account (OAuth2 identify), storing the Discord user id that a later feature (DM alerts) will deliver to. Standard authorization-code flow, all server-side: - backend/discord_link.py: /discord/link/start redirects to Discord's consent screen; /discord/link/callback exchanges the code, reads the Discord user id, and stores it; /discord/unlink forgets it. The `state` is signed with the app auth secret (stdlib hmac, no new dependency) and carries the Thermograph user id, so a callback can't be replayed or bound to another account. Every route requires an active session, so linking acts on whoever is actually logged in. - models.py: User.discord_id (unique, nullable). schemas.py exposes it on UserRead so the frontend can show link state. - app.py: the router under /api/v2/discord. - account.js: a "Link Discord" / "Unlink Discord" control in the account popover. - deploy/migrations/001-user-discord-id.sql: the manual column add for the existing prod accounts DB. This project has no Alembic — create_all only makes missing tables, so an added column needs a hand-applied migration (documented in-file). - env example: THERMOGRAPH_DISCORD_CLIENT_SECRET + the redirect to register. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
125 lines
5 KiB
Python
125 lines
5 KiB
Python
"""Discord account linking: signed-state integrity, OAuth callback (Discord HTTP
|
|
mocked), unlink, and auth gating. Reuses the throwaway accounts DB from conftest."""
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app as appmod
|
|
import db
|
|
import discord_link as dl
|
|
|
|
V2 = "/thermograph/api/v2"
|
|
PW = "supersecret123"
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def _tables():
|
|
db.Base.metadata.create_all(db.sync_engine)
|
|
|
|
|
|
def _login(client, email):
|
|
r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW})
|
|
assert r.status_code in (201, 400)
|
|
assert client.post(f"{V2}/auth/login", data={"username": email, "password": PW}).status_code == 204
|
|
|
|
|
|
# --- signed state ------------------------------------------------------------
|
|
|
|
def test_state_round_trips_and_rejects_tampering():
|
|
s = dl._sign_state("user-123")
|
|
assert dl._verify_state(s) == "user-123"
|
|
payload, mac = s.split(".", 1)
|
|
assert dl._verify_state(f"{payload}.{'0' * len(mac)}") is None # bad mac
|
|
assert dl._verify_state(payload) is None # no mac
|
|
assert dl._verify_state("garbage") is None
|
|
|
|
|
|
def test_state_expires(monkeypatch):
|
|
monkeypatch.setattr(dl, "_STATE_TTL", -1) # already expired
|
|
assert dl._verify_state(dl._sign_state("u")) is None
|
|
|
|
|
|
def test_state_is_secret_dependent(monkeypatch):
|
|
s = dl._sign_state("u")
|
|
monkeypatch.setattr(dl, "SECRET", dl.SECRET + "-different")
|
|
assert dl._verify_state(s) is None # signed under the old secret
|
|
|
|
|
|
# --- routes ------------------------------------------------------------------
|
|
|
|
def test_link_requires_auth():
|
|
c = TestClient(appmod.app)
|
|
assert c.get(f"{V2}/discord/link/start", follow_redirects=False).status_code == 401
|
|
assert c.post(f"{V2}/discord/unlink").status_code == 401
|
|
|
|
|
|
def test_start_redirects_to_discord(monkeypatch):
|
|
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
|
|
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
|
|
c = TestClient(appmod.app)
|
|
_login(c, "start@example.com")
|
|
r = c.get(f"{V2}/discord/link/start", follow_redirects=False)
|
|
assert r.status_code == 303
|
|
loc = r.headers["location"]
|
|
assert loc.startswith("https://discord.com/api/oauth2/authorize")
|
|
assert "client_id=app123" in loc and "scope=identify" in loc and "state=" in loc
|
|
|
|
|
|
def test_start_when_unconfigured_bounces_back(monkeypatch):
|
|
monkeypatch.setattr(dl, "CLIENT_ID", "")
|
|
monkeypatch.setattr(dl, "CLIENT_SECRET", "")
|
|
c = TestClient(appmod.app)
|
|
_login(c, "noconf@example.com")
|
|
r = c.get(f"{V2}/discord/link/start", follow_redirects=False)
|
|
assert r.status_code == 303 and "discord=unavailable" in r.headers["location"]
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self, code, data): self.status_code = code; self._data = data
|
|
def json(self): return self._data
|
|
|
|
|
|
class _MockClient:
|
|
"""Stands in for httpx.AsyncClient: token exchange then /users/@me."""
|
|
def __init__(self, *a, **k): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
async def post(self, url, **k): return _Resp(200, {"access_token": "tok"})
|
|
async def get(self, url, **k): return _Resp(200, {"id": "discord-999", "username": "someone"})
|
|
|
|
|
|
def test_callback_links_the_account(monkeypatch):
|
|
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
|
|
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
|
|
monkeypatch.setattr(dl.httpx, "AsyncClient", _MockClient)
|
|
c = TestClient(appmod.app)
|
|
_login(c, "callback@example.com")
|
|
uid = c.get(f"{V2}/users/me").json()["id"]
|
|
state = dl._sign_state(uid)
|
|
r = c.get(f"{V2}/discord/link/callback?code=abc&state={state}", follow_redirects=False)
|
|
assert r.status_code == 303 and "discord=linked" in r.headers["location"]
|
|
# /users/me now reports the linked id, and unlink clears it.
|
|
assert c.get(f"{V2}/users/me").json()["discord_id"] == "discord-999"
|
|
assert c.post(f"{V2}/discord/unlink").status_code == 204
|
|
assert c.get(f"{V2}/users/me").json()["discord_id"] is None
|
|
|
|
|
|
def test_callback_rejects_a_forged_state(monkeypatch):
|
|
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
|
|
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
|
|
monkeypatch.setattr(dl.httpx, "AsyncClient", _MockClient)
|
|
c = TestClient(appmod.app)
|
|
_login(c, "forged@example.com")
|
|
# State signed for a different user id must not link this session.
|
|
r = c.get(f"{V2}/discord/link/callback?code=abc&state={dl._sign_state('someone-else')}",
|
|
follow_redirects=False)
|
|
assert r.status_code == 303 and "discord=error" in r.headers["location"]
|
|
assert c.get(f"{V2}/users/me").json()["discord_id"] is None
|
|
|
|
|
|
def test_callback_without_code_is_cancelled(monkeypatch):
|
|
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
|
|
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
|
|
c = TestClient(appmod.app)
|
|
_login(c, "cancel@example.com")
|
|
r = c.get(f"{V2}/discord/link/callback?error=access_denied", follow_redirects=False)
|
|
assert r.status_code == 303 and "discord=cancelled" in r.headers["location"]
|