thermograph/tests/notifications/test_discord_interactions.py

117 lines
4.4 KiB
Python
Raw Normal View History

"""Discord HTTP-interactions: Ed25519 verification, PING/PONG, /grade dispatch,
and the wired FastAPI route. No live Discord we hold a test signing key and sign
requests ourselves."""
import datetime
import json
import pytest
from fastapi.testclient import TestClient
from nacl.signing import SigningKey
import app as appmod
import discord_interactions as di
import homepage
B = "/thermograph"
_SK = SigningKey.generate()
_PUBKEY = _SK.verify_key.encode().hex()
def _sign(body: bytes, ts: str = "1700000000") -> tuple[str, str]:
return _SK.sign(ts.encode() + body).signature.hex(), ts
_CARD = {
"display": "Tokyo, Japan", "name": "Tokyo", "slug": "tokyo-jp",
"metric_label": "High temp", "value": 96.0, "normal": 84.0,
"pct_label": "97th percentile", "grade_label": "Very High",
"tail": "warm", "date_label": None,
}
# --- signature verification --------------------------------------------------
def test_verify_accepts_a_good_signature():
body = b'{"type":1}'
sig, ts = _sign(body)
assert di.verify(sig, ts, body, _PUBKEY) is True
def test_verify_rejects_tampering_and_bad_inputs():
body = b'{"type":1}'
sig, ts = _sign(body)
assert di.verify(sig, ts, body + b" ", _PUBKEY) is False # body changed
assert di.verify(sig, "1700000001", body, _PUBKEY) is False # timestamp changed
assert di.verify("00" * 64, ts, body, _PUBKEY) is False # wrong signature
assert di.verify("nothex", ts, body, _PUBKEY) is False # malformed
assert di.verify(sig, ts, body, "") is False # no key configured
assert di.verify("", ts, body, _PUBKEY) is False # no signature
# --- handle(): routing -------------------------------------------------------
def test_handle_ping_pongs(monkeypatch):
monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY)
body = b'{"type":1}'
sig, ts = _sign(body)
status, payload = di.handle(body, sig, ts)
assert status == 200 and payload == {"type": 1}
def test_handle_rejects_bad_signature(monkeypatch):
monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY)
body = b'{"type":1}'
status, payload = di.handle(body, "00" * 64, "1700000000")
assert status == 401
def test_grade_command_returns_the_citys_grade(monkeypatch):
monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY)
monkeypatch.setattr(homepage, "_grade_city", lambda city, today: dict(_CARD))
body = json.dumps({"type": 2, "data": {
"name": "grade", "options": [{"name": "city", "value": "Tokyo"}]}}).encode()
sig, ts = _sign(body)
status, payload = di.handle(body, sig, ts)
assert status == 200 and payload["type"] == 4
embed = payload["data"]["embeds"][0]
assert "Tokyo, Japan" in embed["title"]
assert "96°F" in embed["description"] and "97th percentile" in embed["description"]
assert embed["url"].endswith("/climate/tokyo-jp")
def test_grade_unknown_city_is_ephemeral(monkeypatch):
monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY)
data = di._grade_message("Nowherecityville")
assert data["flags"] == di._EPHEMERAL and "don't track" in data["content"]
def test_grade_city_not_yet_warm_is_ephemeral(monkeypatch):
monkeypatch.setattr(homepage, "_grade_city", lambda city, today: None)
data = di._grade_message("Tokyo") # a real curated city, but no warm cache
assert data["flags"] == di._EPHEMERAL and "No recent reading" in data["content"]
def test_resolve_city_prefers_exact_then_population():
# Real cities.json: an exact name resolves, and it's a real curated entry.
tokyo = di._resolve_city("tokyo")
assert tokyo is not None and tokyo["name"] == "Tokyo"
assert di._resolve_city(" ") is None
assert di._resolve_city("Zzznotacity") is None
# --- the wired FastAPI route -------------------------------------------------
def test_interactions_route_pings_and_rejects(monkeypatch):
monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY)
client = TestClient(appmod.app)
body = b'{"type":1}'
sig, ts = _sign(body)
# Signed PING -> PONG. Send raw bytes so the signature matches byte-for-byte.
r = client.post(f"{B}/discord/interactions", content=body,
headers={"x-signature-ed25519": sig, "x-signature-timestamp": ts})
assert r.status_code == 200 and r.json() == {"type": 1}
# Unsigned -> 401 (Discord's security probe).
r = client.post(f"{B}/discord/interactions", content=body)
assert r.status_code == 401