thermograph/tests/test_discord_link.py
Emi Griffith 41e84060ab Link a Thermograph account to Discord via OAuth2 (#208)
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
2026-07-20 02:26:33 +00:00

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"]