thermograph/tests/notifications/test_discord_link.py
Emi Griffith d17ac794fd 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

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
from web import app as appmod
from accounts import db
from notifications 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"]