e253ea0233
Under gateway.multiplex_profiles, os.environ holds the DEFAULT profile's values and a secondary profile's .env exists only in its secret scope. Matrix, Home Assistant, Teams, DingTalk, SMS and Telegram already read their *secret* scope-aware, but read the paired endpoint/identity raw from os.environ — so a secondary's token/secret/password was paired with the default profile's host or app identity: - Matrix: MATRIX_HOMESERVER / MATRIX_USER_ID / MATRIX_DEVICE_ID in __init__ and MATRIX_HOMESERVER in _standalone_send -> the secondary's access token was sent to the default's homeserver (and its E2EE device id reused). - Home Assistant: HASS_URL in __init__ and _standalone_send -> the secondary's HASS_TOKEN was posted to the default's HA instance. - Teams: TEAMS_CLIENT_ID / TEAMS_TENANT_ID (env-first, even over extra), TEAMS_SERVICE_URL, TEAMS_HOME_CHANNEL(_NAME) in _credentials, _env_enablement, _standalone_send and __init__ -> Bot Framework token requested for the default's app with the secondary's secret; cron home channel was the default's conversation. - DingTalk: DINGTALK_CLIENT_ID in _credentials, DINGTALK_WEBHOOK_URL in _standalone_send -> wrong app id; cron output posted to the default's robot webhook (URL carries its access_token). - Telegram: TELEGRAM_WEBHOOK_URL in connect() -> the default's public webhook URL registered on the secondary bot, mixing update traffic. Each read now goes through the same scoped reader its sibling secret already uses (_get_scoped_secret / _startup_env_secret / get_secret with the UnscopedSecretError fallback), so a scoped miss is empty rather than the default profile's value; the unscoped default-profile path and single-profile deployments keep reading os.environ, which there IS the profile's own value. Teams _credentials now lets extra (per-profile config.yaml) win over env, matching every other adapter and its own __init__. Live repro (/tmp/mux_audit/fix-endpoint-identity/repro.py, secondary scope "bot2" with the default profile in os.environ): 19 of 24 reads returned the default's endpoint/identity before; 0 after. Salvage: SMS from-number fix cherry-picked from #100625 (tests trimmed to two invariants). Teams identity (#100622) and DingTalk (#100615) reimplemented on the minimal shape — the Teams PR added a _profile_scoped() gate to _env_enablement and the DingTalk PR only covered the YAML bridge, not DINGTALK_CLIENT_ID / DINGTALK_WEBHOOK_URL. Matrix, Home Assistant and Telegram parts have no prior PR. Co-authored-by: nftpoetrist <264138787+nftpoetrist@users.noreply.github.com>
192 lines
8.2 KiB
Python
192 lines
8.2 KiB
Python
"""Invariant: an adapter's endpoint/identity resolves through the SAME profile scope as its secret.
|
|
|
|
Under ``gateway.multiplex_profiles`` ``os.environ`` holds the DEFAULT profile's values; a secondary
|
|
profile's ``.env`` exists only in its secret scope. Every adapter here already read its *secret*
|
|
scope-aware but read the paired homeserver / URL / client_id / from-number / webhook URL raw from
|
|
``os.environ`` — so the secondary's credential was sent to the default profile's host or identity.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from agent import secret_scope as ss
|
|
from gateway.config import PlatformConfig
|
|
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
|
|
|
|
DEFAULT_ENV = {
|
|
"MATRIX_HOMESERVER": "https://default.matrix.example", "MATRIX_USER_ID": "@default:example",
|
|
"MATRIX_DEVICE_ID": "DEFAULTDEV", "MATRIX_ACCESS_TOKEN": "default-matrix-token",
|
|
"HASS_URL": "http://default-ha.example:8123", "HASS_TOKEN": "default-ha-token",
|
|
"TEAMS_CLIENT_ID": "default-client-id", "TEAMS_TENANT_ID": "default-tenant",
|
|
"TEAMS_CLIENT_SECRET": "default-teams-secret", "TEAMS_HOME_CHANNEL": "default-conv",
|
|
"DINGTALK_CLIENT_ID": "default-ding-id", "DINGTALK_CLIENT_SECRET": "default-ding-secret",
|
|
"DINGTALK_WEBHOOK_URL": "https://oapi.dingtalk.com/robot/send?access_token=DEFAULT",
|
|
"TELEGRAM_WEBHOOK_URL": "https://default.example/tg",
|
|
}
|
|
SECONDARY = {
|
|
"MATRIX_HOMESERVER": "https://bot2.matrix.example", "MATRIX_USER_ID": "@bot2:example",
|
|
"MATRIX_DEVICE_ID": "BOT2DEV", "MATRIX_ACCESS_TOKEN": "bot2-matrix-token",
|
|
"HASS_URL": "http://bot2-ha.example:8123", "HASS_TOKEN": "bot2-ha-token",
|
|
"TEAMS_CLIENT_ID": "bot2-client-id", "TEAMS_TENANT_ID": "bot2-tenant",
|
|
"TEAMS_CLIENT_SECRET": "bot2-teams-secret", "TEAMS_HOME_CHANNEL": "bot2-conv",
|
|
"DINGTALK_CLIENT_ID": "bot2-ding-id", "DINGTALK_CLIENT_SECRET": "bot2-ding-secret",
|
|
"DINGTALK_WEBHOOK_URL": "https://oapi.dingtalk.com/robot/send?access_token=BOT2",
|
|
"TELEGRAM_WEBHOOK_URL": "https://bot2.example/tg",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def secondary_scope(monkeypatch):
|
|
"""Default profile in os.environ, multiplex on, secondary profile's scope installed."""
|
|
for key, value in DEFAULT_ENV.items():
|
|
monkeypatch.setenv(key, value)
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope(SECONDARY)
|
|
yield
|
|
ss.reset_secret_scope(token)
|
|
ss.set_multiplex_active(False)
|
|
|
|
|
|
def test_matrix_homeserver_identity_follow_the_scoped_token(secondary_scope):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
adapter = MatrixAdapter(PlatformConfig(enabled=True))
|
|
assert adapter._access_token == SECONDARY["MATRIX_ACCESS_TOKEN"]
|
|
assert (adapter._homeserver, adapter._user_id, adapter._device_id) == (
|
|
SECONDARY["MATRIX_HOMESERVER"], SECONDARY["MATRIX_USER_ID"], SECONDARY["MATRIX_DEVICE_ID"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_matrix_standalone_send_posts_scoped_token_to_scoped_homeserver(secondary_scope, monkeypatch):
|
|
import aiohttp
|
|
from plugins.platforms.matrix import adapter as mx
|
|
|
|
seen = {}
|
|
|
|
class _Resp:
|
|
status = 200
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
async def json(self): return {"event_id": "$x"}
|
|
async def text(self): return ""
|
|
|
|
class _Sess:
|
|
def __init__(self, *a, **k): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
def put(self, url, **kw): seen["url"] = url; seen["headers"] = kw.get("headers", {}); return _Resp()
|
|
|
|
monkeypatch.setattr(aiohttp, "ClientSession", _Sess)
|
|
await mx._standalone_send(PlatformConfig(enabled=True), "!room:example", "hi")
|
|
assert seen["url"].startswith(SECONDARY["MATRIX_HOMESERVER"] + "/")
|
|
assert DEFAULT_ENV["MATRIX_HOMESERVER"] not in seen["url"]
|
|
|
|
|
|
def test_homeassistant_url_follows_the_scoped_token(secondary_scope):
|
|
from plugins.platforms.homeassistant.adapter import HomeAssistantAdapter
|
|
|
|
adapter = HomeAssistantAdapter(PlatformConfig(enabled=True))
|
|
assert (adapter._hass_url, adapter._hass_token) == (SECONDARY["HASS_URL"], SECONDARY["HASS_TOKEN"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_homeassistant_standalone_send_targets_scoped_url(secondary_scope, monkeypatch):
|
|
import aiohttp
|
|
from plugins.platforms.homeassistant import adapter as ha
|
|
|
|
seen = {}
|
|
|
|
class _Resp:
|
|
status = 200
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
async def text(self): return ""
|
|
|
|
class _Sess:
|
|
def __init__(self, *a, **k): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
def post(self, url, **kw): seen["url"] = url; return _Resp()
|
|
|
|
monkeypatch.setattr(aiohttp, "ClientSession", _Sess)
|
|
await ha._standalone_send(PlatformConfig(enabled=True), "x", "hi")
|
|
assert seen["url"].startswith(SECONDARY["HASS_URL"] + "/")
|
|
|
|
|
|
def test_teams_credentials_pair_scoped_secret_with_scoped_app_identity(secondary_scope):
|
|
teams = load_plugin_adapter("teams")
|
|
assert teams._credentials(PlatformConfig(enabled=True)) == (
|
|
SECONDARY["TEAMS_CLIENT_ID"], SECONDARY["TEAMS_CLIENT_SECRET"], SECONDARY["TEAMS_TENANT_ID"])
|
|
# extra (per-profile config.yaml) must win over any env value.
|
|
assert teams._credentials(PlatformConfig(enabled=True, extra={"client_id": "yaml-id"}))[0] == "yaml-id"
|
|
|
|
|
|
def test_teams_env_enablement_seeds_scoped_identity_and_home_channel(secondary_scope):
|
|
teams = load_plugin_adapter("teams")
|
|
seed = teams._env_enablement()
|
|
assert seed["client_id"] == SECONDARY["TEAMS_CLIENT_ID"]
|
|
assert seed["home_channel"]["chat_id"] == SECONDARY["TEAMS_HOME_CHANNEL"]
|
|
|
|
|
|
def test_dingtalk_client_id_follows_the_scoped_secret(secondary_scope):
|
|
from plugins.platforms.dingtalk.adapter import _credentials
|
|
|
|
assert _credentials(None) == (SECONDARY["DINGTALK_CLIENT_ID"], SECONDARY["DINGTALK_CLIENT_SECRET"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dingtalk_standalone_send_posts_to_scoped_robot_webhook(secondary_scope, monkeypatch):
|
|
import httpx
|
|
from plugins.platforms.dingtalk import adapter as ding
|
|
|
|
posted = []
|
|
|
|
class _Resp:
|
|
def raise_for_status(self): pass
|
|
def json(self): return {"errcode": 0}
|
|
|
|
class _Client:
|
|
def __init__(self, *a, **k): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
async def post(self, url, **kw): posted.append(url); return _Resp()
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", _Client)
|
|
result = await ding._standalone_send(PlatformConfig(enabled=True), "c", "hi")
|
|
assert result.get("success") is True
|
|
assert posted == [SECONDARY["DINGTALK_WEBHOOK_URL"]]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_telegram_connect_registers_scoped_webhook_url(secondary_scope, monkeypatch):
|
|
from plugins.platforms.telegram import adapter as tg
|
|
|
|
class _Builder:
|
|
def __getattr__(self, name):
|
|
return lambda *a, **k: self
|
|
def build(self):
|
|
app = MagicMock(); app.bot = MagicMock(); app.start = AsyncMock(); app.initialize = AsyncMock()
|
|
return app
|
|
|
|
monkeypatch.setattr(tg, "Application", type("_App", (), {"builder": staticmethod(_Builder)}))
|
|
adapter = tg.TelegramAdapter(PlatformConfig(enabled=True, token="bot2-token"))
|
|
for name in ("_wire_plugin_handlers", "_register_handlers", "_mark_connected", "_start_post_connect_housekeeping"):
|
|
monkeypatch.setattr(adapter, name, MagicMock())
|
|
monkeypatch.setattr(adapter, "_acquire_platform_lock", lambda *a, **k: True)
|
|
monkeypatch.setattr(adapter, "_build_ptb_requests", AsyncMock(return_value=(MagicMock(), MagicMock())))
|
|
monkeypatch.setattr(adapter, "_initialize_app_with_retries", AsyncMock())
|
|
seen = {}
|
|
|
|
async def _webhook(url, *, is_reconnect): seen["url"] = url
|
|
async def _polling(*, is_reconnect): seen["url"] = "<polling>"
|
|
|
|
monkeypatch.setattr(adapter, "_start_webhook_mode", _webhook)
|
|
monkeypatch.setattr(adapter, "_start_polling_mode", _polling)
|
|
assert await adapter.connect() is True
|
|
assert seen["url"] == SECONDARY["TELEGRAM_WEBHOOK_URL"]
|
|
assert os.environ["TELEGRAM_WEBHOOK_URL"] == DEFAULT_ENV["TELEGRAM_WEBHOOK_URL"] # env untouched; scope won
|