thermograph/backend/tests/notifications/test_discord_link.py
Emi Griffith c17a4c3dd7
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m1s
PR build (required check) / build-backend (pull_request) Successful in 1m29s
PR build (required check) / gate (pull_request) Successful in 1s
accounts: sign in with Google, on a shared provider-agnostic OAuth engine
Adds Google as a second identity provider. Rather than a second copy of the
Discord flow, the flow itself moves to accounts/oauth.py and both providers
become configurations of it — account resolution is the security-critical
part, and a parallel hand-rolled copy is where a subtle divergence becomes a
takeover. A third provider is now a PROVIDERS entry and nothing else.

Identity moves to an oauth_account table keyed (provider, subject), so a
login resolves on the provider's own stable id rather than an email that can
be changed or reassigned. user.discord_id deliberately stays: it is the DM
delivery address notify.py reads, not merely an identity, and the Discord
link keeps writing it. discord_only becomes oauth_only, and the lockout guard
now refuses to unlink the *last* provider from a password-less account rather
than singling out Discord — with two linked, either may go.

Migration 0005 renames the flag and backfills an oauth_account row for every
existing discord_id. Without that backfill an already-linked user would stop
being recognised at login and would silently get a second account on their
next sign-in. It also drops a vestigial discord_only that 0003 re-adds on a
database whose 0001 already built oauth_only from current metadata, which
otherwise left fresh and upgraded databases with different schemas.

Compatibility, since the frontend deploys independently of this service:
/discord/link/callback keeps answering because that URL is registered in
Discord's developer portal, the other /discord/* routes stay because the
deployed frontend calls them, the callback still emits ?discord= alongside
?oauth=, and UserRead still carries discord_only as a computed alias.

Google needs THERMOGRAPH_GOOGLE_CLIENT_ID/_CLIENT_SECRET and its own
registered redirect URI; unset, it reports disabled and shows no UI.
2026-07-26 12:06:56 -07:00

124 lines
5 KiB
Python

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