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
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.
124 lines
5 KiB
Python
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")
|