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:
co-authored by
Claude Opus 5.5
parent
a75149b2eb
commit
2c2cf9d9be
@@ -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
|
||||
Reference in New Issue
Block a user