Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -829,11 +829,27 @@ def _source_from_reaction_for_auth(self, update):
chat_id=chat_id, chat_type=chat_type, user_id=user_id, user_name=user_name, thread_id=None, message_id=str(message_id))

def _telegram_auth_env_configured(self) -> bool:
"""Return True when Telegram auth env vars make an early decision safe."""
keys = (
"TELEGRAM_ALLOWED_USERS", "TELEGRAM_GROUP_ALLOWED_USERS", "TELEGRAM_GROUP_ALLOWED_CHATS",
"TELEGRAM_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS", "GATEWAY_ALLOW_ALL_USERS")
return any(_scoped_gate_env(key).strip() for key in keys)
"""Return True when Telegram auth env vars make an early decision safe.

Allowlist keys carry identities/chats, so any non-empty value counts as
configured. The ``*_ALLOW_ALL_USERS`` toggles are booleans, so they must
be parsed as such — an explicit ``false`` (a common ``.env``/template
default) means *not* configured, matching the boolean parse at
``_is_user_authorized`` and the Discord adapter. Treating a literal
``false`` as "configured" would route unknown DMs through the runner's
allowlist check and drop them before the pairing flow could run (#68794).
"""
allowlist_keys = (
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
"GATEWAY_ALLOWED_USERS",
)
allow_all_keys = ("TELEGRAM_ALLOW_ALL_USERS", "GATEWAY_ALLOW_ALL_USERS")
truthy = {"true", "1", "yes"}
return any(_scoped_gate_env(key).strip() for key in allowlist_keys) or any(
_scoped_gate_env(key).strip().lower() in truthy for key in allow_all_keys
)

def _should_pass_unauthorized_dm_for_pairing(self, source) -> bool:
"""True when an unauthorized DM must still reach gateway pairing (``unauthorized_dm_behavior``
Expand Down
173 changes: 173 additions & 0 deletions tests/gateway/test_telegram_auth_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,120 @@ def track_build(*a, **kw):
assert build_called is True


def test_is_user_authorized_from_message_wildcard():
"""_is_user_authorized_from_message should accept wildcard '*'."""
adapter = _make_adapter(allow_from=["*"])

msg = _make_message(from_user_id=999)
assert adapter._is_user_authorized_from_message(msg) is True


def test_is_user_authorized_from_message_no_from_user():
"""_is_user_authorized_from_message should return True for messages without from_user."""
adapter = _make_adapter(allow_from=["111"])

msg = _make_message()
msg.from_user = None
assert adapter._is_user_authorized_from_message(msg) is True


def test_is_user_authorized_from_message_callback():
"""_is_user_authorized_from_message should use _is_callback_user_authorized."""
adapter = _make_adapter(callback_auth=lambda uid, **_kw: uid == "555")

msg = _make_message(from_user_id=555)
assert adapter._is_user_authorized_from_message(msg) is True

msg = _make_message(from_user_id=666)
assert adapter._is_user_authorized_from_message(msg) is False


def test_unknown_dm_with_no_allowlist_passes_to_pairing(monkeypatch):
"""Unknown DMs must still reach the gateway pairing flow when no allowlist exists."""
for key in (
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
"TELEGRAM_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
):
monkeypatch.delenv(key, raising=False)

adapter = _make_adapter()
msg = _make_message(from_user_id=111, chat_id=111, chat_type="private")

assert adapter._is_user_authorized_from_message(msg) is True


_ALL_AUTH_ENV_KEYS = (
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
"TELEGRAM_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
)


def test_allow_all_users_false_is_not_configured(monkeypatch):
"""A literal ``false`` ALLOW_ALL toggle must not count as configured auth (#68794).

``.env`` files and config templates routinely persist an explicit
``GATEWAY_ALLOW_ALL_USERS=false``. An unset key and a ``false`` key are
semantically identical everywhere else, so the intake prefilter must treat
both as "no auth configured" and let unknown DMs reach pairing.
"""
for key in _ALL_AUTH_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
adapter = _make_adapter()

# Only an explicit falsey ALLOW_ALL toggle is set → not configured.
for key in ("GATEWAY_ALLOW_ALL_USERS", "TELEGRAM_ALLOW_ALL_USERS"):
for val in ("false", "False", "0", "no", ""):
monkeypatch.setenv(key, val)
assert adapter._telegram_auth_env_configured() is False, (key, val)
monkeypatch.delenv(key, raising=False)

# A truthy ALLOW_ALL toggle → configured.
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
assert adapter._telegram_auth_env_configured() is True
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)

# A real allowlist value (identities/chats) still counts as configured.
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "222")
assert adapter._telegram_auth_env_configured() is True


def test_unknown_dm_reaches_pairing_when_allow_all_is_false(monkeypatch):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is an adapter-level unit test, not end-to-end: the fake Runner proves the prefilter bypasses its authorization method, but it never executes GatewayRunner's unauthorized-DM branch that generates and sends a pairing code. Please add a real-runner regression for that delivery path.

"""End-to-end: with only ``GATEWAY_ALLOW_ALL_USERS=false`` set, an unknown DM
must fall through to pairing instead of being rejected by the runner's
allowlist check (#68794)."""
for key in _ALL_AUTH_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "false")

rejected = []

class Runner:
def _is_user_authorized(self, source):
# If the prefilter mis-detects `false` as configured, it consults
# this and drops the unknown DM before pairing.
rejected.append(source.user_id)
return False

async def handle(self, event):
return None

runner = Runner()
adapter = _make_adapter()
adapter._message_handler = runner.handle
msg = _make_message(from_user_id=111, chat_id=111, chat_type="private")

assert adapter._is_user_authorized_from_message(msg) is True
assert rejected == [] # runner allowlist was never consulted


def test_runner_auth_gets_group_user_allowlist_context(monkeypatch):
"""Group user allowlists need a group-shaped source, not a DM-shaped one."""
monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_USERS", "111")
Expand Down Expand Up @@ -370,3 +484,62 @@ def test_multiplex_closure_handler_without_callback_falls_back_to_env(monkeypatc
assert adapter._is_user_authorized_from_message(
_make_message(from_user_id=555, chat_id=-100123, chat_type="group")
) is False


@pytest.mark.asyncio
async def test_allow_all_false_unknown_dm_reaches_real_runner_pairing(monkeypatch, tmp_path):
"""Real-runner regression: with only ``GATEWAY_ALLOW_ALL_USERS=false`` set, an
unknown Telegram DM must reach ``GatewayRunner``'s unauthorized-DM branch and
actually generate + deliver a pairing code (#68794).

The focused adapter-prefilter tests above prove the intake filter treats a
literal ``false`` toggle as "not configured" and lets the DM through. This
exercises the downstream path the prefilter hands off to: the runner's own
``_is_user_authorized`` must likewise reject the unknown user under a falsey
global toggle, so ``_handle_message`` falls into the pairing branch that
calls ``pairing_store.generate_code`` and sends the code via the adapter.
"""
from gateway.config import GatewayConfig
from gateway.platforms.base import MessageEvent
from gateway.run import GatewayRunner
from gateway.session import SessionSource
import gateway.run as gateway_run

for key in _ALL_AUTH_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
# Only an explicit falsey global toggle — no allowlist of any kind.
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "false")

monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
(tmp_path / "config.yaml").write_text("", encoding="utf-8")

runner = GatewayRunner(GatewayConfig())
adapter = SimpleNamespace(send=AsyncMock())
runner.adapters[Platform.TELEGRAM] = adapter

generated = []
original_generate = runner.pairing_store.generate_code

def tracking_generate(*args, **kwargs):
code = original_generate(*args, **kwargs)
generated.append((args, code))
return code

runner.pairing_store.generate_code = tracking_generate

source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="111",
chat_type="dm",
user_id="111",
user_name="stranger",
)
event = MessageEvent(text="hi", source=source, internal=False)

await runner._handle_message(event)

# The unknown DM must have driven the real pairing branch, not been dropped.
assert generated, "unknown DM under GATEWAY_ALLOW_ALL_USERS=false should generate a pairing code"
assert adapter.send.await_count == 1
sent_text = adapter.send.await_args.args[1]
assert "pairing code" in sent_text.lower()
Loading