thermograph/backend/tests/notifications/test_discord_dm.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

187 lines
7.5 KiB
Python

"""Discord DM alerts: send_dm REST mechanics (mocked), the notify dispatch gate,
the on/off toggle endpoint, and that linking opts in."""
import types
import pytest
from fastapi.testclient import TestClient
from web import app as appmod
from accounts import db
from accounts import oauth
from notifications import discord
from notifications import notify
V2 = "/thermograph/api/v2"
PW = "supersecret123"
@pytest.fixture(scope="module", autouse=True)
def _tables():
db.Base.metadata.create_all(db.sync_engine)
# --- send_dm REST mechanics --------------------------------------------------
class _Resp:
def __init__(self, code, data=None): self.status_code = code; self._data = data or {}
def json(self): return self._data
def _mock_posts(monkeypatch, script):
"""Route discord._client.post by URL; `script` maps a URL substring -> _Resp."""
calls = []
def _post(url, json=None, headers=None, timeout=None):
calls.append((url, json))
for frag, resp in script.items():
if frag in url:
return resp
return _Resp(404)
monkeypatch.setattr(discord._client, "post", _post)
return calls
def test_send_dm_opens_channel_then_posts(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
calls = _mock_posts(monkeypatch, {
"/users/@me/channels": _Resp(200, {"id": "chan-1"}),
"/channels/chan-1/messages": _Resp(204),
})
ok = discord.send_dm("discord-9", "Tehran hit a Near Record high", "104F, 99th pct", "/day#x")
assert ok is True
assert calls[0][0].endswith("/users/@me/channels") and calls[0][1] == {"recipient_id": "discord-9"}
assert "/channels/chan-1/messages" in calls[1][0]
embed = calls[1][1]["embeds"][0]
assert embed["title"].startswith("Tehran") and embed["url"].endswith("/day#x")
def test_send_dm_disabled_or_no_recipient(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "")
assert discord.send_dm("d", "t", "b") is False
monkeypatch.setattr(discord, "BOT_TOKEN", "bot")
assert discord.send_dm("", "t", "b") is False
def test_send_dm_gives_up_if_channel_open_fails(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
calls = _mock_posts(monkeypatch, {"/users/@me/channels": _Resp(403)})
assert discord.send_dm("d", "t", "b") is False
assert len(calls) == 1 # never tried to post a message
def test_post_subscription_alert_posts_to_channel(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord, "SUBSCRIPTION_CHANNEL_ID", "chan-sub")
calls = _mock_posts(monkeypatch, {"/channels/chan-sub/messages": _Resp(204)})
ok = discord.post_subscription_alert("Seattle: unusually hot day", "97th pct", "/day#x")
assert ok is True
(url, body), = calls
assert url.endswith("/channels/chan-sub/messages")
embed = body["embeds"][0]
assert embed["title"].startswith("Seattle") and embed["url"].endswith("/day#x")
def test_post_subscription_alert_noop_without_config(monkeypatch):
# Channel set but no bot token, and bot token set but no channel: both no-op.
monkeypatch.setattr(discord, "BOT_TOKEN", "")
monkeypatch.setattr(discord, "SUBSCRIPTION_CHANNEL_ID", "chan-sub")
calls = _mock_posts(monkeypatch, {})
assert discord.post_subscription_alert("t", "b") is False
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord, "SUBSCRIPTION_CHANNEL_ID", "")
assert discord.post_subscription_alert("t", "b") is False
assert calls == [] # never touched the network
def test_bot_post_retries_once_on_429(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord.time, "sleep", lambda s: None) # don't actually wait
seq = iter([_Resp(429, {"retry_after": 0.01}), _Resp(200, {"id": "c"})])
monkeypatch.setattr(discord._client, "post", lambda *a, **k: next(seq))
resp = discord._bot_post("/users/@me/channels", {"recipient_id": "d"})
assert resp.status_code == 200 # the retry succeeded
# --- notify dispatch gate ----------------------------------------------------
def _fake_session(user):
return types.SimpleNamespace(get=lambda model, uid: user)
def _sub():
return types.SimpleNamespace(id="s1", user_id="u1", lat=47.6, lon=-122.3)
def test_discord_job_gathers_linked_opted_in_user(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: True)
user = types.SimpleNamespace(discord_id="d9", discord_dm=True)
job = notify._discord_job(_fake_session(user), _sub(), "T", "B", "2026-07-19")
assert job is not None and job[0] == "d9"
def test_discord_job_skips_unlinked_or_opted_out(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: True)
# linked but opted out
assert notify._discord_job(
_fake_session(types.SimpleNamespace(discord_id="d", discord_dm=False)),
_sub(), "T", "B", "2026-07-19") is None
# not linked
assert notify._discord_job(
_fake_session(types.SimpleNamespace(discord_id=None, discord_dm=True)),
_sub(), "T", "B", "2026-07-19") is None
def test_discord_job_noop_when_bot_unconfigured(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: False)
user = types.SimpleNamespace(discord_id="d", discord_dm=True)
assert notify._discord_job(_fake_session(user), _sub(), "T", "B", "2026-07-19") is None
def test_send_discord_job_calls_send_dm(monkeypatch):
sent = []
monkeypatch.setattr(discord, "send_dm", lambda *a, **k: sent.append(a) or True)
notify._send_discord_job(("d9", "T", "B", "/day#x"))
assert sent == [("d9", "T", "B", "/day#x")]
# --- the on/off toggle + link opt-in -----------------------------------------
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
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-777"})
def test_linking_opts_in_and_toggle_flips(monkeypatch):
monkeypatch.setitem(oauth.CREDENTIALS, "discord", ("app", "sec"))
monkeypatch.setattr(oauth.httpx, "AsyncClient", _MockClient)
c = TestClient(appmod.app)
_login(c, "dm-toggle@example.com")
uid = c.get(f"{V2}/users/me").json()["id"]
# Link -> opted in by default. discord_id is a delivery address, so the link
# has to write it as well as the oauth_account row.
state = oauth._sign_state(uid, "link", "discord")
r = c.get(f"{V2}/discord/link/callback?code=x&state={state}", follow_redirects=False)
assert r.status_code == 303
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] == "discord-777" and me["discord_dm"] is True
# Mute without unlinking.
assert c.post(f"{V2}/discord/dm", json={"enabled": False}).status_code == 204
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] == "discord-777" and me["discord_dm"] is False
# Turn back on.
assert c.post(f"{V2}/discord/dm", json={"enabled": True}).status_code == 204
assert c.get(f"{V2}/users/me").json()["discord_dm"] is True
def test_dm_toggle_requires_auth():
c = TestClient(appmod.app)
assert c.post(f"{V2}/discord/dm", json={"enabled": True}).status_code == 401