Node page Discord picker: org's linked servers + voice channels, not raw IDs (#213 item 3, #109 item 13)
- c2-core routers/discord_web.py:
PUT /discord/guilds/{id}/directory (service key): the server bot pushes each
server's name + voice channels; merged onto discord_guilds, never touches org_id.
GET /discord/nodes/{id}/join-options: the node org's servers (same
resolve rule as the bot), their voice channels with any live stream,
radio-bot readiness + invite links, and this node's streams. No tokens.
POST /discord/nodes/{id}/streams: stream pinned to the node; server must be
the node org's, channel must be in the bot's directory, guild + node caps.
POST /discord/nodes/{id}/streams/stop: one stream on this node, or all.
Control = platform admin, org owner, or operator owning the node.
- nodes.py: legacy raw-ID discord_join now refuses a server outside the node's org.
- discord bot: DirectorySync cog pushes on ready (15-min loop), join/leave,
rename, voice/category channel changes (debounced).
- frontend: NodeDiscordRadio panel + picker replaces DiscordJoinModal and the
admin-only Join/Leave buttons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dc1df72c86
commit
62e6d69bfa
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Node-page Discord picker (server-26#213 item 3, #109 item 13).
|
||||
|
||||
The web no longer takes raw guild/channel IDs: it picks from the org's
|
||||
Discord servers (discord_guilds resolving to the node's org) and the voice
|
||||
channels the server bot pushed, and the pick becomes a stream pinned to the
|
||||
node. These tests pin the org boundary, the directory-only channel rule, the
|
||||
caps, and that the directory push never clobbers a guild's org link.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_streams as streams
|
||||
from app.models import CommandPayload
|
||||
from app.routers import discord_web as web
|
||||
from app.routers import nodes as nodes_router
|
||||
from tests.fake_firestore import FakeFirestore
|
||||
|
||||
ORG, OTHER, SYS = "org-a", "org-b", "sys-1"
|
||||
ADMIN = {"uid": "u-admin", "role": "admin"}
|
||||
OWNER = {"uid": "u-own", "org_id": ORG, "org_role": "owner"}
|
||||
MEMBER = {"uid": "u-mem", "org_id": ORG, "org_role": "member"}
|
||||
OPERATOR = {"uid": "u-op", "org_id": ORG, "org_role": "member", "role": "operator", "owned_node_ids": ["n1"]}
|
||||
STRANGER = {"uid": "u-x", "org_id": OTHER, "org_role": "owner"}
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fs(monkeypatch):
|
||||
fake = FakeFirestore.install(monkeypatch)
|
||||
published: list = []
|
||||
from app.internal.mqtt_handler import mqtt_handler
|
||||
monkeypatch.setattr(mqtt_handler, "publish_discord_desired", lambda n, p: published.append((n, p)) or True)
|
||||
streams._publish_locks.clear()
|
||||
|
||||
async def _no_discord(token_ids):
|
||||
return 0
|
||||
monkeypatch.setattr(leases, "refresh_membership", _no_discord)
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||
fake.published = published
|
||||
|
||||
for nid, org in (("n1", ORG), ("n2", ORG), ("nx", OTHER)):
|
||||
fake.put("nodes", nid, {"node_id": nid, "name": f"Node {nid}", "org_id": org, "status": "online",
|
||||
"approval_status": "approved", "assigned_system_id": SYS})
|
||||
fake.put("systems", SYS, {"system_id": SYS, "name": "County P25", "org_id": ORG})
|
||||
fake.put("discord_guilds", "100", {"guild_id": "100", "org_id": ORG, "name": "Fire Co. 7",
|
||||
"bot_present": True,
|
||||
"voice_channels": [{"channel_id": "1001", "name": "Dispatch",
|
||||
"category": "Radio", "position": 1},
|
||||
{"channel_id": "1002", "name": "Fireground",
|
||||
"category": "Radio", "position": 2}]})
|
||||
fake.put("discord_guilds", "200", {"guild_id": "200", "org_id": OTHER, "name": "Other Org Server",
|
||||
"voice_channels": [{"channel_id": "2001", "name": "Theirs"}]})
|
||||
fake.put("bot_tokens", "t1", {"token_id": "t1", "name": "DRB Radio 1", "token": "secret-t1",
|
||||
"org_id": None, "guild_ids": ["100", "200"], "valid": True,
|
||||
"application_id": "app1", "lease": None})
|
||||
return fake
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory push
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_directory_push_keeps_org_link(fs):
|
||||
body = web.GuildDirectory(name="Fire Co. 7 (renamed)",
|
||||
voice_channels=[web.VoiceChannelIn(channel_id="1003", name="Night")])
|
||||
run(web.put_guild_directory("100", body, {"service": True}))
|
||||
g = fs.get("discord_guilds", "100")
|
||||
assert g["org_id"] == ORG
|
||||
assert g["name"] == "Fire Co. 7 (renamed)"
|
||||
assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"]
|
||||
|
||||
|
||||
def test_directory_bot_removed_hides_guild(fs):
|
||||
run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True}))
|
||||
opts = run(web.join_options("n1", OWNER))
|
||||
assert opts["guilds"] == []
|
||||
|
||||
|
||||
def test_directory_rejects_non_snowflake(fs):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run(web.put_guild_directory("abc", web.GuildDirectory(), {"service": True}))
|
||||
assert e.value.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Options — org-scoped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_options_only_this_orgs_servers(fs):
|
||||
opts = run(web.join_options("n1", OWNER))
|
||||
assert [g["guild_id"] for g in opts["guilds"]] == ["100"]
|
||||
g = opts["guilds"][0]
|
||||
assert [c["name"] for c in g["voice_channels"]] == ["Dispatch", "Fireground"]
|
||||
assert g["bot"]["ok"] is True
|
||||
assert opts["can_control"] is True
|
||||
assert opts["system_name"] == "County P25"
|
||||
assert "secret" not in repr(opts)
|
||||
|
||||
|
||||
def test_options_unlinked_fallback_follows_bot_rule(fs, monkeypatch):
|
||||
fs.put("discord_guilds", "300", {"guild_id": "300", "name": "Unlinked", "voice_channels": []})
|
||||
assert "300" not in [g["guild_id"] for g in run(web.join_options("n1", OWNER))["guilds"]]
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
|
||||
got = {g["guild_id"]: g for g in run(web.join_options("n1", OWNER))["guilds"]}
|
||||
assert got["300"]["linked"] is False
|
||||
assert "200" not in got
|
||||
|
||||
|
||||
def test_options_other_org_node_is_404(fs):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run(web.join_options("nx", OWNER))
|
||||
assert e.value.status_code == 404
|
||||
|
||||
|
||||
def test_member_reads_but_cannot_control(fs):
|
||||
assert run(web.join_options("n1", MEMBER))["can_control"] is False
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), MEMBER))
|
||||
assert e.value.status_code == 403
|
||||
|
||||
|
||||
def test_operator_controls_only_owned_node(fs):
|
||||
assert run(web.join_options("n1", OPERATOR))["can_control"] is True
|
||||
assert run(web.join_options("n2", OPERATOR))["can_control"] is False
|
||||
|
||||
|
||||
def test_options_reports_missing_bot_with_invite(fs):
|
||||
fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]})
|
||||
g = run(web.join_options("n1", OWNER))["guilds"][0]
|
||||
assert g["bot"]["ok"] is False and g["bot"]["code"] == "no_bot_in_guild"
|
||||
assert g["bot"]["invite"][0]["invite_url"].startswith("https://discord.com/oauth2/authorize")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start / stop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_start_pins_stream_to_node_with_names(fs):
|
||||
r = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER))
|
||||
assert r["ok"] and r["stream_id"] == "100:1001" and r["token_id"] == "t1"
|
||||
doc = fs.get("discord_streams", "100:1001")
|
||||
assert doc["node_id"] == "n1" and doc["org_id"] == ORG and doc["system_id"] == SYS
|
||||
assert doc["channel_name"] == "Dispatch" and doc["guild_name"] == "Fire Co. 7"
|
||||
assert doc["created_by_discord_id"] == "web:u-own"
|
||||
assert fs.published and fs.published[-1][0] == "n1"
|
||||
opts = run(web.join_options("n1", OWNER))
|
||||
assert [s["stream_id"] for s in opts["streams"]] == ["100:1001"]
|
||||
assert opts["guilds"][0]["voice_channels"][0]["stream"]["node_name"] == "Node n1"
|
||||
|
||||
|
||||
def test_start_refuses_other_orgs_server(fs):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="200", channel_id="2001"), OWNER))
|
||||
assert e.value.status_code == 404
|
||||
assert fs.get("discord_streams", "200:2001") is None
|
||||
|
||||
|
||||
def test_start_refuses_channel_not_in_directory(fs):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="9999"), OWNER))
|
||||
assert e.value.status_code == 400
|
||||
|
||||
|
||||
def test_start_respects_node_cap(fs, monkeypatch):
|
||||
monkeypatch.setattr(settings, "discord_node_max_streams", 1)
|
||||
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER))
|
||||
# Re-picking the same channel is idempotent, not a second slot.
|
||||
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER))
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1002"), OWNER))
|
||||
assert e.value.status_code == 409
|
||||
|
||||
|
||||
def test_start_lease_refusal_carries_invite(fs):
|
||||
fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]})
|
||||
r = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), ADMIN))
|
||||
assert r.status_code == 409
|
||||
assert b"no_bot_in_guild" in r.body and b"secret" not in r.body
|
||||
|
||||
|
||||
def test_stop_one_and_scoped_to_node(fs):
|
||||
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER))
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run(web.stop_node_streams("n2", web.NodeStreamStop(stream_id="100:1001"), ADMIN))
|
||||
assert e.value.status_code == 404
|
||||
r = run(web.stop_node_streams("n1", web.NodeStreamStop(stream_id="100:1001"), OWNER))
|
||||
assert r["stopped"] == ["100:1001"]
|
||||
assert fs.get("discord_streams", "100:1001")["desired"] == "off"
|
||||
|
||||
|
||||
def test_legacy_raw_id_join_refuses_other_orgs_server(fs):
|
||||
cmd = CommandPayload(action="discord_join", guild_id="200", channel_id="2001")
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run(nodes_router.send_command("n1", cmd, ADMIN))
|
||||
assert e.value.status_code == 404
|
||||
Reference in New Issue
Block a user