Merge pull request 'Node page Discord picker: org's servers + voice channels, not raw IDs (#213 item 3)' (#220) from feat/213-node-join-picker into main
Reviewed-on: #220
This commit was merged in pull request #220.
This commit is contained in:
@@ -23,6 +23,7 @@ from app.routers import discord as discord_router
|
||||
from app.routers import discord_link as discord_link_router
|
||||
from app.internal import dynsec
|
||||
from app.internal import firestore as fstore
|
||||
from app.routers import discord_web
|
||||
|
||||
|
||||
async def _clear_legacy_token_flags():
|
||||
@@ -156,6 +157,9 @@ app.include_router(waitlist.router) # public — no auth, source-IP rate limi
|
||||
# public by necessity — an <audio src> can't send a bearer token, so the
|
||||
# short-lived HMAC in the URL is the credential. Checked inline in media.py.
|
||||
app.include_router(media.router)
|
||||
# auth is per-endpoint: directory push takes the service key; the node-page
|
||||
# picker takes a Firebase token scoped to the node's org (server-26#213).
|
||||
app.include_router(discord_web.router)
|
||||
# auth is per-endpoint: bot routes take the service key (or a platform admin);
|
||||
# the token-fetch route takes the calling node's own API key.
|
||||
app.include_router(discord_router.router)
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Discord radio channels from the web node page (server-26#213 item 3, #109 item 13).
|
||||
|
||||
The node page used to take raw guild/channel IDs. It now picks from:
|
||||
|
||||
* the Discord servers the caller's org owns — ``discord_guilds`` docs that
|
||||
resolve to the node's org (an explicit link, or the unlinked-server
|
||||
fallback org, same rule the bot uses: streams.resolve_guild_org), and
|
||||
* their voice channels, from a directory the DRB server bot pushes
|
||||
(``PUT /discord/guilds/{id}/directory``). The bot already holds its
|
||||
gateway cache of every server it is in; C2 never calls Discord with a
|
||||
token on the browser's behalf and no token string ever reaches the browser.
|
||||
|
||||
A pick becomes a ``discord_streams`` desired-state doc **pinned to this node**
|
||||
(streams.start_stream(node_id=...)), exactly what the old raw-ID path did, with
|
||||
the checks that path skipped: the server must belong to the node's org, the
|
||||
channel must be a voice channel the bot reported in that server, and the
|
||||
per-server and per-node stream caps apply.
|
||||
|
||||
Auth:
|
||||
* PUT /discord/guilds/{id}/directory — service key (the server bot) only.
|
||||
* GET /discord/nodes/{node_id}/join-options — any signed-in member of the node's org
|
||||
(or a platform admin); ``can_control`` says
|
||||
whether the caller may start/stop.
|
||||
* POST /discord/nodes/{node_id}/streams — platform admin, the org's owner, or an
|
||||
* POST /discord/nodes/{node_id}/streams/stop operator whose owned_node_ids lists the node.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_streams as streams
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import (
|
||||
get_org_role,
|
||||
get_role,
|
||||
require_firebase_token,
|
||||
require_service_key,
|
||||
)
|
||||
from app.internal.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/discord", tags=["discord"])
|
||||
|
||||
MAX_CHANNELS = 200
|
||||
_NAME_MAX = 100
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory — pushed by the DRB server bot from its gateway cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class VoiceChannelIn(BaseModel):
|
||||
channel_id: str = Field(..., pattern=r"^[0-9]{1,25}$")
|
||||
name: str = Field(..., max_length=_NAME_MAX)
|
||||
category: Optional[str] = Field(None, max_length=_NAME_MAX)
|
||||
position: int = 0
|
||||
|
||||
|
||||
class GuildDirectory(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=_NAME_MAX)
|
||||
present: bool = True
|
||||
voice_channels: list[VoiceChannelIn] = Field(default_factory=list, max_length=MAX_CHANNELS)
|
||||
|
||||
|
||||
@router.put("/guilds/{guild_id}/directory")
|
||||
async def put_guild_directory(guild_id: str, body: GuildDirectory, _: dict = Depends(require_service_key)):
|
||||
"""The server bot's view of one Discord server: its name and voice channels.
|
||||
Merged onto ``discord_guilds/{id}`` — never touches ``org_id`` (the link)."""
|
||||
if not guild_id.isdigit():
|
||||
raise HTTPException(400, "guild_id must be a Discord snowflake.")
|
||||
data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()}
|
||||
if body.present:
|
||||
data["voice_channels"] = [c.model_dump() for c in body.voice_channels]
|
||||
if body.name:
|
||||
# Same field the /drb link flow writes (feat/213-guild-link, PR #218).
|
||||
data["guild_name"] = body.name
|
||||
await fstore.doc_set(streams.GUILDS, guild_id, data)
|
||||
return {"ok": True, "guild_id": guild_id, "voice_channels": len(body.voice_channels)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node page — picker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _node_for_caller(node_id: str, decoded: dict) -> tuple[dict, bool]:
|
||||
"""The node, and whether this caller may start/stop its radio channels.
|
||||
A node outside the caller's org is a 404, never a 403 (no existence leak)."""
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
if get_role(decoded) == "admin":
|
||||
return node, True
|
||||
org_id = decoded.get("org_id")
|
||||
if not org_id or node.get("org_id") != org_id:
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
can = get_org_role(decoded) == "owner" or (
|
||||
get_role(decoded) == "operator" and node_id in (decoded.get("owned_node_ids") or [])
|
||||
)
|
||||
return node, can
|
||||
|
||||
|
||||
def _guild_name(g: dict) -> Optional[str]:
|
||||
return g.get("guild_name") or g.get("name")
|
||||
|
||||
|
||||
def _require_control(can: bool) -> None:
|
||||
if not can:
|
||||
raise HTTPException(403, "Only the organization owner, an admin, or this node's operator can do that.")
|
||||
|
||||
|
||||
async def _org_guilds(org_id: str) -> list[dict]:
|
||||
"""``discord_guilds`` docs this org may stream into, bot still present.
|
||||
Mirrors streams.resolve_guild_org: explicit link, else the unlinked-server
|
||||
fallback org — except a server an org explicitly unlinked (``unlinked_at``,
|
||||
PR #218), which gets no fallback."""
|
||||
fallback = settings.discord_unlinked_guild_org_id or None
|
||||
out = []
|
||||
for g in await fstore.collection_list(streams.GUILDS):
|
||||
gid = str(g.get("guild_id") or "")
|
||||
if not gid or g.get("bot_present") is False:
|
||||
continue
|
||||
owner = g.get("org_id") or (None if g.get("unlinked_at") else fallback)
|
||||
if owner and owner == org_id:
|
||||
out.append(g)
|
||||
out.sort(key=lambda g: ((_guild_name(g) or "").lower(), g["guild_id"]))
|
||||
return out
|
||||
|
||||
|
||||
def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> dict:
|
||||
res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id)
|
||||
if res.ok:
|
||||
return {"ok": True, "code": None, "message": None, "invite": []}
|
||||
return {"ok": False, "code": res.error, "message": streams.ERROR_TEXT.get(res.error or "", res.error),
|
||||
"invite": [i for i in res.invite if i.get("invite_url")][:5]}
|
||||
|
||||
|
||||
def _stream_view(s: dict, node_names: dict) -> dict:
|
||||
return {
|
||||
"stream_id": s.get("stream_id"),
|
||||
"guild_id": s.get("guild_id"),
|
||||
"channel_id": s.get("channel_id"),
|
||||
"guild_name": s.get("guild_name"),
|
||||
"channel_name": s.get("channel_name"),
|
||||
"system_name": s.get("system_name"),
|
||||
"mode": s.get("mode"),
|
||||
"node_id": s.get("node_id"),
|
||||
"node_name": node_names.get(s.get("node_id") or "") or s.get("node_id"),
|
||||
"actual": s.get("actual"),
|
||||
"updated_at": s.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/nodes/{node_id}/join-options")
|
||||
async def join_options(node_id: str, decoded: dict = Depends(require_firebase_token)):
|
||||
node, can_control = await _node_for_caller(node_id, decoded)
|
||||
org_id = node.get("org_id")
|
||||
system_id = streams._effective_system(node)
|
||||
system = (await fstore.doc_get_cached("systems", system_id)) if system_id else None
|
||||
|
||||
guilds = await _org_guilds(org_id) if org_id else []
|
||||
live = [s for s in await fstore.collection_list(streams.STREAMS, desired="on")
|
||||
if s.get("org_id") == org_id]
|
||||
nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id)
|
||||
if n.get("node_id")} if org_id else {}
|
||||
by_channel = {s.get("stream_id"): s for s in live}
|
||||
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||
|
||||
out_guilds = []
|
||||
for g in guilds:
|
||||
gid = g["guild_id"]
|
||||
channels = []
|
||||
for c in sorted(g.get("voice_channels") or [],
|
||||
key=lambda c: (c.get("position") or 0, (c.get("name") or "").lower())):
|
||||
s = by_channel.get(leases.stream_id_for(gid, str(c.get("channel_id"))))
|
||||
channels.append({
|
||||
"channel_id": str(c.get("channel_id")),
|
||||
"name": c.get("name"),
|
||||
"category": c.get("category"),
|
||||
"stream": _stream_view(s, nodes) if s else None,
|
||||
})
|
||||
out_guilds.append({
|
||||
"guild_id": gid,
|
||||
"name": _guild_name(g),
|
||||
"linked": bool(g.get("org_id")),
|
||||
"synced": g.get("voice_channels") is not None,
|
||||
"synced_at": g.get("directory_at"),
|
||||
"voice_channels": channels,
|
||||
"streams_on": sum(1 for s in live if s.get("guild_id") == gid),
|
||||
"bot": _bot_status(tokens, node_id=node_id, guild_id=gid, org_id=org_id),
|
||||
})
|
||||
|
||||
here = [s for s in live if s.get("node_id") == node_id]
|
||||
here.sort(key=lambda s: (s.get("guild_name") or "", s.get("channel_name") or "", s.get("stream_id") or ""))
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"node_name": node.get("name"),
|
||||
"node_online": node.get("status") in streams.ONLINE,
|
||||
"system_id": system_id,
|
||||
"system_name": (system or {}).get("name") or system_id,
|
||||
"can_control": can_control,
|
||||
"limits": {
|
||||
"node_max": settings.discord_node_max_streams,
|
||||
"node_used": len(here),
|
||||
"guild_max": settings.discord_guild_max_streams,
|
||||
},
|
||||
"guilds": out_guilds,
|
||||
"streams": [_stream_view(s, nodes) for s in here],
|
||||
}
|
||||
|
||||
|
||||
class NodeStreamStart(BaseModel):
|
||||
guild_id: str
|
||||
channel_id: str
|
||||
mode: str = "adhoc"
|
||||
|
||||
|
||||
@router.post("/nodes/{node_id}/streams")
|
||||
async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict = Depends(require_firebase_token)):
|
||||
node, can_control = await _node_for_caller(node_id, decoded)
|
||||
_require_control(can_control)
|
||||
org_id = node.get("org_id")
|
||||
if not org_id:
|
||||
raise HTTPException(409, "This node is not in an organization yet.")
|
||||
if body.mode not in ("persistent", "adhoc"):
|
||||
raise HTTPException(400, "mode must be persistent or adhoc.")
|
||||
system_id = streams._effective_system(node)
|
||||
if not system_id:
|
||||
raise HTTPException(409, "This node is not listening to a system yet.")
|
||||
|
||||
guild_id, channel_id = str(body.guild_id), str(body.channel_id)
|
||||
guild = next((g for g in await _org_guilds(org_id) if g["guild_id"] == guild_id), None)
|
||||
if not guild:
|
||||
raise HTTPException(404, "That Discord server is not linked to this organization.")
|
||||
channel = next((c for c in guild.get("voice_channels") or [] if str(c.get("channel_id")) == channel_id), None)
|
||||
if not channel:
|
||||
raise HTTPException(400, "That is not a voice channel DRB can see in this server. "
|
||||
"Refresh the list and pick again.")
|
||||
|
||||
stream_id = leases.stream_id_for(guild_id, channel_id)
|
||||
existing = await fstore.doc_get(streams.STREAMS, stream_id) or {}
|
||||
already_here = existing.get("desired") == "on" and existing.get("node_id") == node_id
|
||||
if not already_here:
|
||||
used = sum(1 for s in await fstore.collection_list(streams.STREAMS, node_id=node_id)
|
||||
if s.get("desired") == "on")
|
||||
if used >= settings.discord_node_max_streams:
|
||||
raise HTTPException(409, f"This node already carries {used} radio channels, its maximum. "
|
||||
"Stop one first.")
|
||||
|
||||
try:
|
||||
doc = await streams.start_stream(
|
||||
org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id,
|
||||
node_id=node_id, mode=body.mode,
|
||||
channel_name=channel.get("name"), guild_name=_guild_name(guild),
|
||||
created_by=f"web:{decoded.get('uid') or 'unknown'}",
|
||||
max_streams=settings.discord_guild_max_streams,
|
||||
)
|
||||
except streams.StreamError as e:
|
||||
return JSONResponse(status_code=e.status,
|
||||
content={"detail": e.message, "code": e.code, "invite": e.invite})
|
||||
logger.info(f"Web: radio channel {stream_id} pinned to node {node_id} ({body.mode})")
|
||||
return {"ok": True, "stream_id": doc["stream_id"], "token_id": doc.get("token_id"),
|
||||
"delivered": doc.get("delivered"), "reused": doc.get("reused"), "actual": doc.get("actual")}
|
||||
|
||||
|
||||
class NodeStreamStop(BaseModel):
|
||||
# guild:channel snowflakes (leases.stream_id_for). Validated so a malformed
|
||||
# id (e.g. one with "/") is a 422, not a Firestore path error / 500.
|
||||
stream_id: Optional[str] = Field(None, pattern=r"^[0-9]{1,25}:[0-9]{1,25}$")
|
||||
|
||||
|
||||
@router.post("/nodes/{node_id}/streams/stop")
|
||||
async def stop_node_streams(node_id: str, body: NodeStreamStop, decoded: dict = Depends(require_firebase_token)):
|
||||
"""Stop one radio channel this node carries, or (no stream_id) all of them."""
|
||||
_, can_control = await _node_for_caller(node_id, decoded)
|
||||
_require_control(can_control)
|
||||
if body.stream_id:
|
||||
doc = await fstore.doc_get(streams.STREAMS, body.stream_id)
|
||||
if not doc or doc.get("node_id") != node_id or doc.get("desired") != "on":
|
||||
raise HTTPException(404, "This node is not carrying that radio channel.")
|
||||
await streams.stop_stream(body.stream_id, "stopped_from_web")
|
||||
return {"ok": True, "stopped": [body.stream_id]}
|
||||
return {"ok": True, "stopped": await streams.stop_node(node_id, "stopped_from_web")}
|
||||
@@ -168,6 +168,11 @@ async def send_command(
|
||||
if cmd.action == "discord_join":
|
||||
if not cmd.guild_id or not cmd.channel_id:
|
||||
raise HTTPException(400, "guild_id and channel_id are required.")
|
||||
# The raw-ID path never checked the server belongs to the node's org
|
||||
# (server-26#213): one org's node could be streamed into another org's
|
||||
# Discord server. The web picker uses /discord/nodes/{id}/streams now.
|
||||
if await discord_streams.resolve_guild_org(cmd.guild_id) != node.get("org_id"):
|
||||
raise HTTPException(404, "That Discord server is not linked to this node's organization.")
|
||||
system_id = discord_streams._effective_system(node)
|
||||
if not system_id:
|
||||
raise HTTPException(409, "This node is not listening to a system yet.")
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
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["guild_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
|
||||
# An org that explicitly unlinked its server (PR #218) never falls back.
|
||||
fs.put("discord_guilds", "300", {"guild_id": "300", "org_id": None, "unlinked_at": "2026-10-04T00:00:00+00:00",
|
||||
"guild_name": "Unlinked", "voice_channels": []})
|
||||
assert "300" not in [g["guild_id"] for g in run(web.join_options("n1", OWNER))["guilds"]]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_stop_rejects_malformed_stream_id():
|
||||
# "a/b" would otherwise reach Firestore as a document path and 500.
|
||||
from pydantic import ValidationError
|
||||
for bad in ("100/1001", "../x", "100:1001/extra", ""):
|
||||
with pytest.raises(ValidationError):
|
||||
web.NodeStreamStop(stream_id=bad)
|
||||
assert web.NodeStreamStop(stream_id="100:1001").stream_id == "100:1001"
|
||||
assert web.NodeStreamStop().stream_id is None
|
||||
Reference in New Issue
Block a user