Frontend, built from design-canvas/discord-system-mockup.html ("DRB admin"):
- Settings > Discord (/settings/discord, platform admin only; the tab is
hidden from org owners because every route behind it is admin-only).
KPIs + tabs:
* Radio channels: every discord_streams doc, desired -> actual, the node's
real error, red after 2 min desired-on without connecting. Stop
(POST /discord/guilds/{id}/stop), Start for a stopped channel and
"+ Add radio channel" (POST /discord/streams), with the invite links a
"no bot in this server" refusal carries.
* Servers: linked servers plus servers seen running radio while unlinked
(acting for DISCORD_UNLINKED_GUILD_ORG_ID); Link / Change org
(PUT /discord/guilds/{id}/link).
* Radio bots: valid / check_error, guild membership with a per-server
invite link for each server the bot is missing from, lease (node,
since, releasing), fan-out, Refresh (POST /tokens/{id}/refresh),
Remove, Release all leases (POST /tokens/flush), add with optional BYO org.
* How it works.
- /tokens now renders the same radio-bot table (new token fields + refresh).
- Left out, no backend yet: alert routes, incident forum/roles/plan columns,
per-channel talkgroup chips, "Generate link code" (item 1).
Backend (routers/discord.py):
- GET /discord/admin/overview (platform admin): guilds, streams with node
and bot names, orgs, systems, limits. No token strings, no Discord user
ids, no linking-admin uid.
- PUT /discord/guilds/{id}/link: digits-only id, optional guild_name label,
and re-linking a server to a different org stops its live channels
(the reconciler would otherwise keep re-placing them on the old org's
nodes, using its stored org_id).
- POST /discord/streams: guild_id/channel_id must be digits (the web form
types them in; the bot always sent snowflakes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
5.7 KiB
Python
118 lines
5.7 KiB
Python
"""
|
|
Web Discord admin page backend (server-26#213 item 2): GET /discord/admin/overview,
|
|
plus the id validation the page relies on for PUT /discord/guilds/{id}/link and
|
|
POST /discord/streams.
|
|
"""
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.config import settings
|
|
from app.internal.auth import require_admin_token, require_service_key_or_admin
|
|
from app.main import app
|
|
from tests.fake_firestore import FakeFirestore
|
|
|
|
client = TestClient(app)
|
|
ADMIN = {"uid": "admin-1", "role": "admin"}
|
|
|
|
|
|
@pytest.fixture
|
|
def fs(monkeypatch):
|
|
fake = FakeFirestore.install(monkeypatch)
|
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
|
|
yield fake
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def _seed(fake):
|
|
fake.put("organizations", "founding", {"org_id": "founding", "name": "Founding"})
|
|
fake.put("organizations", "org-b", {"org_id": "org-b", "name": "Bravo Fire"})
|
|
fake.put("discord_guilds", "111", {"guild_id": "111", "org_id": "org-b", "linked_by": "uid-x",
|
|
"linked_at": "2026-10-01T00:00:00+00:00", "guild_name": "Bravo Server"})
|
|
fake.put("nodes", "n1", {"node_id": "n1", "name": "Node One", "status": "online", "org_id": "org-b"})
|
|
fake.put("bot_tokens", "t1", {"token_id": "t1", "name": "Radio 1", "token": "secret-t1"})
|
|
fake.put("systems", "s1", {"system_id": "s1", "name": "County P25", "org_id": "org-b"})
|
|
fake.put("discord_streams", "111:900", {
|
|
"stream_id": "111:900", "guild_id": "111", "channel_id": "900", "channel_name": "Dispatch",
|
|
"org_id": "org-b", "system_id": "s1", "mode": "persistent", "desired": "on", "node_id": "n1",
|
|
"token_id": "t1", "actual": {"state": "connected"}, "created_by_discord_id": "4242",
|
|
})
|
|
# A server that ran /join before anyone linked it — acts for the fallback org.
|
|
fake.put("discord_streams", "222:901", {
|
|
"stream_id": "222:901", "guild_id": "222", "channel_id": "901", "guild_name": "Unlinked Server",
|
|
"org_id": "founding", "system_id": "s1", "mode": "adhoc", "desired": "off",
|
|
"actual": {"state": "stopping"},
|
|
})
|
|
|
|
|
|
def test_overview_requires_platform_admin(fs):
|
|
def deny():
|
|
raise HTTPException(403, "Admin access required")
|
|
app.dependency_overrides[require_admin_token] = deny
|
|
assert client.get("/discord/admin/overview").status_code == 403
|
|
|
|
|
|
def test_overview_lists_linked_and_seen_guilds_streams_and_limits(fs):
|
|
_seed(fs)
|
|
app.dependency_overrides[require_admin_token] = lambda: ADMIN
|
|
body = client.get("/discord/admin/overview").json()
|
|
|
|
guilds = {g["guild_id"]: g for g in body["guilds"]}
|
|
assert guilds["111"]["linked"] is True and guilds["111"]["acting_org_name"] == "Bravo Fire"
|
|
assert guilds["111"]["live_streams"] == 1 and guilds["111"]["guild_name"] == "Bravo Server"
|
|
# unlinked but seen: shown, acting for the fallback org, name taken from its stream
|
|
assert guilds["222"]["linked"] is False and guilds["222"]["acting_org_id"] == "founding"
|
|
assert guilds["222"]["guild_name"] == "Unlinked Server" and guilds["222"]["live_streams"] == 0
|
|
assert [g["guild_id"] for g in body["guilds"]] == ["111", "222"] # linked first
|
|
|
|
s = body["streams"][0]
|
|
assert s["stream_id"] == "111:900" and s["node_name"] == "Node One" and s["token_name"] == "Radio 1"
|
|
assert s["actual"]["state"] == "connected"
|
|
assert body["streams"][1]["desired"] == "off" # live channels first
|
|
|
|
text = repr(body)
|
|
assert "secret-t1" not in text # no token strings
|
|
assert "4242" not in text # no Discord user ids
|
|
assert "uid-x" not in text # no linking-admin uid
|
|
assert body["limits"]["unlinked_guild_org_name"] == "Founding"
|
|
assert {o["org_id"] for o in body["orgs"]} == {"founding", "org-b"}
|
|
assert body["node_names"] == {"n1": "Node One"}
|
|
assert body["systems"] == [{"system_id": "s1", "name": "County P25", "org_id": "org-b"}]
|
|
|
|
|
|
def test_link_rejects_non_snowflake_and_stores_label(fs):
|
|
_seed(fs)
|
|
app.dependency_overrides[require_admin_token] = lambda: ADMIN
|
|
assert client.put("/discord/guilds/abc/link", json={"org_id": "org-b"}).status_code == 400
|
|
r = client.put("/discord/guilds/333/link", json={"org_id": "org-b", "guild_name": " Charlie "})
|
|
assert r.status_code == 200
|
|
assert fs.get("discord_guilds", "333")["guild_name"] == "Charlie"
|
|
assert client.put("/discord/guilds/333/link", json={"org_id": "nope"}).status_code == 404
|
|
|
|
|
|
def test_start_stream_rejects_non_snowflake_ids(fs):
|
|
app.dependency_overrides[require_service_key_or_admin] = lambda: ADMIN
|
|
r = client.post("/discord/streams", json={"guild_id": "111", "channel_id": "general", "system_id": "s1"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_relinking_a_server_to_another_org_stops_its_live_channels(fs, monkeypatch):
|
|
from app.internal import discord_streams as streams
|
|
calls = []
|
|
|
|
async def _stop_guild(guild_id, reason, channel_id=None):
|
|
calls.append((guild_id, reason))
|
|
return [f"{guild_id}:900"]
|
|
monkeypatch.setattr(streams, "stop_guild", _stop_guild)
|
|
_seed(fs)
|
|
app.dependency_overrides[require_admin_token] = lambda: ADMIN
|
|
|
|
# same org again: nothing stopped
|
|
assert client.put("/discord/guilds/111/link", json={"org_id": "org-b"}).json()["stopped"] == []
|
|
# unlinked server (acting for the fallback org) linked to that same org: nothing stopped
|
|
assert client.put("/discord/guilds/222/link", json={"org_id": "founding"}).json()["stopped"] == []
|
|
assert calls == []
|
|
# moved to a different org: its live channels are stopped
|
|
r = client.put("/discord/guilds/111/link", json={"org_id": "founding"}).json()
|
|
assert r["stopped"] == ["111:900"] and calls == [("111", "server linked to another organization")]
|