deer-flow/backend/tests/test_channel_connections_router.py
Nan Gao 8d5f5fd0a8
feat(channels): share encrypted runtime channel config across Gateway instances (#6988)
* feat(channels): share encrypted runtime channel config across Gateway instances

IM bot credentials entered in the web UI lived in each host's plaintext
channels/runtime-config.json, and the router cached the merged config on
app.state for the life of the process. With several Gateway instances a
save or disconnect served by an instance that does not hold the
provider's leader lease never reached the transport holder: it kept the
old credentials until it next started the channel, and a disconnect
could leave the old bot connected.

- New shared table channel_runtime_configs (migration 0044): one row per
  provider with the config block encrypted under DEER_FLOW_CREDENTIALS_KEY,
  a plaintext disconnect tombstone, and a version bumped by every save.
- SqlChannelRuntimeConfigStore replaces the JSON file for sqlite/postgres
  (memory keeps the file); it is synchronous on app_sync_sqlalchemy_url
  because its callers already run in worker threads.
- The router re-reads the store on every request instead of caching the
  merged config, and refuses (503) to save credentials without a key.
- The leader-lease supervisor compares the versions on each renewal tick
  and restarts or stops the holder's transport when a provider's config
  changed; a takeover reads a disconnect instead of reviving cached
  credentials.
- The first start imports an existing runtime-config.json (rows already
  in the table win) and deletes the plaintext file.

* docs(changelog): reference #6988 for the shared runtime channel config

* ci: rerun checks after #6999 fixed the frontend guidance budget on main

* fix(channels): start a held transport that is down when its runtime config changes

When the lease holder's transport was down (a start that failed on the
old credentials), applying a peer-saved config only updated the cached
config, and the transport started on the next lease round: two renewal
ticks instead of the one the docs promise. The apply step now starts it
on the held lease right away, with the lease round's backoff bookkeeping.
2026-10-18 11:10:31 +08:00

1683 lines
63 KiB
Python

"""Router tests for browser-connectable IM channels."""
from __future__ import annotations
import asyncio
import threading
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import UUID
import pytest
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.channels.runtime_config_store import ChannelRuntimeConfigStore, merge_runtime_channel_configs
from app.gateway.auth.models import User
from app.gateway.routers import channel_connections
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@pytest.fixture(autouse=True)
def _stub_app_config(monkeypatch):
"""Keep router tests independent from a developer-local config.yaml."""
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "0")
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
monkeypatch.delenv("WEB_CONCURRENCY", raising=False)
monkeypatch.delenv("DEER_FLOW_MULTI_INSTANCE", raising=False)
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}))
yield
reset_app_config()
def _user() -> User:
return User(
id=UUID("11111111-2222-3333-4444-555555555555"),
email="alice@example.com",
password_hash="x",
system_role="admin",
)
def _non_admin_user() -> User:
return User(
id=UUID("99999999-8888-7777-6666-555555555555"),
email="bob@example.com",
password_hash="x",
system_role="user",
)
async def _make_repo(tmp_path):
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'router.db'}", sqlite_dir=str(tmp_path))
return ChannelConnectionRepository(get_session_factory())
def _make_app(
config: ChannelConnectionsConfig,
repo,
channels_config: dict | None = None,
*,
runtime_config_store: ChannelRuntimeConfigStore | None = None,
set_channels_config_state: bool = True,
):
app = make_authed_test_app(user_factory=_user)
app.state.channel_connections_config = config
app.state.channel_connection_repo = repo
if set_channels_config_state:
# The ``channels`` blocks of config.yaml; the router overlays the runtime store on every request.
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, "channels": channels_config or {}}))
if runtime_config_store is None:
runtime_config_dir = TemporaryDirectory()
app.state.channel_runtime_config_tmpdir = runtime_config_dir
runtime_config_store = ChannelRuntimeConfigStore(f"{runtime_config_dir.name}/runtime-config.json")
app.state.channel_runtime_config_store = runtime_config_store
app.include_router(channel_connections.router)
return app
def _channels(app) -> dict:
"""The channel configs the router serves now: config.yaml ``channels`` with the runtime store overlaid."""
from deerflow.config.app_config import get_app_config
result = dict((get_app_config().model_extra or {}).get("channels") or {})
merge_runtime_channel_configs(result, app.state.channel_connections_config, store=app.state.channel_runtime_config_store)
return result
def _enabled_connections_config() -> ChannelConnectionsConfig:
return ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"slack": {"enabled": True},
"discord": {"enabled": True},
"feishu": {"enabled": True},
"dingtalk": {"enabled": True},
"wechat": {"enabled": True},
"wecom": {"enabled": True},
}
)
def _channels_config() -> dict:
return {
"telegram": {"enabled": True, "bot_token": "telegram-token"},
"slack": {"enabled": True, "bot_token": "xoxb-operator", "app_token": "xapp-operator"},
"discord": {"enabled": True, "bot_token": "discord-bot"},
"feishu": {"enabled": True, "app_id": "feishu-app", "app_secret": "feishu-secret"},
"dingtalk": {"enabled": True, "client_id": "dingtalk-client", "client_secret": "dingtalk-secret"},
"wechat": {"enabled": True, "bot_token": "wechat-token"},
"wecom": {"enabled": True, "bot_id": "wecom-bot", "bot_secret": "wecom-secret"},
}
def test_get_providers_only_returns_enabled_channels_and_setup_fields(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
"discord": {"enabled": False},
}
)
app = _make_app(config, repo, {})
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
body = response.json()
assert body["enabled"] is True
assert [provider["provider"] for provider in body["providers"]] == ["slack"]
assert body["providers"][0]["configured"] is False
assert body["providers"][0]["connectable"] is False
assert body["providers"][0]["credential_fields"] == [
{
"name": "bot_token",
"label": "Bot token",
"type": "password",
"required": True,
},
{
"name": "app_token",
"label": "App token",
"type": "password",
"required": True,
},
]
anyio.run(repo.close)
def test_get_providers_uses_existing_channels_config(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
body = response.json()
assert body["enabled"] is True
by_provider = {item["provider"]: item for item in body["providers"]}
assert set(by_provider) == {"telegram", "slack", "discord", "feishu", "dingtalk", "wechat", "wecom"}
assert by_provider["telegram"]["configured"] is True
assert by_provider["telegram"]["auth_mode"] == "deep_link"
assert by_provider["telegram"]["credential_values"] == {
"bot_token": "********",
"bot_username": "deerflow_bot",
}
assert by_provider["slack"]["configured"] is True
assert by_provider["slack"]["auth_mode"] == "binding_code"
assert by_provider["slack"]["connection_status"] == "not_connected"
assert by_provider["slack"]["credential_values"] == {
"bot_token": "********",
"app_token": "********",
}
assert by_provider["discord"]["configured"] is True
assert by_provider["discord"]["auth_mode"] == "binding_code"
assert by_provider["discord"]["credential_values"] == {"bot_token": "********"}
assert by_provider["feishu"]["configured"] is True
assert by_provider["feishu"]["auth_mode"] == "binding_code"
assert by_provider["feishu"]["connection_status"] == "not_connected"
assert by_provider["feishu"]["credential_values"] == {
"app_id": "feishu-app",
"app_secret": "********",
}
assert by_provider["dingtalk"]["configured"] is True
assert by_provider["dingtalk"]["auth_mode"] == "binding_code"
assert by_provider["dingtalk"]["credential_values"] == {
"client_id": "dingtalk-client",
"client_secret": "********",
}
assert by_provider["wechat"]["configured"] is True
assert by_provider["wechat"]["auth_mode"] == "binding_code"
assert by_provider["wechat"]["credential_values"] == {"bot_token": "********"}
assert by_provider["wecom"]["configured"] is True
assert by_provider["wecom"]["auth_mode"] == "binding_code"
assert by_provider["wecom"]["credential_values"] == {
"bot_id": "wecom-bot",
"bot_secret": "********",
}
# Scheduled-task updates are pushed only where the provider implements
# proactive push; Settings shows that per provider card.
assert {name: item["proactive_notifications"] for name, item in by_provider.items()} == {
"telegram": False,
"slack": False,
"discord": False,
"feishu": True,
"dingtalk": False,
"wechat": True,
"wecom": True,
}
anyio.run(repo.close)
def test_get_providers_degrades_when_persistence_is_unavailable(monkeypatch):
monkeypatch.setattr(channel_connections, "get_session_factory", lambda: None)
app = _make_app(_enabled_connections_config(), None, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
by_provider = {item["provider"]: item for item in response.json()["providers"]}
assert by_provider["slack"]["configured"] is True
assert by_provider["slack"]["connectable"] is True
assert by_provider["slack"]["connection_status"] == "not_connected"
def test_get_providers_reports_connected_without_binding_in_auth_disabled_mode(tmp_path, monkeypatch):
import anyio
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.delenv("DEER_FLOW_ENV", raising=False)
monkeypatch.delenv("ENVIRONMENT", raising=False)
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
by_provider = {item["provider"]: item for item in response.json()["providers"]}
# Auth-disabled local mode routes channel messages to the default user, so
# a configured running channel is effectively connected without a binding.
assert by_provider["slack"]["connection_status"] == "connected"
assert by_provider["feishu"]["connection_status"] == "connected"
anyio.run(repo.close)
def test_get_providers_reports_unconfigured_when_runtime_channel_is_missing(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, {"telegram": {"enabled": True, "bot_token": "telegram-token"}})
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
by_provider = {item["provider"]: item for item in response.json()["providers"]}
assert by_provider["telegram"]["configured"] is True
assert by_provider["slack"]["configured"] is False
assert by_provider["slack"]["connectable"] is False
assert "Slack credentials" in by_provider["slack"]["unavailable_reason"]
assert by_provider["discord"]["configured"] is False
assert "Discord credentials" in by_provider["discord"]["unavailable_reason"]
assert by_provider["feishu"]["configured"] is False
assert "Feishu credentials" in by_provider["feishu"]["unavailable_reason"]
assert by_provider["dingtalk"]["configured"] is False
assert "DingTalk credentials" in by_provider["dingtalk"]["unavailable_reason"]
assert by_provider["wechat"]["configured"] is False
assert "WeChat credentials" in by_provider["wechat"]["unavailable_reason"]
assert by_provider["wecom"]["configured"] is False
assert "WeCom credentials" in by_provider["wecom"]["unavailable_reason"]
anyio.run(repo.close)
def test_get_providers_reports_configured_channel_not_running(tmp_path, monkeypatch):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
service = SimpleNamespace(
get_status=lambda: {
"service_running": True,
"channels": {
"feishu": {
"enabled": True,
"running": False,
}
},
}
)
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
by_provider = {item["provider"]: item for item in response.json()["providers"]}
assert by_provider["feishu"]["configured"] is True
assert by_provider["feishu"]["connectable"] is False
assert by_provider["feishu"]["connection_status"] == "not_connected"
assert "configured but is not running" in by_provider["feishu"]["unavailable_reason"]
anyio.run(repo.close)
def test_get_providers_provider_unavailable_overrides_stale_connected_row(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connection():
await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="slack",
external_account_id="U123",
status="connected",
)
anyio.run(seed_connection)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
app = _make_app(config, repo, {})
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
by_provider = {item["provider"]: item for item in response.json()["providers"]}
assert by_provider["slack"]["configured"] is False
assert by_provider["slack"]["connectable"] is False
assert by_provider["slack"]["connection_status"] == "not_connected"
assert "Slack credentials" in by_provider["slack"]["unavailable_reason"]
anyio.run(repo.close)
def test_get_providers_restarts_configured_channel_when_service_can_reconcile(tmp_path, monkeypatch):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"feishu": {"enabled": True},
}
)
channels_config = {
"feishu": {
"enabled": True,
"app_id": "feishu-app",
"app_secret": "feishu-secret",
}
}
app = _make_app(config, repo, channels_config)
status = {
"service_running": True,
"channels": {
"feishu": {
"enabled": True,
"running": False,
}
},
}
reconciled: list[tuple[str, dict]] = []
async def ensure_channel_ready(provider, runtime_config):
reconciled.append((provider, dict(runtime_config)))
status["channels"][provider]["running"] = True
return True
service = SimpleNamespace(
get_status=lambda: status,
ensure_channel_ready=ensure_channel_ready,
)
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
by_provider = {item["provider"]: item for item in response.json()["providers"]}
assert by_provider["feishu"]["configured"] is True
assert by_provider["feishu"]["connectable"] is True
assert by_provider["feishu"]["connection_status"] == "not_connected"
assert by_provider["feishu"]["unavailable_reason"] is None
assert reconciled == [("feishu", channels_config["feishu"])]
anyio.run(repo.close)
def test_get_providers_uses_newest_connection_status_per_provider(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connections():
await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="slack",
external_account_id="U-old",
workspace_id="T-old",
status="revoked",
)
await anyio.sleep(0.01)
await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="slack",
external_account_id="U-new",
workspace_id="T-new",
status="connected",
)
anyio.run(seed_connections)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
by_provider = {item["provider"]: item for item in response.json()["providers"]}
assert by_provider["slack"]["connection_status"] == "connected"
anyio.run(repo.close)
def test_get_connections_returns_current_user_connections_only(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connections():
await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="telegram",
external_account_id="42",
external_account_name="Alice",
status="connected",
)
await repo.upsert_connection(
owner_user_id="other-user",
provider="telegram",
external_account_id="99",
external_account_name="Bob",
status="connected",
)
anyio.run(seed_connections)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/connections")
assert response.status_code == 200
body = response.json()
assert len(body["connections"]) == 1
assert body["connections"][0]["provider"] == "telegram"
assert body["connections"][0]["external_account_id"] == "42"
anyio.run(repo.close)
def test_connect_telegram_returns_deep_link_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.post("/api/channels/telegram/connect")
assert response.status_code == 200
body = response.json()
assert body["provider"] == "telegram"
assert body["mode"] == "deep_link"
assert body["url"].startswith("https://t.me/deerflow_bot?start=")
assert body["code"]
assert "/start" in body["instruction"]
async def count_states():
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="telegram")
assert anyio.run(count_states) == 1
anyio.run(repo.close)
def test_connect_slack_returns_binding_command_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.post("/api/channels/slack/connect")
assert response.status_code == 200
body = response.json()
assert body["provider"] == "slack"
assert body["mode"] == "binding_code"
assert body["url"] is None
assert len(body["code"]) >= 22
assert body["instruction"] == f"Send /connect {body['code']} to the DeerFlow Slack bot."
async def count_states():
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="slack")
assert anyio.run(count_states) == 1
anyio.run(repo.close)
def test_connect_binding_code_caps_pending_states_per_provider(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
responses = [client.post("/api/channels/slack/connect") for _ in range(6)]
assert [response.status_code for response in responses[:5]] == [200, 200, 200, 200, 200]
assert responses[5].status_code == 429
assert "Too many pending channel connection codes" in responses[5].json()["detail"]
async def count_states():
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="slack")
assert anyio.run(count_states) == 5
anyio.run(repo.close)
def test_connect_discord_returns_binding_command_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.post("/api/channels/discord/connect")
assert response.status_code == 200
body = response.json()
assert body["provider"] == "discord"
assert body["mode"] == "binding_code"
assert body["url"] is None
assert body["code"]
assert body["instruction"] == f"Send /connect {body['code']} to the DeerFlow Discord bot."
async def count_states():
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="discord")
assert anyio.run(count_states) == 1
anyio.run(repo.close)
def test_connect_existing_binding_code_channels_return_command_and_persist_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
providers = ["feishu", "dingtalk", "wechat", "wecom"]
with TestClient(app) as client:
responses = {provider: client.post(f"/api/channels/{provider}/connect") for provider in providers}
for provider, response in responses.items():
expected_display_name = {
"feishu": "Feishu",
"dingtalk": "DingTalk",
"wechat": "WeChat",
"wecom": "WeCom",
}[provider]
assert response.status_code == 200
body = response.json()
assert body["provider"] == provider
assert body["mode"] == "binding_code"
assert body["url"] is None
assert len(body["code"]) >= 22
assert body["instruction"] == f"Send /connect {body['code']} to the DeerFlow {expected_display_name} bot."
async def count_states(provider=provider):
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider=provider)
assert anyio.run(count_states) == 1
anyio.run(repo.close)
@pytest.mark.parametrize("provider", ["qq", "wecom"])
def test_browser_group_names_survive_state_consumption_and_bind_two_groups(tmp_path, monkeypatch, provider):
import anyio
from app.channels.message_bus import MessageBus
from app.channels.qq import QQChannel
from app.channels.wecom import WeComChannel
monkeypatch.setattr(channel_connections, "_ensure_runtime_channel_ready_if_available", AsyncMock())
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate({"enabled": True, provider: {"enabled": True}})
runtime = {"qq": {"enabled": True, "app_id": "app-1", "client_secret": "offline"}, "wecom": {"enabled": True, "bot_id": "bot-1", "bot_secret": "offline"}}
app = _make_app(config, repo, runtime)
try:
with TestClient(app) as client:
responses = [client.post(f"/api/channels/{provider}/connect", json={"group_name": name}) for name in ("Release team", "Support team")]
assert all(response.status_code == 200 for response in responses), [response.text for response in responses]
async def bind_groups():
if provider == "qq":
channel = QQChannel(MessageBus(), {"app_id": "app-1", "connection_repo": repo})
channel.send = AsyncMock()
else:
channel = WeComChannel(MessageBus(), {"connection_repo": repo})
channel._send_connection_reply = AsyncMock()
for index, response in enumerate(responses):
code = response.json()["code"]
if provider == "qq":
await channel._handle_inbound({"t": "GROUP_AT_MESSAGE_CREATE", "d": {"id": f"message-{index}", "author": {"member_openid": "alice"}, "group_openid": f"group-{index}", "content": f"/connect {code}"}})
else:
await channel._bind_connection_from_connect_code(frame={"body": {"aibotid": "bot-1", "chattype": "group", "chatid": f"wr-group-{index}"}}, user_id="alice", code=code)
return await repo.list_connections(str(_user().id))
rows = anyio.run(bind_groups)
assert len(rows) == 2
assert len({row["id"] for row in rows}) == 2
assert len({row["workspace_id"] for row in rows}) == 2
assert {row["workspace_name"] for row in rows} == {"Release team", "Support team"}
finally:
anyio.run(repo.close)
def test_connect_unconfigured_runtime_channel_returns_400(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, {})
with TestClient(app) as client:
response = client.post("/api/channels/slack/connect")
assert response.status_code == 400
assert "Slack credentials" in response.json()["detail"]
anyio.run(repo.close)
@pytest.mark.parametrize("provider", ["enabled", "require_bound_identity", "provider_status", "unknown_provider"])
def test_connect_rejects_non_provider_config_attribute_with_404(tmp_path, provider):
import anyio
# A request-supplied provider name that collides with a real (non-provider)
# ChannelConnectionsConfig attribute -- e.g. the "enabled" /
# "require_bound_identity" bool fields, or the "provider_status" method --
# must resolve to the intended 404. Before the allowlist check, an
# unrestricted getattr returned that attribute instead of falling through to
# the 404, and the connect handler then dereferenced it as a provider config
# (AttributeError -> HTTP 500) for any authenticated user.
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app, raise_server_exceptions=False) as client:
response = client.post(f"/api/channels/{provider}/connect")
assert response.status_code == 404
assert response.json()["detail"] == "Unknown channel provider"
anyio.run(repo.close)
def test_configure_provider_runtime_credentials_enables_connect_without_file_edits(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
app = _make_app(config, repo, {})
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
connect_response = client.post("/api/channels/slack/connect")
assert configure_response.status_code == 200
configured = configure_response.json()
assert configured["provider"] == "slack"
assert configured["configured"] is True
assert configured["connectable"] is True
assert configured["connection_status"] == "not_connected"
assert _channels(app)["slack"] == {
"enabled": True,
"bot_token": "xoxb-ui",
"app_token": "xapp-ui",
}
assert connect_response.status_code == 200
assert connect_response.json()["provider"] == "slack"
anyio.run(repo.close)
def test_runtime_config_endpoints_require_admin(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
app = make_authed_test_app(user_factory=_non_admin_user)
app.state.channel_connections_config = config
app.state.channel_connection_repo = repo
runtime_config_dir = TemporaryDirectory()
app.state.channel_runtime_config_tmpdir = runtime_config_dir
app.state.channel_runtime_config_store = ChannelRuntimeConfigStore(f"{runtime_config_dir.name}/runtime-config.json")
app.include_router(channel_connections.router)
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
disconnect_response = client.delete("/api/channels/slack/runtime-config")
providers_response = client.get("/api/channels/providers")
assert configure_response.status_code == 403
assert "Admin privileges" in configure_response.json()["detail"]
assert disconnect_response.status_code == 403
# Read-only provider listing stays available to regular users.
assert providers_response.status_code == 200
anyio.run(repo.close)
def test_configure_provider_runtime_rolls_back_visible_state_when_start_fails(tmp_path, monkeypatch):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
existing_runtime_config = {
"enabled": True,
"bot_token": "xoxb-old",
"app_token": "xapp-old",
}
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
runtime_config_store.set_provider_config("slack", existing_runtime_config)
service = SimpleNamespace(configure_channel=AsyncMock(return_value=False))
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
app = _make_app(
config,
repo,
{"slack": dict(existing_runtime_config)},
runtime_config_store=runtime_config_store,
)
with TestClient(app) as client:
response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-new", "app_token": "xapp-new"}},
)
assert response.status_code == 400
assert "Failed to start Slack channel" in response.json()["detail"]
service.configure_channel.assert_awaited_once_with(
"slack",
{
"enabled": True,
"bot_token": "xoxb-new",
"app_token": "xapp-new",
},
)
assert _channels(app)["slack"] == existing_runtime_config
assert runtime_config_store.get_provider_config("slack") == existing_runtime_config
anyio.run(repo.close)
def test_configure_telegram_runtime_uses_new_bot_username_for_deep_link_without_mutating_config(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"telegram": {"enabled": True, "bot_username": "old_bot"},
}
)
app = _make_app(config, repo, {})
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/telegram/runtime-config",
json={"values": {"bot_token": "tg-token", "bot_username": "new_bot"}},
)
connect_response = client.post("/api/channels/telegram/connect")
assert configure_response.status_code == 200
assert configure_response.json()["credential_values"]["bot_username"] == "new_bot"
assert connect_response.status_code == 200
assert connect_response.json()["url"].startswith("https://t.me/new_bot?start=")
# The original config object cached by get_app_config() must stay untouched.
assert config.telegram.bot_username == "old_bot"
anyio.run(repo.close)
def test_configure_provider_runtime_credentials_survive_local_restart(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
runtime_config_path = tmp_path / "channels" / "runtime-config.json"
first_app = _make_app(
config,
repo,
{},
runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path),
)
with TestClient(first_app) as client:
configure_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
assert configure_response.status_code == 200
restarted_app = _make_app(
config,
repo,
runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path),
set_channels_config_state=False,
)
with TestClient(restarted_app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
by_provider = {item["provider"]: item for item in response.json()["providers"]}
assert by_provider["slack"]["configured"] is True
assert by_provider["slack"]["connectable"] is True
assert by_provider["slack"]["connection_status"] == "not_connected"
assert _channels(restarted_app)["slack"] == {
"enabled": True,
"bot_token": "xoxb-ui",
"app_token": "xapp-ui",
}
anyio.run(repo.close)
def test_runtime_config_saved_on_peer_workers_both_survive_restart(tmp_path):
"""Each Gateway worker keeps its own store; a save must not drop a peer's provider."""
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = _enabled_connections_config()
runtime_config_path = tmp_path / "channels" / "runtime-config.json"
# Both workers load the (empty) file before either one saves.
worker_a = _make_app(config, repo, {}, runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path))
worker_b = _make_app(config, repo, {}, runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path))
with TestClient(worker_a) as client:
telegram_response = client.post(
"/api/channels/telegram/runtime-config",
json={"values": {"bot_token": "tg-token", "bot_username": "ui_bot"}},
)
with TestClient(worker_b) as client:
slack_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
assert telegram_response.status_code == 200
assert slack_response.status_code == 200
assert ChannelRuntimeConfigStore(runtime_config_path).load_all() == {
"telegram": {"enabled": True, "bot_token": "tg-token", "bot_username": "ui_bot"},
"slack": {"enabled": True, "bot_token": "xoxb-ui", "app_token": "xapp-ui"},
}
anyio.run(repo.close)
def test_runtime_disconnect_on_one_worker_is_not_undone_by_a_peer_save(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = _enabled_connections_config()
runtime_config_path = tmp_path / "channels" / "runtime-config.json"
slack_config = {"enabled": True, "bot_token": "xoxb-ui", "app_token": "xapp-ui"}
ChannelRuntimeConfigStore(runtime_config_path).set_provider_config("slack", slack_config)
worker_a = _make_app(config, repo, {"slack": dict(slack_config)}, runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path))
worker_b = _make_app(config, repo, {"slack": dict(slack_config)}, runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path))
with TestClient(worker_a) as client:
disconnect_response = client.delete("/api/channels/slack/runtime-config")
with TestClient(worker_b) as client:
telegram_response = client.post(
"/api/channels/telegram/runtime-config",
json={"values": {"bot_token": "tg-token", "bot_username": "ui_bot"}},
)
assert disconnect_response.status_code == 200
assert telegram_response.status_code == 200
persisted = ChannelRuntimeConfigStore(runtime_config_path).load_all()
assert persisted["slack"] == {"enabled": False, "_runtime_disabled": True}
assert persisted["telegram"]["bot_token"] == "tg-token"
anyio.run(repo.close)
def test_configure_provider_runtime_credentials_preserves_masked_secrets(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"feishu": {"enabled": True},
}
)
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
app = _make_app(
config,
repo,
{
"feishu": {
"enabled": True,
"app_id": "old-app-id",
"app_secret": "old-secret",
}
},
runtime_config_store=runtime_config_store,
)
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/feishu/runtime-config",
json={
"values": {
"app_id": "new-app-id",
"app_secret": "********",
}
},
)
providers_response = client.get("/api/channels/providers")
assert configure_response.status_code == 200
assert _channels(app)["feishu"] == {
"enabled": True,
"app_id": "new-app-id",
"app_secret": "old-secret",
}
assert runtime_config_store.get_provider_config("feishu") == {
"enabled": True,
"app_id": "new-app-id",
"app_secret": "old-secret",
}
by_provider = {item["provider"]: item for item in providers_response.json()["providers"]}
assert by_provider["feishu"]["credential_values"] == {
"app_id": "new-app-id",
"app_secret": "********",
}
anyio.run(repo.close)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("runtime_config", "expected_persisted"),
[
({"enabled": True, "bot_token": "new-token"}, {"enabled": True, "bot_token": "new-token"}),
(None, {"enabled": False, "_runtime_disabled": True}),
],
)
async def test_runtime_config_commit_drains_persistence_across_cancellation(
monkeypatch,
runtime_config,
expected_persisted,
):
started = threading.Event()
release = threading.Event()
persisted: dict[str, dict] = {}
live_channels_config = {
"telegram": {"enabled": True, "bot_token": "sibling"},
"slack": {"enabled": True, "bot_token": "old-token"},
}
def set_provider_config(provider, value):
started.set()
assert release.wait(timeout=5)
persisted[provider] = dict(value)
def set_provider_disconnected(provider):
started.set()
assert release.wait(timeout=5)
persisted[provider] = {"enabled": False, "_runtime_disabled": True}
store = SimpleNamespace(
set_provider_config=set_provider_config,
set_provider_disconnected=set_provider_disconnected,
)
async def get_store(_request):
return store
async def get_channels_config(_request):
return live_channels_config
monkeypatch.setattr(channel_connections, "_get_runtime_config_store", get_store)
monkeypatch.setattr(channel_connections, "_get_channels_config", get_channels_config)
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
task = asyncio.create_task(channel_connections._commit_runtime_channel_config(request, "slack", runtime_config))
try:
assert await asyncio.to_thread(started.wait, 5)
task.cancel()
await asyncio.sleep(0)
task.cancel()
await asyncio.sleep(0)
assert not task.done()
release.set()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert persisted["slack"] == expected_persisted
def test_disconnect_provider_runtime_config_clears_connected_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
app = _make_app(config, repo, {}, runtime_config_store=runtime_config_store)
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
disconnect_response = client.delete("/api/channels/slack/runtime-config")
providers_response = client.get("/api/channels/providers")
assert configure_response.status_code == 200
assert disconnect_response.status_code == 200
disconnected = disconnect_response.json()
assert disconnected["provider"] == "slack"
assert disconnected["configured"] is False
assert disconnected["connectable"] is False
assert disconnected["connection_status"] == "not_connected"
assert runtime_config_store.get_provider_config("slack") == {
"enabled": False,
"_runtime_disabled": True,
}
assert providers_response.status_code == 200
by_provider = {item["provider"]: item for item in providers_response.json()["providers"]}
assert by_provider["slack"]["connection_status"] == "not_connected"
anyio.run(repo.close)
def test_disconnect_provider_runtime_config_suppresses_file_config_and_stops_channel(tmp_path, monkeypatch):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"feishu": {"enabled": True},
}
)
set_app_config(
AppConfig.model_validate(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"channels": {
"feishu": {
"enabled": True,
"app_id": "file-app-id",
"app_secret": "file-secret",
}
},
}
)
)
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
runtime_config_store.set_provider_config(
"feishu",
{
"enabled": True,
"app_id": "runtime-app-id",
"app_secret": "runtime-secret",
},
)
service = SimpleNamespace(
configure_channel=AsyncMock(return_value=True),
remove_channel=AsyncMock(return_value=True),
)
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
app = _make_app(
config,
repo,
{
"feishu": {
"enabled": True,
"app_id": "runtime-app-id",
"app_secret": "runtime-secret",
}
},
runtime_config_store=runtime_config_store,
)
with TestClient(app) as client:
disconnect_response = client.delete("/api/channels/feishu/runtime-config")
providers_response = client.get("/api/channels/providers")
assert disconnect_response.status_code == 200
disconnected = disconnect_response.json()
assert disconnected["provider"] == "feishu"
assert disconnected["configured"] is False
assert disconnected["connectable"] is False
assert disconnected["connection_status"] == "not_connected"
assert "feishu" not in _channels(app)
service.remove_channel.assert_awaited_once_with("feishu")
service.configure_channel.assert_not_awaited()
assert providers_response.status_code == 200
by_provider = {item["provider"]: item for item in providers_response.json()["providers"]}
assert by_provider["feishu"]["configured"] is False
assert by_provider["feishu"]["connection_status"] == "not_connected"
anyio.run(repo.close)
def test_disconnect_provider_runtime_config_revokes_all_provider_connections(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connection():
await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="slack",
external_account_id="U123",
status="connected",
)
await repo.upsert_connection(
owner_user_id="other-user",
provider="slack",
external_account_id="U456",
status="connected",
)
await repo.upsert_connection(
owner_user_id="other-user",
provider="telegram",
external_account_id="42",
status="connected",
)
anyio.run(seed_connection)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
app = _make_app(config, repo, {}, runtime_config_store=runtime_config_store)
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
disconnect_response = client.delete("/api/channels/slack/runtime-config")
assert configure_response.status_code == 200
assert disconnect_response.status_code == 200
async def get_connection_statuses():
return {
"admin_slack": (await repo.list_connections(str(_user().id)))[0]["status"],
"other": {item["provider"]: item["status"] for item in await repo.list_connections("other-user")},
}
statuses = anyio.run(get_connection_statuses)
assert statuses["admin_slack"] == "revoked"
assert statuses["other"]["slack"] == "revoked"
assert statuses["other"]["telegram"] == "connected"
anyio.run(repo.close)
def test_get_providers_preserves_revoked_status_when_provider_unavailable(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connection():
await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="slack",
external_account_id="U123",
status="revoked",
)
anyio.run(seed_connection)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
# No runtime channels_config -> the slack provider is unavailable.
app = _make_app(config, repo, {})
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
by_provider = {item["provider"]: item for item in response.json()["providers"]}
assert by_provider["slack"]["connectable"] is False
assert by_provider["slack"]["unavailable_reason"] is not None
# A revoked binding must stay distinguishable from a never-connected one,
# even when the runtime provider is currently unavailable.
assert by_provider["slack"]["connection_status"] == "revoked"
anyio.run(repo.close)
def test_configure_provider_runtime_does_not_clobber_concurrent_config_update(tmp_path, monkeypatch):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
}
)
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
app = _make_app(config, repo, {}, runtime_config_store=runtime_config_store)
async def configure_channel(provider, runtime_config):
# Simulate a concurrent admin request for a *different* provider whose
# save lands while this request awaits the worker restart.
await asyncio.to_thread(runtime_config_store.set_provider_config, "telegram", {"enabled": True, "bot_token": "tg-token"})
return True
service = SimpleNamespace(configure_channel=configure_channel)
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
with TestClient(app) as client:
response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
assert response.status_code == 200
# The concurrent telegram write must survive alongside the slack write.
assert _channels(app)["slack"]["bot_token"] == "xoxb-ui"
assert _channels(app)["telegram"]["bot_token"] == "tg-token"
anyio.run(repo.close)
def test_disconnect_provider_runtime_keeps_state_consistent_when_revoke_fails(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
app = _make_app(config, repo, {}, runtime_config_store=runtime_config_store)
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
assert configure_response.status_code == 200
repo.disconnect_provider_connections = AsyncMock(side_effect=RuntimeError("db down"))
with TestClient(app, raise_server_exceptions=False) as client:
disconnect_response = client.delete("/api/channels/slack/runtime-config")
assert disconnect_response.status_code == 500
# When the DB revoke fails, the store/cache must not be left diverged from
# the DB: the provider stays configured so a later re-configure cannot
# silently reactivate un-revoked connection rows.
assert _channels(app)["slack"]["bot_token"] == "xoxb-ui"
assert runtime_config_store.get_provider_config("slack") == {
"enabled": True,
"bot_token": "xoxb-ui",
"app_token": "xapp-ui",
}
anyio.run(repo.close)
def test_disconnect_connection_revokes_current_user_connection(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connection():
connection = await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="telegram",
external_account_id="42",
status="connected",
)
return connection["id"]
connection_id = anyio.run(seed_connection)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.delete(f"/api/channels/connections/{connection_id}")
assert response.status_code == 204
async def get_connection_status():
return (await repo.list_connections(str(_user().id)))[0]["status"]
assert anyio.run(get_connection_status) == "revoked"
anyio.run(repo.close)
def test_disconnect_connection_is_current_user_scoped(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connection():
connection = await repo.upsert_connection(
owner_user_id="other-user",
provider="telegram",
external_account_id="42",
status="connected",
)
return connection["id"]
connection_id = anyio.run(seed_connection)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.delete(f"/api/channels/connections/{connection_id}")
assert response.status_code == 404
async def get_connection_status():
return (await repo.list_connections("other-user"))[0]["status"]
assert anyio.run(get_connection_status) == "connected"
anyio.run(repo.close)
@pytest.mark.parametrize("worker_env", [{}, {"GATEWAY_WORKERS": "1"}, {"WEB_CONCURRENCY": "1"}, {"GATEWAY_WORKERS": ""}])
def test_wechat_qr_login_saves_credentials_without_exposing_token(monkeypatch, worker_env):
from app.channels.wechat_qr_login import WechatQRLogin
for name, value in worker_env.items():
monkeypatch.setenv(name, value)
app = _make_app(_enabled_connections_config(), None, {})
login = WechatQRLogin()
login.request = AsyncMock(side_effect=[{"qrcode": "poll-secret", "qrcode_img_content": "scan-url"}, {"status": "confirmed", "bot_token": "new-secret", "ilink_bot_id": "bot-1"}])
app.state.wechat_qr_login = login
restart = AsyncMock(return_value=True)
monkeypatch.setattr(channel_connections, "_restart_runtime_channel_if_available", restart)
with TestClient(app) as client:
started = client.post("/api/channels/wechat/qr-login")
assert started.status_code == 200
assert started.headers["cache-control"] == "no-store"
assert "poll-secret" not in started.text
url = f"/api/channels/wechat/qr-login/{started.json()['id']}"
confirmed = client.post(f"{url}/poll")
assert confirmed.status_code == 200
assert confirmed.json()["status"] == "confirmed"
assert confirmed.json()["provider"]["credential_values"]["bot_token"] == "********"
assert "new-secret" not in confirmed.text
assert _channels(app)["wechat"]["bot_token"] == "new-secret"
assert _channels(app)["wechat"]["ilink_bot_id"] == "bot-1"
saved = ChannelRuntimeConfigStore(app.state.channel_runtime_config_store._path).get_provider_config("wechat")
assert saved["bot_token"] == "new-secret"
assert saved["ilink_bot_id"] == "bot-1"
client.post(f"{url}/poll")
restart.assert_awaited_once()
assert client.delete(url).status_code == 204
assert client.post(f"{url}/poll").status_code == 404
def test_wechat_qr_login_requires_admin_before_any_provider_calls():
from app.channels.wechat_qr_login import WechatQRLogin
app = make_authed_test_app(user_factory=_non_admin_user)
login = WechatQRLogin()
login.request = AsyncMock()
app.state.wechat_qr_login = login
app.include_router(channel_connections.router)
with TestClient(app) as client:
assert client.post("/api/channels/wechat/qr-login").status_code == 403
assert client.post("/api/channels/wechat/qr-login/id/poll").status_code == 403
assert client.delete("/api/channels/wechat/qr-login/id").status_code == 403
login.request.assert_not_awaited()
def test_wechat_qr_login_respects_disabled_provider():
app = _make_app(ChannelConnectionsConfig.model_validate({"enabled": True, "wechat": {"enabled": False}}), None)
with TestClient(app) as client:
assert client.post("/api/channels/wechat/qr-login").status_code == 400
@pytest.mark.parametrize("method,path", [("POST", "/api/channels/wechat/qr-login"), ("POST", "/api/channels/wechat/qr-login/session/poll"), ("DELETE", "/api/channels/wechat/qr-login/session")])
def test_wechat_qr_routes_reject_cookie_requests_without_csrf(method, path):
from app.gateway.csrf_middleware import CSRFMiddleware
app = _make_app(_enabled_connections_config(), None)
app.add_middleware(CSRFMiddleware)
with TestClient(app) as client:
response = client.request(method, path, headers={"Cookie": "session=browser-session"})
assert response.status_code == 403
def test_manual_wechat_setup_invalidates_pending_qr(monkeypatch):
from app.channels.wechat_qr_login import WechatQRLogin
app = _make_app(_enabled_connections_config(), None)
login = WechatQRLogin()
login.request = AsyncMock(return_value={"qrcode": "id", "qrcode_img_content": "scan"})
app.state.wechat_qr_login = login
monkeypatch.setattr(channel_connections, "_restart_runtime_channel_if_available", AsyncMock(return_value=True))
with TestClient(app) as client:
session = client.post("/api/channels/wechat/qr-login").json()
assert client.post("/api/channels/wechat/runtime-config", json={"values": {"bot_token": "manual-token"}}).status_code == 200
assert client.post(f"/api/channels/wechat/qr-login/{session['id']}/poll").status_code == 404
assert _channels(app)["wechat"]["bot_token"] == "manual-token"
def test_wechat_pairing_code_is_validated_and_forwarded_without_echo(monkeypatch):
from app.channels.wechat_qr_login import WechatQRLogin
app = _make_app(_enabled_connections_config(), None, {})
login = WechatQRLogin()
login.request = AsyncMock(
side_effect=[
{"qrcode": "private-id", "qrcode_img_content": "scan-url"},
{"status": "need_verifycode"},
{"status": "confirmed", "bot_token": "new-secret", "baseurl": "https://ilinkai2.weixin.qq.com/"},
]
)
app.state.wechat_qr_login = login
monkeypatch.setattr(channel_connections, "_restart_runtime_channel_if_available", AsyncMock(return_value=True))
with TestClient(app) as client:
session = client.post("/api/channels/wechat/qr-login").json()
poll_url = f"/api/channels/wechat/qr-login/{session['id']}/poll"
assert client.post(poll_url).json()["status"] == "verification_required"
assert client.post(poll_url, json={"verify_code": "not-a-code"}).status_code == 422
result = client.post(poll_url, json={"verify_code": "123456"})
assert result.status_code == 200
assert result.json()["status"] == "confirmed"
assert "123456" not in result.text
assert "new-secret" not in result.text
assert login.request.call_args.kwargs["verify_code"] == "123456"
saved = ChannelRuntimeConfigStore(app.state.channel_runtime_config_store._path).get_provider_config("wechat")
assert saved["base_url"] == "https://ilinkai2.weixin.qq.com"
@pytest.mark.parametrize(
"returned_fields,expected_id",
[({}, "existing-bot"), ({"ilink_bot_id": None}, "existing-bot"), ({"ilink_bot_id": ""}, "existing-bot"), ({"ilink_bot_id": " "}, "existing-bot"), ({"ilink_bot_id": " replacement-bot "}, "replacement-bot")],
)
def test_wechat_qr_confirmation_preserves_bot_id_unless_replaced(monkeypatch, returned_fields, expected_id):
from app.channels.wechat_qr_login import WechatQRLogin
existing = {"enabled": True, "bot_token": "old-token", "ilink_bot_id": "existing-bot"}
app = _make_app(_enabled_connections_config(), None, {"wechat": existing})
app.state.channel_runtime_config_store.set_provider_config("wechat", existing)
login = WechatQRLogin()
login.request = AsyncMock(side_effect=[{"qrcode": "private-id", "qrcode_img_content": "scan-url"}, {"status": "confirmed", "bot_token": "new-token", **returned_fields}])
app.state.wechat_qr_login = login
restart = AsyncMock(return_value=True)
monkeypatch.setattr(channel_connections, "_restart_runtime_channel_if_available", restart)
with TestClient(app) as client:
started = client.post("/api/channels/wechat/qr-login")
assert started.status_code == 200
result = client.post(f"/api/channels/wechat/qr-login/{started.json()['id']}/poll")
assert result.status_code == 200
assert result.json()["status"] == "confirmed"
expected = {**existing, "bot_token": "new-token", "ilink_bot_id": expected_id}
restart.assert_awaited_once_with("wechat", expected)
assert _channels(app)["wechat"] == expected
saved = ChannelRuntimeConfigStore(app.state.channel_runtime_config_store._path).get_provider_config("wechat")
assert saved == expected
@pytest.mark.parametrize(
"worker_env",
[
{"GATEWAY_WORKERS": "2"},
{"GATEWAY_WORKERS": "4"},
{"WEB_CONCURRENCY": "2"},
{"GATEWAY_WORKERS": "invalid"},
# A blank GATEWAY_WORKERS is unset, so WEB_CONCURRENCY still decides.
{"GATEWAY_WORKERS": "", "WEB_CONCURRENCY": "2"},
# Kubernetes replicas run one worker each and declare their peers instead.
{"GATEWAY_WORKERS": "1", "DEER_FLOW_MULTI_INSTANCE": "1"},
],
)
@pytest.mark.parametrize("method,suffix", [("POST", ""), ("POST", "/session/poll"), ("DELETE", "/session")])
def test_wechat_qr_routes_reject_unsupported_workers_before_session_access(monkeypatch, worker_env, method, suffix):
from app.channels.wechat_qr_login import WechatQRLogin
for name, value in worker_env.items():
monkeypatch.setenv(name, value)
app = _make_app(_enabled_connections_config(), None)
login = WechatQRLogin()
session = {"id": "session", "status": "pending", "qrcode_content": "scan-url", "expires_in": 180}
login.start = AsyncMock(return_value=session)
login.poll = AsyncMock(return_value=session)
login.cancel = AsyncMock(return_value=None)
app.state.wechat_qr_login = login
with TestClient(app) as client:
result = client.request(method, f"/api/channels/wechat/qr-login{suffix}")
assert result.status_code == 503
assert "single Gateway worker" in result.json()["detail"]
assert "GATEWAY_WORKERS=1" in result.json()["detail"]
login.start.assert_not_awaited()
login.poll.assert_not_awaited()
login.cancel.assert_not_awaited()
def test_manual_wechat_setup_remains_available_with_multiple_workers(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
app = _make_app(_enabled_connections_config(), None)
restart = AsyncMock(return_value=True)
monkeypatch.setattr(channel_connections, "_restart_runtime_channel_if_available", restart)
with TestClient(app) as client:
result = client.post("/api/channels/wechat/runtime-config", json={"values": {"bot_token": "manual-token"}})
assert result.status_code == 200
assert _channels(app)["wechat"]["bot_token"] == "manual-token"
restart.assert_awaited_once()
def test_qq_provider_exposes_binding_flow_and_masks_secret(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate({"enabled": True, "qq": {"enabled": True}})
app = _make_app(
config,
repo,
{
"qq": {
"enabled": True,
"app_id": "fixture-app",
"client_secret": "fixture-private-secret",
}
},
)
try:
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
providers = response.json()["providers"]
assert len(providers) == 1
provider = providers[0]
assert provider["provider"] == "qq"
assert provider["display_name"] == "QQ"
assert provider["auth_mode"] == "binding_code"
assert provider["configured"] is True
assert provider["connectable"] is True
assert provider["proactive_notifications"] is True
assert "fixture-private-secret" not in response.text
fields = {field["name"]: field for field in provider["credential_fields"]}
assert fields["app_id"]["type"] == "text"
assert fields["client_secret"]["type"] == "password"
connected = client.post("/api/channels/qq/connect")
assert connected.status_code == 200
body = connected.json()
assert body["mode"] == "binding_code"
assert body["url"] is None
assert body["instruction"] == f"Send /connect {body['code']} to the DeerFlow QQ bot."
async def consume():
return await repo.consume_oauth_state(provider="qq", state=body["code"])
state = anyio.run(consume)
assert state["owner_user_id"] == str(_user().id)
assert anyio.run(consume) is None
finally:
anyio.run(repo.close)
def test_qq_runtime_credentials_can_be_configured_from_browser(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate({"enabled": True, "qq": {"enabled": True}})
app = _make_app(config, repo, {})
try:
with TestClient(app) as client:
response = client.post(
"/api/channels/qq/runtime-config",
json={
"values": {
"app_id": "fixture-app",
"client_secret": "fixture-private-secret",
}
},
)
assert response.status_code == 200
assert response.json()["configured"] is True
assert "fixture-private-secret" not in response.text
assert _channels(app)["qq"]["client_secret"] == "fixture-private-secret"
assert client.post("/api/channels/qq/connect").status_code == 200
finally:
anyio.run(repo.close)