Discord picker: per-bot permission status, owner's preferred bot (server-26#225 S4)

Node-page picker and the admin Add form now show, per voice channel, whether
a radio bot can stream there, from S3's voice-check routes:
- Badge per channel: Ready / Needs fix (names the missing permission) /
  Couldn't check / Bots busy / No radio bot / Not a voice channel / Limit N.
- The chosen channel shows "<bot> can't Speak in #dispatch: <what removed it>"
  plus c2-core's fix sentence, and an expandable per-bot list.
- Footer: "Permissions checked 40s ago · Re-check" (refresh=1, rate limited
  server-side; a limited re-check says so). Invite links when no bot fits.
- Start is disabled only on a definite refusal (needs_fix, bad_channel);
  unknown fails open. A channel this system already plays in is judged by
  the bot that carries it, so Update/move is never blocked by the other bots.
- Admin Add form disables bots that are definitely denied in the bot list.
- 409 refusals (missing_permissions, preferred_busy, preferred_other_node,
  bad_channel) are shown verbatim, multi-line.

Org owners (owner decision Q2) get "Radio bot per system" on
Settings > Organization: their own systems, their own BYO bots, set/clear
through S3's PUT /tokens/{id}/prefer/{system}. New GET /tokens/org/preferred
lists only the caller org's systems and bots (no pool bots, no token
strings; a pool bot an admin chose is flagged, not named).

The prefer route now drops the 5-minute doc_get_cached entry for every
system it rewrites, so a new preference applies to the next start.

Stacked on feat/225-s3-perm-check: must merge after S3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:28:36 -04:00
co-authored by Claude Opus 5.5
parent 2c2cf9d9be
commit 1ee1560383
9 changed files with 938 additions and 20 deletions
@@ -0,0 +1,113 @@
"""
Org-owner "radio bot per system" card (server-26#225 S4, owner decision Q2):
GET /tokens/org/preferred lists the owner's own systems and own BYO bots (no
pool bots, no token strings); a preference set through
PUT /tokens/{id}/prefer/{system} (S3) applies to the very next start, not after
the 5-minute systems cache.
"""
import pytest
from fastapi.testclient import TestClient
from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
from app.main import app
from tests.fake_firestore import FakeFirestore
client = TestClient(app)
ORG, OTHER = "org-a", "org-b"
OWNER = {"uid": "u-own", "org_id": ORG, "org_role": "owner"}
MEMBER = {"uid": "u-mem", "org_id": ORG, "org_role": "member", "role": "operator"}
STRANGER = {"uid": "u-x", "org_id": OTHER, "org_role": "owner"}
ADMIN_NO_ORG = {"uid": "u-admin", "role": "admin"}
@pytest.fixture
def fs(monkeypatch):
fake = FakeFirestore.install(monkeypatch)
fake.put("systems", "s-a", {"system_id": "s-a", "name": "County P25", "org_id": ORG})
fake.put("systems", "s-a2", {"system_id": "s-a2", "name": "City Fire", "org_id": ORG})
fake.put("systems", "s-b", {"system_id": "s-b", "name": "Other County", "org_id": OTHER})
fake.put("systems", "s-shared", {"system_id": "s-shared", "name": "Shared", "org_id": None})
fake.put("bot_tokens", "byo-a", {"token_id": "byo-a", "name": "Alpha Radio", "token": "secret-a",
"org_id": ORG, "valid": True, "guild_ids": ["100", "101"],
"application_id": "555"})
fake.put("bot_tokens", "byo-a-bad", {"token_id": "byo-a-bad", "name": "Broken", "token": "secret-bad",
"org_id": ORG, "valid": False})
fake.put("bot_tokens", "byo-b", {"token_id": "byo-b", "name": "Bravo Radio", "token": "secret-b",
"org_id": OTHER, "valid": True})
fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "secret-p",
"org_id": None, "valid": True})
yield fake
app.dependency_overrides.clear()
def as_(who):
# /tokens is mounted behind require_service_or_firebase_token (main.py).
app.dependency_overrides[require_service_or_firebase_token] = lambda: who
app.dependency_overrides[require_firebase_token] = lambda: who
def test_owner_lists_own_systems_and_own_bots_only(fs):
as_(OWNER)
r = client.get("/tokens/org/preferred")
assert r.status_code == 200, r.text
body = r.json()
assert body["org_id"] == ORG
assert [s["system_id"] for s in body["systems"]] == ["s-a2", "s-a"] # own systems, by name
assert {b["token_id"] for b in body["bots"]} == {"byo-a", "byo-a-bad"} # no pool, no other org
alpha = next(b for b in body["bots"] if b["token_id"] == "byo-a")
assert alpha["server_count"] == 2 and alpha["invite_url"] and alpha["valid"] is True
assert "secret" not in repr(body) # never a token string
def test_member_is_refused(fs):
as_(MEMBER)
assert client.get("/tokens/org/preferred").status_code == 403
def test_admin_without_org_gets_403_not_every_org(fs):
as_(ADMIN_NO_ORG)
assert client.get("/tokens/org/preferred").status_code == 403
# With ?org_id= a platform admin may act for that org (resolve_org_scope).
body = client.get(f"/tokens/org/preferred?org_id={OTHER}").json()
assert [s["system_id"] for s in body["systems"]] == ["s-b"]
def test_non_admin_org_override_is_ignored(fs):
as_(STRANGER)
body = client.get(f"/tokens/org/preferred?org_id={ORG}").json()
assert body["org_id"] == OTHER
assert [s["system_id"] for s in body["systems"]] == ["s-b"]
assert [b["token_id"] for b in body["bots"]] == ["byo-b"]
def test_platform_pick_on_owner_system_is_flagged_not_named(fs):
fs.put("systems", "s-a", {"system_id": "s-a", "name": "County P25", "org_id": ORG,
"preferred_token_id": "pool-1"})
as_(OWNER)
s = next(x for x in client.get("/tokens/org/preferred").json()["systems"] if x["system_id"] == "s-a")
assert s["preferred_token_id"] is None and s["preferred_name"] is None
assert s["preferred_is_platform_bot"] is True
assert "DRB Radio 1" not in repr(s)
def test_owner_set_then_list_round_trip_and_cache_is_dropped(fs):
stale = (1e18, {"system_id": "s-a", "org_id": ORG}) # what start_stream would read for 5 min
fstore._doc_cache[f"{fstore._path('systems')}/s-a"] = stale
fstore._doc_cache[f"{fstore._path('systems')}/s-a2"] = stale
as_(OWNER)
assert client.put("/tokens/byo-a/prefer/s-a").status_code == 200
assert f"{fstore._path('systems')}/s-a" not in fstore._doc_cache
s = next(x for x in client.get("/tokens/org/preferred").json()["systems"] if x["system_id"] == "s-a")
assert s["preferred_token_id"] == "byo-a" and s["preferred_name"] == "Alpha Radio"
# Moving the bot to another system drops both systems' cached copies.
fstore._doc_cache[f"{fstore._path('systems')}/s-a"] = stale
assert client.put("/tokens/byo-a/prefer/s-a2").status_code == 200
assert f"{fstore._path('systems')}/s-a" not in fstore._doc_cache
assert f"{fstore._path('systems')}/s-a2" not in fstore._doc_cache
fstore._doc_cache[f"{fstore._path('systems')}/s-a2"] = stale
assert client.put("/tokens/byo-a/prefer/_none").status_code == 200
assert f"{fstore._path('systems')}/s-a2" not in fstore._doc_cache
assert fs.get("systems", "s-a2")["preferred_token_id"] is None