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