All checks were successful
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / changes (pull_request) Successful in 11s
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 57s
PR build (required check) / build-backend (pull_request) Successful in 1m25s
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.
187 lines
7.5 KiB
Python
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
|