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>
99 lines
4.0 KiB
Python
99 lines
4.0 KiB
Python
# All C2 core settings have defaults — no env setup needed.
|
|
#
|
|
# firebase-admin and google-cloud-firestore are runtime-only dependencies: they
|
|
# are installed in the container but not in the local dev venv, and
|
|
# app/internal/firestore.py calls _init_firebase() at import time. Without the
|
|
# stubs below, importing ANY module that reaches Firestore fails at collection
|
|
# time, which is why test_mqtt_handler and test_node_sweeper could not be run
|
|
# outside the container.
|
|
#
|
|
# The stubs are installed only when the real packages are absent, so the
|
|
# container's real SDK is never shadowed.
|
|
import sys
|
|
from types import ModuleType
|
|
from unittest.mock import MagicMock
|
|
|
|
try: # pragma: no cover - exercised only by which packages are installed
|
|
import firebase_admin # noqa: F401
|
|
except ModuleNotFoundError:
|
|
_firebase = ModuleType("firebase_admin")
|
|
# Falsy so _init_firebase() takes the initialize_app() branch rather than the
|
|
# already-initialised branch, which is itself broken (see DEFERRED.md).
|
|
_firebase._apps = {}
|
|
_firebase.initialize_app = MagicMock()
|
|
_firebase.credentials = MagicMock()
|
|
_firebase.firestore = MagicMock()
|
|
|
|
_credentials = ModuleType("firebase_admin.credentials")
|
|
_credentials.Certificate = MagicMock()
|
|
_credentials.ApplicationDefault = MagicMock()
|
|
|
|
_fs = ModuleType("firebase_admin.firestore")
|
|
_fs.client = MagicMock()
|
|
# A distinct sentinel rather than a MagicMock: production code writes this
|
|
# into dicts that tests compare against, and a MagicMock compares unequal
|
|
# to itself across attribute accesses.
|
|
_fs.SERVER_TIMESTAMP = "__SERVER_TIMESTAMP__"
|
|
# Same reasoning as SERVER_TIMESTAMP above: a distinct sentinel, not a
|
|
# MagicMock, so `fstore.DELETE_FIELD is fs.DELETE_FIELD` and dict/`is`
|
|
# comparisons against it in tests (server-26#96/#114, PR #132) behave.
|
|
_fs.DELETE_FIELD = "__DELETE_FIELD__"
|
|
|
|
_auth = ModuleType("firebase_admin.auth")
|
|
_auth.verify_id_token = MagicMock()
|
|
_auth.set_custom_user_claims = MagicMock()
|
|
_auth.get_user_by_email = MagicMock()
|
|
_auth.get_user = MagicMock()
|
|
# Type used in annotations at import time by routers/users.py, so it has to
|
|
# exist as a name even though nothing here ever instantiates it. Without it,
|
|
# importing app.main -- and therefore testing anything wired at app level,
|
|
# like the CORS policy -- fails at collection.
|
|
_auth.UserRecord = MagicMock()
|
|
_auth.list_users = MagicMock()
|
|
_auth.update_user = MagicMock()
|
|
_auth.create_user = MagicMock()
|
|
_auth.delete_user = MagicMock()
|
|
|
|
_firebase.auth = _auth
|
|
_firebase.credentials = _credentials
|
|
_firebase.firestore = _fs
|
|
|
|
sys.modules["firebase_admin"] = _firebase
|
|
sys.modules["firebase_admin.credentials"] = _credentials
|
|
sys.modules["firebase_admin.firestore"] = _fs
|
|
sys.modules["firebase_admin.auth"] = _auth
|
|
|
|
try: # pragma: no cover
|
|
from google.cloud.firestore_v1.base_query import FieldFilter # noqa: F401
|
|
except ModuleNotFoundError:
|
|
for _name in (
|
|
"google", "google.cloud", "google.cloud.firestore_v1",
|
|
"google.cloud.firestore_v1.base_query",
|
|
):
|
|
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()
|