"""Router tests for browser-connectable IM channels.""" from __future__ import annotations from uuid import UUID from _router_auth_helpers import make_authed_test_app from fastapi.testclient import TestClient from app.channels.runtime_config_store import ChannelRuntimeConfigStore from app.gateway.auth.models import User from app.gateway.routers import channel_connections from deerflow.config.channel_connections_config import ChannelConnectionsConfig def _user() -> User: return User( id=UUID("11111111-2222-3333-4444-555555555555"), email="alice@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: app.state.channels_config = channels_config or {} if runtime_config_store is not None: app.state.channel_runtime_config_store = runtime_config_store app.include_router(channel_connections.router) return app 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"] == "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"] == "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": "********", } 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_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_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) 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) 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"] == "connected" assert app.state.channels_config["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_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"] == "connected" assert restarted_app.state.channels_config["slack"] == { "enabled": True, "bot_token": "xoxb-ui", "app_token": "xapp-ui", } 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 app.state.channels_config["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) 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") is None 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_revokes_current_user_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", ) 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_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_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)