Discord: check bot permissions before handing a token to a node (server-26#225 S3)

start_stream now asks Discord (cached REST snapshots, S2) whether each bot
it could pick may View, Connect and Speak in the voice channel, before the
lease. Covers all four entry points: bot POST /discord/streams, web picker,
nodes discord_join, and the reconciler's re-place.

- pick_token: denied bots skipped; confirmed-ok sorts before unknown;
  unknown is allowed (fail open); all denied -> missing_permissions naming
  each bot, missing permission, trace reason and fix. The reuse branch
  falls through when its token is denied.
- Bounded: discord_perm_check_concurrency at once, within
  discord_perm_check_budget_seconds; unanswered = unknown.
- Non-voice channel -> 400 bad_channel (not missing_permissions).
- systems/{id}.preferred_token_id is now read (plan finding 1), as a hint.
- Pool bots at discord_pool_bot_guild_cap are never offered as invites;
  pool_full when nothing else is left (owner Q3).
- handle_actual drops cached access on missing_permissions, channel_full,
  not_in_guild, bad_channel.
- New GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check (org
  member, names only) and GET /discord/guilds/{guild_id}/voice-check
  (service key or admin).
- PUT /tokens/{id}/prefer/{system_id}: org owners for their own systems
  and own BYO bots; shared systems and pool bots stay admin-only (owner Q2).
  Keeps both pointers single-valued; audited.
- Kill switch: DISCORD_PERM_CHECK_ENABLED=false.
- tests/conftest.py: autouse fixture so no test ever reaches discord.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:22:45 -04:00
co-authored by Claude Opus 5.5
parent a75149b2eb
commit 2c2cf9d9be
9 changed files with 1232 additions and 47 deletions
+24
View File
@@ -72,3 +72,27 @@ except ModuleNotFoundError:
):
sys.modules.setdefault(_name, ModuleType(_name))
sys.modules["google.cloud.firestore_v1.base_query"].FieldFilter = MagicMock()
import pytest # noqa: E402
@pytest.fixture(autouse=True)
def _no_real_discord(monkeypatch):
"""No test reaches discord.com (server-26#225 S3).
``start_stream`` now asks Discord, over REST, whether a bot may speak in
the channel before it hands the bot to a node. Every Discord call in a
test is answered by a transport that fails to connect, so the check is
"unknown" and fails open — today's token picking — unless the test
installs its own transport or stubs ``discord_rest.guild_snapshot``."""
import httpx
from app.internal import discord_rest
def _offline(request):
raise httpx.ConnectError("tests never reach Discord", request=request)
discord_rest.reset()
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(_offline))
yield
discord_rest.reset()
@@ -0,0 +1,557 @@
"""
Discord permission check wired into token picking (server-26#225 S3).
Before a radio bot is handed to a node, C2 asks Discord (cached REST
snapshots, discord_rest.guild_snapshot — stubbed here) whether the bot may
View, Connect and Speak in the voice channel. These tests pin:
* pick_token: a denied bot is skipped, confirmed-ok sorts before unknown,
unknown is allowed (fail open), all-denied is ``missing_permissions``
naming each bot; a full pool bot (discord_pool_bot_guild_cap) is never
offered as an invite.
* start_stream: the check runs for every entry point (bot POST
/discord/streams, web picker, node discord_join, reconciler), inside a time
budget, never when the kill switch is off; a non-voice channel is
``bad_channel``; the system's preferred bot is tried first.
* GET voice-check: org-scoped, bot names only for non-admins.
* PUT /tokens/{id}/prefer/{system}: owner decision Q2.
"""
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_perms as perms_mod
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.models import CommandPayload
from app.routers import discord as discord_router
from app.routers import discord_checks as checks
from app.routers import discord_web as web
from app.routers import nodes as nodes_router
from app.routers import tokens as tokens_router
from tests.fake_firestore import FakeFirestore
ORG, OTHER, SYS = "org-a", "org-b", "sys-1"
GUILD = "100"
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"}
STRANGER = {"uid": "u-x", "org_id": OTHER, "org_role": "owner"}
VOICE = perms_mod.VIEW_CHANNEL | perms_mod.CONNECT | perms_mod.SPEAK
def _guild():
return {"id": GUILD, "owner_id": "owner-user",
"roles": [{"id": GUILD, "name": "@everyone", "permissions": str(VOICE)},
{"id": "r-muted", "name": "Muted", "permissions": "0"}]}
def _channels():
return [
{"id": "1001", "name": "dispatch", "type": 2, "user_limit": 0,
"permission_overwrites": [{"id": "r-muted", "type": 0, "allow": "0", "deny": str(perms_mod.SPEAK)}]},
{"id": "1002", "name": "fireground", "type": 2, "user_limit": 5, "permission_overwrites": []},
{"id": "1003", "name": "chat", "type": 0, "permission_overwrites": []},
]
def _snap(tok_id, *, muted=False, status=discord_rest.OK):
if status != discord_rest.OK:
return discord_rest.GuildSnapshot(token_id=tok_id, guild_id=GUILD, status=status, error="x")
return discord_rest.GuildSnapshot(
token_id=tok_id, guild_id=GUILD, status=discord_rest.OK, bot_user_id=f"u-{tok_id}",
guild=_guild(), member={"user": {"id": f"u-{tok_id}"}, "roles": ["r-muted"] if muted else []},
channels=_channels())
@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()
streams._guild_locks.clear()
checks._last_refresh.clear()
async def _no_discord(token_ids):
return 0
monkeypatch.setattr(discord_rest, "refresh_membership", _no_discord)
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
monkeypatch.setattr(settings, "discord_perm_check_enabled", True)
# Per-token snapshot behaviour: "ok" | "muted" | "unknown" | "slow" | "not_member"
fake.snap_mode = {}
fake.snap_calls = []
async def _snapshot(token_doc, guild_id, *, refresh=False):
tid = token_doc["token_id"]
fake.snap_calls.append((tid, guild_id, refresh))
assert "token" in token_doc # the real call needs the string; it never leaves C2
mode = fake.snap_mode.get(tid, "ok")
if mode == "slow":
await asyncio.sleep(5)
if mode == "unknown":
return _snap(tid, status=discord_rest.UNKNOWN)
if mode == "not_member":
return _snap(tid, status=discord_rest.NOT_MEMBER)
return _snap(tid, muted=(mode == "muted"))
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
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("systems", "shared", {"system_id": "shared", "name": "State Net", "org_id": None})
fake.put("systems", "sys-b", {"system_id": "sys-b", "name": "Theirs", "org_id": OTHER})
fake.put("discord_guilds", GUILD, {"guild_id": GUILD, "org_id": ORG, "name": "Fire Co. 7", "bot_present": True,
"voice_channels": [{"channel_id": "1001", "name": "dispatch"},
{"channel_id": "1002", "name": "fireground"}]})
fake.put("discord_guilds", "200", {"guild_id": "200", "org_id": OTHER, "name": "Other", "bot_present": True,
"voice_channels": [{"channel_id": "2001", "name": "theirs"}]})
fake.published = published
return fake
def _token(fake, tid, *, name=None, org=None, guilds=(GUILD,), **extra):
fake.put("bot_tokens", tid, {"token_id": tid, "name": name or f"DRB Radio {tid}", "token": f"secret-{tid}",
"org_id": org, "guild_ids": list(guilds) if guilds is not None else None,
"valid": True, "application_id": f"app-{tid}", "lease": None, **extra})
def run(coro):
return asyncio.run(coro)
async def _start(channel="1001", **kw):
return await streams.start_stream(org_id=kw.pop("org_id", ORG), guild_id=GUILD, channel_id=channel,
system_id=kw.pop("system_id", SYS), **kw)
# ---------------------------------------------------------------------------
# pick_token — pure
# ---------------------------------------------------------------------------
def _toks(*ids, **kw):
return {t: {"name": f"Radio {t}", "guild_ids": [GUILD], "application_id": f"a{t}", **kw} for t in ids}
def test_pick_skips_denied_and_prefers_confirmed_ok_over_unknown():
toks = _toks("a", "b", "c")
perms = {"a": {"status": "denied", "missing": ["Speak"], "reason": "r", "fix": "f"},
"b": {"status": "unknown"}, "c": {"status": "ok"}}
assert leases.pick_token(toks, node_id="n1", guild_id=GUILD, org_id=ORG, perms=perms).token_id == "c"
# Unknown is allowed: fail open.
perms["c"] = {"status": "denied", "missing": ["Speak"]}
assert leases.pick_token(toks, node_id="n1", guild_id=GUILD, org_id=ORG, perms=perms).token_id == "b"
def test_pick_all_denied_is_missing_permissions_naming_each_bot():
toks = {**_toks("a", "b"), "out": {"name": "Radio out", "guild_ids": [], "application_id": "9"}}
perms = {t: {"status": "denied", "missing": ["Speak"], "reason": f"why {t}", "fix": "fix"} for t in ("a", "b")}
res = leases.pick_token(toks, node_id="n1", guild_id=GUILD, org_id=ORG, perms=perms)
assert res.error == "missing_permissions"
assert [d["name"] for d in res.detail["denied"]] == ["Radio a", "Radio b"]
assert res.detail["denied"][0] == {"name": "Radio a", "missing": ["Speak"], "reason": "why a", "fix": "fix"}
assert res.invite and res.invite[0]["name"] == "Radio out" # the other way out rides along
def test_pick_without_perms_is_unchanged():
toks = _toks("a", "b")
assert leases.pick_token(toks, node_id="n1", guild_id=GUILD, org_id=ORG).token_id == "a"
def test_preferred_bot_denied_is_refused_by_name_not_swapped():
toks = _toks("a", "b")
perms = {"a": {"status": "denied", "missing": ["Connect", "Speak"], "reason": "r", "fix": "f"}}
res = leases.pick_token(toks, node_id="n1", guild_id=GUILD, org_id=ORG, preferred_token_id="a", perms=perms)
assert res.error == "missing_permissions" and res.detail["token_name"] == "Radio a"
def test_soft_preferred_goes_first_but_falls_back():
toks = _toks("a", "z")
assert leases.pick_token(toks, node_id="n1", guild_id=GUILD, org_id=ORG,
soft_preferred_token_id="z").token_id == "z"
toks["z"]["lease"] = {"node_id": "n1", "streams": [f"{GUILD}:9:abcdef012345"]} # busy in this server
assert leases.pick_token(toks, node_id="n1", guild_id=GUILD, org_id=ORG,
soft_preferred_token_id="z").token_id == "a"
def test_full_pool_bot_is_never_offered_as_an_invite(monkeypatch):
monkeypatch.setattr(settings, "discord_pool_bot_guild_cap", 3)
toks = {"full": {"name": "Full", "guild_ids": ["1", "2", "3"], "application_id": "1"},
"room": {"name": "Room", "guild_ids": ["1"], "application_id": "2"},
"byo": {"name": "Ours", "org_id": ORG, "guild_ids": ["1", "2", "3", "4"], "application_id": "3"}}
res = leases.pick_token(toks, node_id="n1", guild_id=GUILD, org_id=ORG)
assert res.error == "no_bot_in_guild" and {i["name"] for i in res.invite} == {"Room", "Ours"}
res = leases.pick_token({"full": toks["full"]}, node_id="n1", guild_id=GUILD, org_id=ORG)
assert res.error == "pool_full" and not res.invite
# A full pool bot already IN the server is still used: the cap is about joining more.
toks["full"]["guild_ids"].append(GUILD)
assert leases.pick_token({"full": toks["full"]}, node_id="n1", guild_id=GUILD, org_id=ORG).ok
# ---------------------------------------------------------------------------
# evaluate_access — trace -> reason/fix text
# ---------------------------------------------------------------------------
def test_evaluate_names_missing_permission_reason_and_fix():
r = streams.evaluate_access(_snap("t", muted=True), "1001", bot="DRB Radio 2")
assert r["status"] == "denied" and r["missing"] == ["Speak"]
assert r["reason"] == "denied by #dispatch's override for @Muted"
assert "DRB Radio 2" in r["fix"] and "allow Speak" in r["fix"]
assert streams.evaluate_access(_snap("t"), "1001", bot="x")["status"] == "ok"
assert streams.evaluate_access(_snap("t"), "1003", bot="x")["status"] == "bad_channel"
assert streams.evaluate_access(_snap("t"), "9999", bot="x")["status"] == "unknown" # not in list: fail open
bad = _snap("t")
bad.guild = {"id": GUILD, "roles": []} # partial payload
assert streams.evaluate_access(bad, "1001", bot="x")["status"] == "unknown"
# ---------------------------------------------------------------------------
# start_stream
# ---------------------------------------------------------------------------
async def test_denied_bot_is_skipped_for_one_that_can_speak(fs):
_token(fs, "t1"); _token(fs, "t2")
fs.snap_mode["t1"] = "muted"
doc = await _start(node_id="n1")
assert doc["token_id"] == "t2"
async def test_every_bot_denied_is_a_409_naming_bot_channel_permission_and_reason(fs):
_token(fs, "t1", name="DRB Radio 2")
fs.snap_mode["t1"] = "muted"
with pytest.raises(streams.StreamError) as e:
await _start(node_id="n1")
err = e.value
assert err.code == "missing_permissions" and err.status == 409
assert err.message.startswith("DRB Radio 2 can't Speak in #dispatch: denied by #dispatch's override for @Muted.")
assert "Edit Channel → Permissions" in err.message
assert err.extra["denied"][0]["name"] == "DRB Radio 2" and "token" not in str(err.extra)
assert not fs.get("bot_tokens", "t1")["lease"] # nothing handed to a node
assert not fs.data.get("discord_streams")
async def test_unknown_fails_open(fs):
_token(fs, "t1")
fs.snap_mode["t1"] = "unknown"
assert (await _start(node_id="n1"))["token_id"] == "t1"
async def test_check_is_bounded_by_the_time_budget(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_perm_check_budget_seconds", 0.05)
_token(fs, "t1")
fs.snap_mode["t1"] = "slow"
loop = asyncio.get_running_loop()
t0 = loop.time()
doc = await _start(node_id="n1")
assert doc["token_id"] == "t1" and loop.time() - t0 < 2
async def test_concurrency_is_bounded(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_perm_check_concurrency", 2)
for i in range(6):
_token(fs, f"t{i}")
live = {"now": 0, "max": 0}
async def _snapshot(token_doc, guild_id, *, refresh=False):
live["now"] += 1
live["max"] = max(live["max"], live["now"])
await asyncio.sleep(0.01)
live["now"] -= 1
return _snap(token_doc["token_id"])
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
await _start(node_id="n1")
assert live["max"] == 2
async def test_kill_switch_restores_unchecked_picking(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_perm_check_enabled", False)
_token(fs, "t1")
fs.snap_mode["t1"] = "muted"
assert (await _start(node_id="n1"))["token_id"] == "t1"
assert fs.snap_calls == []
async def test_text_channel_is_bad_channel_not_missing_permissions(fs):
_token(fs, "t1")
with pytest.raises(streams.StreamError) as e:
await _start(channel="1003", node_id="n1")
assert e.value.code == "bad_channel" and e.value.status == 400 and "#chat" in e.value.message
async def test_only_this_orgs_and_member_bots_are_asked(fs):
_token(fs, "pool"); _token(fs, "theirs", org=OTHER); _token(fs, "elsewhere", guilds=("999",))
_token(fs, "dead", valid=False)
await _start(node_id="n1")
assert {c[0] for c in fs.snap_calls} == {"pool"}
async def test_system_preferred_bot_is_used_first(fs):
_token(fs, "a"); _token(fs, "z")
fs.put("systems", SYS, {**fs.get("systems", SYS), "preferred_token_id": "z"})
assert (await _start(node_id="n1"))["token_id"] == "z"
async def test_system_preferred_bot_is_a_hint_not_a_demand(fs):
_token(fs, "a"); _token(fs, "z")
fs.put("systems", SYS, {**fs.get("systems", SYS), "preferred_token_id": "z"})
fs.snap_mode["z"] = "muted"
assert (await _start(node_id="n1"))["token_id"] == "a"
async def test_one_bot_setup_invite_mark_preferred_stream(fs):
"""Owner requirement 1: only the system's own BYO radio bot is in the server."""
_token(fs, "byo", org=ORG)
_token(fs, "pool", guilds=("555",))
fs.put("systems", SYS, {**fs.get("systems", SYS), "preferred_token_id": "byo"})
assert (await _start())["token_id"] == "byo"
async def test_running_stream_moves_off_a_bot_that_lost_its_permission(fs):
_token(fs, "t1"); _token(fs, "t2")
first = await _start(node_id="n1")
assert first["token_id"] == "t1"
discord_rest.invalidate()
fs.snap_mode["t1"] = "muted"
again = await streams.start_stream(org_id=ORG, stream_id=first["stream_id"], system_id=SYS, node_id="n1")
assert again["token_id"] == "t2" and again["reused"] is False
assert first["stream_id"] not in (fs.get("bot_tokens", "t1")["lease"] or {}).get("streams", [])
async def test_handle_actual_drops_cached_access_on_edge_refusals(fs, monkeypatch):
_token(fs, "t1")
doc = await _start(node_id="n1")
dropped = []
monkeypatch.setattr(discord_rest, "invalidate", lambda token_id=None, guild_id=None: dropped.append(
(token_id, guild_id)) or 1)
for code in ("missing_permissions", "channel_full", "bad_channel", "no_audio"):
await streams.handle_actual("n1", {"streams": {doc["stream_id"]: {"state": "error", "code": code}}})
assert dropped == [("t1", GUILD)] * 3
# ---------------------------------------------------------------------------
# Every entry point gets the same refusal
# ---------------------------------------------------------------------------
def _all_denied(fs):
_token(fs, "t1", name="DRB Radio 2")
fs.snap_mode["t1"] = "muted"
def test_bot_join_route_returns_409_with_text(fs):
_all_denied(fs)
r = run(discord_router.start_stream(discord_router.StreamStart(guild_id=GUILD, channel_id="1001",
system_id=SYS), {"service": True}))
assert r.status_code == 409
import json
body = json.loads(r.body)
assert body["code"] == "missing_permissions" and "DRB Radio 2 can't Speak in #dispatch" in body["detail"]
assert body["denied"][0]["missing"] == ["Speak"] and "secret" not in r.body.decode()
def test_web_picker_returns_409_with_text(fs):
_all_denied(fs)
r = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=GUILD, channel_id="1001"), OWNER))
assert r.status_code == 409 and b"missing_permissions" in r.body and b"#dispatch" in r.body
def test_node_discord_join_command_is_refused(fs):
_all_denied(fs)
with pytest.raises(HTTPException) as e:
run(nodes_router.send_command("n1", CommandPayload(action="discord_join", guild_id=GUILD,
channel_id="1001"), ADMIN))
assert e.value.status_code == 409 and "can't Speak in #dispatch" in e.value.detail
async def test_reconciler_marks_the_stream_with_the_refusal(fs):
_token(fs, "t1", name="DRB Radio 2")
doc = await _start(node_id="n1", mode="persistent")
# Node goes away; the bot meanwhile lost Speak.
await streams.release_offline_node("n1")
fs.put("nodes", "n1", {**fs.get("nodes", "n1"), "status": "offline", "last_seen": "2000-01-01T00:00:00+00:00"})
discord_rest.invalidate()
fs.snap_mode["t1"] = "muted"
await streams.reconcile_once()
s = fs.get("discord_streams", doc["stream_id"])
assert s["actual"]["state"] == "error" and "DRB Radio 2 can't Speak in #dispatch" in s["actual"]["error"]
assert not s.get("token_id")
# ---------------------------------------------------------------------------
# GET voice-check
# ---------------------------------------------------------------------------
def test_voice_check_reports_each_channel_for_an_org_member(fs):
_token(fs, "t1", name="DRB Radio 1"); _token(fs, "t2", name="DRB Radio 2")
fs.snap_mode["t2"] = "muted"
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id=None, decoded=MEMBER))
assert out["enabled"] is True and out["guild_name"] == "Fire Co. 7"
by = {c["channel_id"]: c for c in out["channels"]}
assert by["1001"]["state"] == "ready" and by["1001"]["any_ok"] is True
bots = {b["name"]: b for b in by["1001"]["bots"]}
assert bots["DRB Radio 2"]["ok"] is False and bots["DRB Radio 2"]["missing"] == ["Speak"]
assert bots["DRB Radio 2"]["reason"] == "denied by #dispatch's override for @Muted"
assert all("token_id" not in b for c in out["channels"] for b in c["bots"]) # names only
assert "secret" not in str(out)
assert by["1002"]["user_limit"] == 5
def test_voice_check_needs_fix_when_every_free_bot_is_denied(fs):
_token(fs, "t1")
fs.snap_mode["t1"] = "muted"
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER))
assert [c["state"] for c in out["channels"]] == ["needs_fix"]
fs.snap_mode["t1"] = "unknown"
discord_rest.invalidate()
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER))
assert out["channels"][0]["state"] == "unknown" # can't tell -> start still allowed
def test_voice_check_is_org_scoped(fs):
_token(fs, "t1")
for node, guild, who in (("n1", "200", OWNER), ("nx", GUILD, OWNER), ("n1", GUILD, STRANGER)):
with pytest.raises(HTTPException) as e:
run(checks.node_voice_check(node, guild, refresh=0, channel_id=None, decoded=who))
assert e.value.status_code == 404
def test_voice_check_admin_sees_token_ids_and_service_route(fs):
_token(fs, "t1")
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id=None, decoded=ADMIN))
assert out["channels"][0]["bots"][0]["token_id"] == "t1"
out = run(checks.guild_voice_check(GUILD, channel_id="1001", refresh=0, decoded={"service": True}))
assert out["org_id"] == ORG and out["channels"][0]["bots"][0]["token_id"] == "t1"
def test_voice_check_refresh_is_rate_limited_per_caller_and_server(fs):
_token(fs, "t1")
first = run(checks.node_voice_check("n1", GUILD, refresh=1, channel_id=None, decoded=OWNER))
second = run(checks.node_voice_check("n1", GUILD, refresh=1, channel_id=None, decoded=OWNER))
assert first["refreshed"] is True and second["refreshed"] is False
assert [c[2] for c in fs.snap_calls] == [True, False]
def test_voice_check_lists_invites_within_the_pool_cap(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_pool_bot_guild_cap", 2)
_token(fs, "in")
_token(fs, "room", guilds=("1",))
_token(fs, "full", guilds=("1", "2"))
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id=None, decoded=OWNER))
assert [i["name"] for i in out["invite"]] == ["DRB Radio room"]
def test_voice_check_busy_and_other_node(fs):
_token(fs, "t1", lease={"node_id": "n2", "streams": [], "lease_id": "L"})
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER))
b = out["channels"][0]["bots"][0]
assert b["ok"] is True and b["available"] is False and b["unavailable_reason"] == "other_node"
assert out["channels"][0]["state"] == "busy"
def test_voice_check_kill_switch(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_perm_check_enabled", False)
_token(fs, "t1")
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id=None, decoded=OWNER))
assert out["enabled"] is False and {c["state"] for c in out["channels"]} == {"unchecked"}
assert fs.snap_calls == []
# ---------------------------------------------------------------------------
# PUT /tokens/{id}/prefer/{system_id} — owner decision Q2
# ---------------------------------------------------------------------------
def _prefer(tid, sid, who):
return run(tokens_router.set_preferred_system(tid, sid, who))
def test_owner_may_prefer_own_byo_bot_for_own_system(fs):
_token(fs, "byo", org=ORG)
assert _prefer("byo", SYS, OWNER)["preferred_for_system_id"] == SYS
assert fs.get("systems", SYS)["preferred_token_id"] == "byo"
assert fs.get("bot_tokens", "byo")["preferred_for_system_id"] == SYS
assert any(a.get("action") == "discord.token.prefer" for a in (fs.data.get("audit_log") or {}).values())
assert _prefer("byo", "_none", OWNER)["preferred_for_system_id"] is None
assert fs.get("systems", SYS)["preferred_token_id"] is None
@pytest.mark.parametrize("tid,sid,who,status", [
("pool", SYS, OWNER, 403), # pool bots: platform admin only
("byo", "shared", OWNER, 403), # shared system: platform admin only
("theirs", SYS, OWNER, 404), # another org's bot: not found
("byo", "sys-b", OWNER, 404), # another org's system: not found
("byo", SYS, MEMBER, 403), # members can't
("byo", SYS, STRANGER, 404),
("nope", SYS, OWNER, 404),
])
def test_prefer_refusals(fs, tid, sid, who, status):
_token(fs, "pool"); _token(fs, "byo", org=ORG); _token(fs, "theirs", org=OTHER)
with pytest.raises(HTTPException) as e:
_prefer(tid, sid, who)
assert e.value.status_code == status
assert not (fs.get("systems", sid) or {}).get("preferred_token_id")
def test_admin_may_prefer_any_bot_for_any_system(fs):
_token(fs, "pool")
assert _prefer("pool", "shared", ADMIN)["preferred_for_system_id"] == "shared"
def test_prefer_keeps_both_pointers_single_valued(fs):
_token(fs, "byo", org=ORG); _token(fs, "byo2", org=ORG)
fs.put("systems", "sys-2", {"system_id": "sys-2", "name": "City", "org_id": ORG})
_prefer("byo", SYS, OWNER)
_prefer("byo", "sys-2", OWNER) # moved: the old system no longer points at it
assert fs.get("systems", SYS)["preferred_token_id"] is None
_prefer("byo2", "sys-2", OWNER) # replaced: the old bot loses its back-pointer
assert fs.get("bot_tokens", "byo")["preferred_for_system_id"] is None
assert fs.get("systems", "sys-2")["preferred_token_id"] == "byo2"
# ---------------------------------------------------------------------------
# End to end through the real REST client (Discord answered by MockTransport)
# ---------------------------------------------------------------------------
async def test_real_snapshot_path_denies_and_never_logs_the_token(fs, monkeypatch, caplog):
import httpx
from app.internal import discord_rest as rest
monkeypatch.undo() # drop the fixture's guild_snapshot stub ...
fs = FakeFirestore.install(monkeypatch) # ... and re-install only what this test needs
from app.internal.mqtt_handler import mqtt_handler
monkeypatch.setattr(mqtt_handler, "publish_discord_desired", lambda n, p: True)
monkeypatch.setattr(settings, "discord_perm_check_enabled", True)
streams._guild_locks.clear()
fs.put("nodes", "n1", {"node_id": "n1", "name": "Node n1", "org_id": ORG, "status": "online",
"approval_status": "approved", "assigned_system_id": SYS})
fs.put("systems", SYS, {"system_id": SYS, "name": "County P25", "org_id": ORG})
_token(fs, "t1", name="DRB Radio 2", bot_user_id="u-t1")
seen = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append((request.url.path, request.headers.get("authorization")))
path = request.url.path.removeprefix("/api/v10")
if path == f"/guilds/{GUILD}":
return httpx.Response(200, json=_guild())
if path == f"/guilds/{GUILD}/members/u-t1":
return httpx.Response(200, json={"user": {"id": "u-t1"}, "roles": ["r-muted"]})
if path == f"/guilds/{GUILD}/channels":
return httpx.Response(200, json=_channels())
return httpx.Response(404, json={"code": 0})
rest.reset()
monkeypatch.setattr(rest, "_transport", httpx.MockTransport(handler))
with pytest.raises(streams.StreamError) as e:
await _start(node_id="n1")
assert e.value.code == "missing_permissions" and "can't Speak in #dispatch" in e.value.message
assert {p for p, _ in seen} >= {f"/api/v10/guilds/{GUILD}", f"/api/v10/guilds/{GUILD}/channels"}
assert all(a == "Bot secret-t1" for _, a in seen)
assert "secret-t1" not in caplog.text and "secret-t1" not in e.value.message
# Cached: a second start asks Discord nothing new.
n = len(seen)
with pytest.raises(streams.StreamError):
await _start(node_id="n1")
assert len(seen) == n