thermograph/backend/tests/notifications/test_discord_link.py

125 lines
5 KiB
Python
Raw Permalink Normal View History

"""The legacy /discord/* routes.
These are not duplicate coverage of tests/accounts/test_oauth.py they pin the two
compatibility promises that outlive the refactor:
* **/discord/link/callback is registered in Discord's developer portal.** If it
stops answering, every Discord login breaks until an operator edits the portal.
* The other /discord/* paths are what the deployed frontend calls, and frontend and
backend deploy independently, so a newer backend must keep serving an older one.
The flow itself lives in accounts/oauth.py and is tested there.
"""
import pytest
from fastapi.testclient import TestClient
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
from web import app as appmod
from accounts import db, oauth
V2 = "/thermograph/api/v2"
PW = "supersecret123"
@pytest.fixture(scope="module", autouse=True)
def _tables():
db.Base.metadata.create_all(db.sync_engine)
@pytest.fixture(autouse=True)
def _configured(monkeypatch):
monkeypatch.setitem(oauth.CREDENTIALS, "discord", ("app123", "secret456"))
monkeypatch.setattr(oauth.httpx, "AsyncClient", _MockClient)
class _Resp:
def __init__(self, code, data): self.status_code = code; self._data = data
def json(self): return self._data
class _MockClient:
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", "email": "legacy@example.com",
"verified": True})
def _login(client, email):
assert client.post(f"{V2}/auth/register",
json={"email": email, "password": PW}).status_code in (201, 400)
assert client.post(f"{V2}/auth/login",
data={"username": email, "password": PW}).status_code == 204
def test_legacy_config_still_reports_enabled_state(monkeypatch):
c = TestClient(appmod.app)
monkeypatch.setitem(oauth.CREDENTIALS, "discord", ("", ""))
assert c.get(f"{V2}/discord/config").json() == {"enabled": False}
monkeypatch.setitem(oauth.CREDENTIALS, "discord", ("app123", "secret456"))
assert c.get(f"{V2}/discord/config").json() == {"enabled": True}
def test_legacy_link_start_still_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_legacy_start_redirects_to_discord():
c = TestClient(appmod.app)
_login(c, "legacy-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
# The redirect_uri it asks Discord to call back on must be the registered one.
assert "discord%2Flink%2Fcallback" in loc
def test_the_registered_callback_url_still_completes_a_link():
c = TestClient(appmod.app)
_login(c, "legacy-cb@example.com")
uid = c.get(f"{V2}/users/me").json()["id"]
state = oauth._sign_state(uid, "link", "discord")
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"]
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] == "discord-999" # the DM delivery address
assert me["oauth_providers"] == ["discord"] # and the identity row
# Legacy unlink clears both.
assert c.post(f"{V2}/discord/unlink").status_code == 204
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] is None and me["oauth_providers"] == []
def test_the_callback_still_emits_the_discord_query_param():
"""The deployed frontend reads ?discord=<status>, not ?oauth=. Dropping it would
silently stop it showing any outcome at all."""
c = TestClient(appmod.app)
r = c.get(f"{V2}/discord/link/callback?error=access_denied", follow_redirects=False)
loc = r.headers["location"]
assert "discord=cancelled" in loc and "oauth=cancelled" in loc
def test_users_me_still_carries_the_deprecated_discord_only_field(monkeypatch):
"""Renamed to oauth_only, but the deployed frontend still reads the old name."""
monkeypatch.setattr(oauth.httpx, "AsyncClient", _MockClient)
c = TestClient(appmod.app)
r = c.get(f"{V2}/discord/link/callback?code=abc"
f"&state={oauth._sign_state(None, 'login', 'discord')}",
follow_redirects=False)
assert "discord=created" in r.headers["location"]
me = c.get(f"{V2}/users/me").json()
assert me["oauth_only"] is True and me["discord_only"] is True
def test_legacy_login_start_is_still_mounted():
c = TestClient(appmod.app)
r = c.get(f"{V2}/discord/login/start", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"].startswith("https://discord.com/api/oauth2/authorize")