thermograph/tests/test_discord_link.py
Emi Griffith c9abad3e13 Show 'Link Discord' only when Discord linking is configured (#213)
The account menu offered 'Link Discord' to every signed-in user even on a
server with no Discord OAuth app configured, where it dead-ends (the start route
303-bounces to /alerts). Add GET /api/v2/discord/config reporting whether linking
is enabled, and have the menu render the entry only when it is. On a server
without Discord set up nothing surfaces; setting the OAuth env vars later makes
the entry appear on its own — no code change needed to turn it on.
2026-07-20 04:36:44 +00:00

136 lines
5.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_config_reports_enabled_state(monkeypatch):
c = TestClient(appmod.app)
monkeypatch.setattr(dl, "CLIENT_ID", "")
monkeypatch.setattr(dl, "CLIENT_SECRET", "")
r = c.get(f"{V2}/discord/config") # no auth required
assert r.status_code == 200 and r.json() == {"enabled": False}
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
assert c.get(f"{V2}/discord/config").json() == {"enabled": True}
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"]