Align IM connections with local channels

This commit is contained in:
taohe
2026-06-10 22:16:47 +08:00
parent 92c185b90d
commit d06643d8a2
33 changed files with 588 additions and 1536 deletions
+2 -4
View File
@@ -22,10 +22,6 @@ from app.gateway.csrf_middleware import CSRFMiddleware
"/api/v1/auth/register",
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
"/api/channels/slack/callback",
"/api/channels/discord/callback",
"/api/channels/webhooks/slack/events",
"/api/channels/webhooks/telegram",
],
)
def test_public_paths(path: str):
@@ -43,6 +39,8 @@ def test_public_paths(path: str):
"/api/threads/123/uploads",
"/api/agents",
"/api/channels",
"/api/channels/providers",
"/api/channels/slack/connect",
"/api/runs/stream",
"/api/threads/123/runs",
"/api/v1/auth/me",
@@ -1,8 +1,5 @@
"""Tests for user-facing IM channel connection configuration."""
import pytest
from pydantic import ValidationError
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@@ -10,67 +7,34 @@ def test_channel_connections_disabled_by_default():
config = ChannelConnectionsConfig()
assert config.enabled is False
assert config.public_base_url == ""
assert config.encryption_key == ""
assert config.slack.enabled is False
assert config.telegram.enabled is False
assert config.discord.enabled is False
def test_enabled_channel_connections_can_run_in_local_mode_without_public_url_or_encryption_key():
def test_enabled_channel_connections_do_not_require_public_url_or_encryption_key():
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
},
"slack": {"enabled": True},
"discord": {"enabled": True},
}
)
assert config.public_base_url == ""
assert config.encryption_key == ""
assert config.enabled is True
assert config.provider_status("telegram") == {"enabled": True, "configured": True}
def test_public_mode_requires_public_url():
with pytest.raises(ValidationError) as excinfo:
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "public",
"encryption_key": "test-secret",
}
)
assert "public_base_url is required when channel_connections.mode is public" in str(excinfo.value)
def test_provider_config_completeness_is_reported_without_crashing():
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "test-secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "slack-signing",
},
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
"webhook_secret": "telegram-webhook",
},
"discord": {"enabled": True, "client_id": "discord-client"},
}
)
assert config.provider_status("slack") == {"enabled": True, "configured": True}
assert config.provider_status("telegram") == {"enabled": True, "configured": True}
assert config.provider_status("discord") == {"enabled": True, "configured": False}
assert config.provider_status("discord") == {"enabled": True, "configured": True}
def test_provider_status_reports_disabled_and_unknown_providers():
config = ChannelConnectionsConfig.model_validate({"enabled": True})
assert config.provider_status("slack") == {"enabled": False, "configured": False}
assert config.provider_status("telegram") == {"enabled": False, "configured": False}
assert config.provider_status("discord") == {"enabled": False, "configured": False}
assert config.provider_status("unknown") == {"enabled": False, "configured": False}
@@ -12,7 +12,6 @@ from deerflow.persistence.channel_connections import (
ChannelConnectionRow,
ChannelCredentialCipher,
ChannelCredentialRow,
ChannelWebhookDeliveryRow,
)
@@ -201,25 +200,3 @@ class TestChannelConnectionRepository:
assert disconnected is False
assert (await repo.list_connections("alice"))[0]["status"] == "connected"
@pytest.mark.anyio
async def test_record_webhook_delivery_returns_false_for_duplicate_delivery_id(self, repo):
first = await repo.record_webhook_delivery(
provider="slack",
delivery_id="Ev123",
payload_sha256="abc",
event_type="app_mention",
)
second = await repo.record_webhook_delivery(
provider="slack",
delivery_id="Ev123",
payload_sha256="abc",
event_type="app_mention",
)
assert first is True
assert second is False
async with repo.session_factory() as session:
rows = (await session.execute(select(ChannelWebhookDeliveryRow))).scalars().all()
assert len(rows) == 1
assert rows[0].event_type == "app_mention"
+87 -415
View File
@@ -2,15 +2,11 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from urllib.parse import parse_qs, urlparse
from uuid import UUID
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.channels.providers.discord_connect import DiscordIdentity
from app.channels.providers.slack_connect import SlackInstall
from app.gateway.auth.models import User
from app.gateway.routers import channel_connections
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@@ -25,42 +21,47 @@ def _user() -> User:
)
async def _make_repo(tmp_path, encryption_key: str | None = "router-secret"):
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
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))
cipher = ChannelCredentialCipher.from_key(encryption_key) if encryption_key else None
return ChannelConnectionRepository(get_session_factory(), cipher=cipher)
return ChannelConnectionRepository(get_session_factory())
def _make_app(config: ChannelConnectionsConfig, repo):
def _make_app(config: ChannelConnectionsConfig, repo, channels_config: dict | None = None):
app = make_authed_test_app(user_factory=_user)
app.state.channel_connections_config = config
app.state.channel_connection_repo = repo
app.state.channels_config = channels_config or {}
app.include_router(channel_connections.router)
return app
def test_get_providers_returns_catalog_and_current_status(tmp_path):
def _enabled_connections_config() -> ChannelConnectionsConfig:
return ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"slack": {"enabled": True},
"discord": {"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"},
}
def test_get_providers_uses_existing_channels_config(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
"webhook_secret": "telegram-secret",
},
"slack": {"enabled": True, "client_id": "slack-client"},
}
)
app = _make_app(config, repo)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/providers")
@@ -68,13 +69,34 @@ def test_get_providers_returns_catalog_and_current_status(tmp_path):
assert response.status_code == 200
body = response.json()
assert body["enabled"] is True
telegram = next(item for item in body["providers"] if item["provider"] == "telegram")
slack = next(item for item in body["providers"] if item["provider"] == "slack")
assert telegram["enabled"] is True
assert telegram["configured"] is True
assert telegram["connection_status"] == "not_connected"
assert slack["enabled"] is True
assert slack["configured"] is False
by_provider = {item["provider"]: item for item in body["providers"]}
assert by_provider["telegram"]["configured"] is True
assert by_provider["telegram"]["auth_mode"] == "deep_link"
assert by_provider["slack"]["configured"] is True
assert by_provider["slack"]["auth_mode"] == "binding_code"
assert by_provider["discord"]["configured"] is True
assert by_provider["discord"]["auth_mode"] == "binding_code"
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 "channels.slack" in by_provider["slack"]["unavailable_reason"]
assert by_provider["discord"]["configured"] is False
assert "channels.discord" in by_provider["discord"]["unavailable_reason"]
anyio.run(repo.close)
@@ -101,16 +123,7 @@ def test_get_connections_returns_current_user_connections_only(tmp_path):
)
anyio.run(seed_connections)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
}
),
repo,
)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/connections")
@@ -128,22 +141,7 @@ def test_connect_telegram_returns_deep_link_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
"webhook_secret": "telegram-secret",
},
}
),
repo,
)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.post("/api/channels/telegram/connect")
@@ -153,6 +151,8 @@ def test_connect_telegram_returns_deep_link_and_persists_state(tmp_path):
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")
@@ -162,375 +162,67 @@ def test_connect_telegram_returns_deep_link_and_persists_state(tmp_path):
anyio.run(repo.close)
def test_connect_telegram_local_mode_without_public_url_or_encryption_key(tmp_path):
def test_connect_slack_returns_binding_command_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path, None)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
},
}
),
repo,
)
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")
response = client.post("/api/channels/slack/connect")
assert response.status_code == 200
body = response.json()
assert body["provider"] == "telegram"
assert body["url"].startswith("https://t.me/deerflow_bot?start=")
assert body["provider"] == "slack"
assert body["mode"] == "binding_code"
assert body["url"] is None
assert body["code"]
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="telegram")
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_get_providers_reports_slack_http_unavailable_without_public_url(tmp_path):
def test_connect_discord_returns_binding_command_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"encryption_key": "router-secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "slack-signing-secret",
"event_delivery": "http",
},
}
)
app = _make_app(config, repo)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/providers")
response = client.post("/api/channels/discord/connect")
assert response.status_code == 200
slack = next(item for item in response.json()["providers"] if item["provider"] == "slack")
assert slack["enabled"] is True
assert slack["configured"] is True
assert slack["connectable"] is False
assert "public_base_url" in slack["unavailable_reason"]
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_unconfigured_provider_returns_400(tmp_path):
def test_connect_unconfigured_runtime_channel_returns_400(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"slack": {"enabled": True, "client_id": "slack-client"},
}
),
repo,
)
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 response.json()["detail"] == "Channel provider is not configured"
anyio.run(repo.close)
def test_connect_slack_http_without_public_url_returns_400(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"encryption_key": "router-secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "slack-signing-secret",
"event_delivery": "http",
},
}
),
repo,
)
with TestClient(app) as client:
response = client.post("/api/channels/slack/connect")
assert response.status_code == 400
assert "public_base_url" in response.json()["detail"]
anyio.run(repo.close)
def test_connect_discord_uses_request_base_url_without_public_base_url(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"encryption_key": "router-secret",
"discord": {
"enabled": True,
"client_id": "discord-client",
"client_secret": "discord-secret",
"bot_token": "discord-bot",
"permissions": "274877975552",
},
}
),
repo,
)
with TestClient(app, base_url="http://localhost:2026") as client:
response = client.post("/api/channels/discord/connect")
assert response.status_code == 200
parsed = urlparse(response.json()["url"])
query = parse_qs(parsed.query)
assert query["redirect_uri"] == ["http://localhost:2026/api/channels/discord/callback"]
anyio.run(repo.close)
def test_connect_discord_without_encryption_key_returns_400(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path, None)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"discord": {
"enabled": True,
"client_id": "discord-client",
"client_secret": "discord-secret",
"bot_token": "discord-bot",
},
}
),
repo,
)
with TestClient(app) as client:
response = client.post("/api/channels/discord/connect")
assert response.status_code == 400
assert "encryption_key" in response.json()["detail"]
anyio.run(repo.close)
def test_connect_discord_includes_bot_install_scope_and_permissions(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"discord": {
"enabled": True,
"client_id": "discord-client",
"client_secret": "discord-secret",
"bot_token": "discord-bot",
"permissions": "274877975552",
},
}
),
repo,
)
with TestClient(app) as client:
response = client.post("/api/channels/discord/connect")
assert response.status_code == 200
url = response.json()["url"]
parsed = urlparse(url)
query = parse_qs(parsed.query)
scopes = set(query["scope"][0].split())
assert {"identify", "guilds", "bot", "applications.commands"}.issubset(scopes)
assert query["permissions"] == ["274877975552"]
anyio.run(repo.close)
def test_slack_callback_exchanges_code_and_stores_connection(tmp_path, monkeypatch):
import anyio
from app.channels.providers import slack_connect
repo = anyio.run(_make_repo, tmp_path)
state_token = "slack-state-token"
async def seed_state():
await repo.create_oauth_state(
owner_user_id=str(_user().id),
provider="slack",
state=state_token,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
requested_scopes=["chat:write"],
)
async def fake_exchange_slack_oauth_code(**kwargs):
assert kwargs["code"] == "slack-code"
assert kwargs["redirect_uri"] == "https://deerflow.example.com/api/channels/slack/callback"
return SlackInstall(
team_id="T123",
team_name="Deer Team",
authed_user_id="U123",
bot_user_id="B123",
bot_access_token="xoxb-secret",
scopes=["chat:write"],
raw={"ok": True},
)
anyio.run(seed_state)
monkeypatch.setattr(slack_connect, "exchange_slack_oauth_code", fake_exchange_slack_oauth_code)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "slack-signing-secret",
},
}
),
repo,
)
with TestClient(app) as client:
response = client.get(
f"/api/channels/slack/callback?code=slack-code&state={state_token}",
follow_redirects=False,
)
assert response.status_code in {302, 307}
assert response.headers["location"] == "/workspace?channel_connected=slack"
async def get_connection_and_credentials():
connections = await repo.list_connections(str(_user().id))
credentials = await repo.get_credentials(connections[0]["id"])
return connections[0], credentials
connection, credentials = anyio.run(get_connection_and_credentials)
assert connection["provider"] == "slack"
assert connection["external_account_id"] == "U123"
assert connection["workspace_id"] == "T123"
assert connection["bot_user_id"] == "B123"
assert connection["scopes"] == ["chat:write"]
assert credentials["access_token"] == "xoxb-secret"
anyio.run(repo.close)
def test_discord_callback_exchanges_code_and_stores_identity(tmp_path, monkeypatch):
import anyio
from app.channels.providers import discord_connect
repo = anyio.run(_make_repo, tmp_path)
state_token = "discord-state-token"
async def seed_state():
await repo.create_oauth_state(
owner_user_id=str(_user().id),
provider="discord",
state=state_token,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
requested_scopes=["identify", "guilds"],
)
async def fake_complete_discord_oauth(**kwargs):
assert kwargs["code"] == "discord-code"
assert kwargs["redirect_uri"] == "https://deerflow.example.com/api/channels/discord/callback"
return DiscordIdentity(
user_id="987",
display_name="Alice",
username="alice",
guilds=[{"id": "G1", "name": "Guild One"}],
access_token="discord-access-token",
refresh_token="discord-refresh-token",
token_type="Bearer",
scopes=["identify", "guilds"],
expires_at=datetime.now(UTC) + timedelta(hours=1),
raw_token={"scope": "identify guilds"},
)
anyio.run(seed_state)
monkeypatch.setattr(discord_connect, "complete_discord_oauth", fake_complete_discord_oauth)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"discord": {
"enabled": True,
"client_id": "discord-client",
"client_secret": "discord-secret",
"bot_token": "discord-bot",
},
}
),
repo,
)
with TestClient(app) as client:
response = client.get(
f"/api/channels/discord/callback?code=discord-code&state={state_token}",
follow_redirects=False,
)
assert response.status_code in {302, 307}
assert response.headers["location"] == "/workspace?channel_connected=discord"
async def get_connection_and_credentials():
connections = await repo.list_connections(str(_user().id))
credentials = await repo.get_credentials(connections[0]["id"])
return connections[0], credentials
connection, credentials = anyio.run(get_connection_and_credentials)
assert connection["provider"] == "discord"
assert connection["external_account_id"] == "987"
assert connection["external_account_name"] == "Alice"
assert connection["metadata"]["guilds"] == [{"id": "G1", "name": "Guild One"}]
assert credentials["access_token"] == "discord-access-token"
assert credentials["refresh_token"] == "discord-refresh-token"
assert "channels.slack" in response.json()["detail"]
anyio.run(repo.close)
@@ -547,20 +239,10 @@ def test_disconnect_connection_revokes_current_user_connection(tmp_path):
external_account_id="42",
status="connected",
)
await repo.store_credentials(connection["id"], access_token="secret-token")
return connection["id"]
connection_id = anyio.run(seed_connection)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
}
),
repo,
)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.delete(f"/api/channels/connections/{connection_id}")
@@ -571,7 +253,6 @@ def test_disconnect_connection_revokes_current_user_connection(tmp_path):
return (await repo.list_connections(str(_user().id)))[0]["status"]
assert anyio.run(get_connection_status) == "revoked"
assert anyio.run(repo.get_credentials, connection_id) is None
anyio.run(repo.close)
@@ -591,16 +272,7 @@ def test_disconnect_connection_is_current_user_scoped(tmp_path):
return connection["id"]
connection_id = anyio.run(seed_connection)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
}
),
repo,
)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.delete(f"/api/channels/connections/{connection_id}")
+31 -62
View File
@@ -3276,7 +3276,7 @@ class TestChannelService:
assert service._config == {"telegram": {"enabled": False}}
def test_from_app_config_merges_telegram_channel_connections_config(self):
def test_from_app_config_does_not_create_runtime_channels_from_channel_connections(self):
from app.channels.service import ChannelService
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@@ -3285,74 +3285,43 @@ class TestChannelService:
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "secret",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
"webhook_secret": "webhook-secret",
},
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"slack": {"enabled": True},
"discord": {"enabled": True},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config == {}
def test_from_app_config_preserves_existing_runtime_channels_with_channel_connections_enabled(self):
from app.channels.service import ChannelService
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
app_config = SimpleNamespace(
model_extra={
"channels": {
"telegram": {"enabled": True, "bot_token": "telegram-token"},
"slack": {"enabled": True, "bot_token": "xoxb", "app_token": "xapp"},
"discord": {"enabled": True, "bot_token": "discord-bot-token"},
}
},
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"slack": {"enabled": True},
"discord": {"enabled": True},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config["telegram"]["enabled"] is True
assert service._config["telegram"]["bot_token"] == "telegram-token"
def test_from_app_config_merges_slack_http_channel_connections_config(self):
from app.channels.service import ChannelService
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
app_config = SimpleNamespace(
model_extra={},
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "signing-secret",
"event_delivery": "http",
},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config["slack"]["enabled"] is True
assert service._config["slack"]["event_delivery"] == "http"
def test_from_app_config_merges_discord_channel_connections_config(self):
from app.channels.service import ChannelService
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
app_config = SimpleNamespace(
model_extra={},
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "secret",
"discord": {
"enabled": True,
"client_id": "discord-client",
"client_secret": "discord-secret",
"bot_token": "discord-bot-token",
},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config["discord"]["enabled"] is True
assert service._config["slack"]["app_token"] == "xapp"
assert service._config["discord"]["bot_token"] == "discord-bot-token"
def test_connection_repo_is_forwarded_to_manager(self):
+5 -8
View File
@@ -22,10 +22,6 @@ def _make_app() -> FastAPI:
async def protected_mutation():
return {"ok": True}
@app.post("/api/channels/webhooks/slack/events")
async def slack_events_webhook():
return {"ok": True}
return app
@@ -239,12 +235,13 @@ def test_non_auth_mutation_rejects_mismatched_double_submit_token():
assert response.json()["detail"] == "CSRF token mismatch."
def test_channel_webhook_post_skips_double_submit_csrf():
def test_channel_posts_require_double_submit_csrf():
client = TestClient(_make_app(), base_url="https://deerflow.example")
response = client.post(
"/api/channels/webhooks/slack/events",
headers={"Origin": "https://slack.com"},
"/api/channels/slack/connect",
headers={"Origin": "https://deerflow.example"},
)
assert response.status_code == 200
assert response.status_code == 403
assert response.json()["detail"] == "CSRF token missing. Include X-CSRF-Token header."
@@ -2,6 +2,9 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.channels.discord import DiscordChannel
@@ -48,3 +51,38 @@ async def test_discord_inbound_attaches_owner_identity_from_user_level_connectio
assert attached.connection_id == connection["id"]
assert attached.owner_user_id == "alice"
assert attached.workspace_id is None
@pytest.mark.anyio
async def test_discord_connect_command_binds_gateway_identity(repo):
state = "discord-bind-code"
await repo.create_oauth_state(
owner_user_id="deerflow-user-1",
provider="discord",
state=state,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
channel = DiscordChannel(
bus=MessageBus(),
config={"bot_token": "discord-bot", "connection_repo": repo},
)
message = MagicMock()
message.author.id = 987
message.author.display_name = "Alice"
message.guild.id = 123
message.guild.name = "Deer Guild"
message.channel.id = 456
message.channel.send = AsyncMock()
handled = await channel._bind_connection_from_connect_code(message, state)
connections = await repo.list_connections("deerflow-user-1")
assert handled is True
assert len(connections) == 1
assert connections[0]["provider"] == "discord"
assert connections[0]["external_account_id"] == "987"
assert connections[0]["external_account_name"] == "Alice"
assert connections[0]["workspace_id"] == "123"
assert connections[0]["workspace_name"] == "Deer Guild"
assert connections[0]["metadata"]["channel_id"] == "456"
message.channel.send.assert_awaited_once()
+36 -130
View File
@@ -1,31 +1,11 @@
"""Slack OAuth Events tests for user-owned channel connections."""
"""Slack connection tests for user-owned channel bindings."""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.channels.message_bus import MessageBus, OutboundMessage
from app.channels.providers.slack_connect import verify_slack_signature
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):
@@ -39,121 +19,47 @@ async def _make_repo(tmp_path):
)
def _make_app(config: ChannelConnectionsConfig, repo, bus):
app = make_authed_test_app(user_factory=_user)
app.state.channel_connections_config = config
app.state.channel_connection_repo = repo
app.state.channel_message_bus = bus
app.include_router(channel_connections.router)
return app
def _slack_signature(signing_secret: str, timestamp: str, body: bytes) -> str:
base = f"v0:{timestamp}:".encode() + body
digest = hmac.new(signing_secret.encode("utf-8"), base, hashlib.sha256).hexdigest()
return f"v0={digest}"
def test_verify_slack_signature_accepts_valid_signature():
body = b'{"type":"event_callback"}'
timestamp = "1710000000"
signature = _slack_signature("secret", timestamp, body)
assert verify_slack_signature(
signing_secret="secret",
timestamp=timestamp,
body=body,
signature=signature,
now=1710000001,
)
def test_verify_slack_signature_rejects_stale_timestamp():
body = b'{"type":"event_callback"}'
timestamp = "1710000000"
signature = _slack_signature("secret", timestamp, body)
assert not verify_slack_signature(
signing_secret="secret",
timestamp=timestamp,
body=body,
signature=signature,
now=1710001000,
)
def test_slack_events_webhook_publishes_connection_scoped_inbound(tmp_path):
def test_slack_connect_command_binds_socket_mode_identity(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
from app.channels.slack import SlackChannel
async def seed_connection():
return await repo.upsert_connection(
owner_user_id=str(_user().id),
async def go():
repo = await _make_repo(tmp_path)
state = "slack-bind-code"
await repo.create_oauth_state(
owner_user_id="deerflow-user-1",
provider="slack",
external_account_id="U123",
workspace_id="T123",
workspace_name="Deer Team",
status="connected",
state=state,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
channel = SlackChannel(
bus=MessageBus(),
config={"bot_token": "xoxb-operator", "app_token": "xapp-operator", "connection_repo": repo},
)
channel._web_client = MagicMock()
handled = await channel._bind_connection_from_connect_code(
event={
"user": "U123",
"channel": "C123",
"ts": "1710000000.000100",
},
team_id="T123",
code=state,
)
connection = anyio.run(seed_connection)
bus = AsyncMock()
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "slack-secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "slack-signing-secret",
},
}
),
repo,
bus,
)
payload = {
"type": "event_callback",
"event_id": "Ev123",
"team_id": "T123",
"event": {
"type": "app_mention",
"user": "U123",
"channel": "C123",
"text": "hello deerflow",
"ts": "1710000000.000100",
},
}
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
headers = {
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": _slack_signature("slack-signing-secret", timestamp, body),
}
connections = await repo.list_connections("deerflow-user-1")
assert handled is True
assert len(connections) == 1
assert connections[0]["provider"] == "slack"
assert connections[0]["external_account_id"] == "U123"
assert connections[0]["workspace_id"] == "T123"
assert connections[0]["metadata"]["channel_id"] == "C123"
channel._web_client.chat_postMessage.assert_called_once()
await repo.close()
with TestClient(app) as client:
response = client.post("/api/channels/webhooks/slack/events", content=body, headers=headers)
duplicate = client.post("/api/channels/webhooks/slack/events", content=body, headers=headers)
assert response.status_code == 200
assert response.json() == {"ok": True, "processed": True}
assert duplicate.status_code == 200
assert duplicate.json() == {"ok": True, "duplicate": True, "processed": False}
bus.publish_inbound.assert_awaited_once()
inbound = bus.publish_inbound.call_args.args[0]
assert inbound.connection_id == connection["id"]
assert inbound.owner_user_id == str(_user().id)
assert inbound.workspace_id == "T123"
assert inbound.chat_id == "C123"
assert inbound.user_id == "U123"
assert inbound.text == "hello deerflow"
assert inbound.topic_id == "1710000000.000100"
anyio.run(repo.close)
anyio.run(go)
def test_slack_send_uses_connection_bot_token_when_connection_id_is_present():
@@ -7,13 +7,9 @@ from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.channels.message_bus import MessageBus
from app.channels.telegram import TelegramChannel
from app.gateway.routers import channel_connections
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@pytest.fixture
@@ -102,44 +98,3 @@ async def test_bound_telegram_message_publishes_connection_identity(repo):
assert inbound.user_id == "42"
assert inbound.chat_id == "100"
assert inbound.text == "hello"
@pytest.mark.anyio
async def test_telegram_webhook_verifies_secret_and_deduplicates_updates(repo):
channel = MagicMock()
channel.process_webhook_update = AsyncMock(return_value=True)
app = FastAPI()
app.state.channel_connections_config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "telegram-secret",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
"webhook_secret": "webhook-secret",
},
}
)
app.state.channel_connection_repo = repo
app.state.channel_instances = {"telegram": channel}
app.include_router(channel_connections.router)
with TestClient(app) as client:
response = client.post(
"/api/channels/webhooks/telegram",
json={"update_id": 123, "message": {"text": "hello"}},
headers={"X-Telegram-Bot-Api-Secret-Token": "webhook-secret"},
)
duplicate = client.post(
"/api/channels/webhooks/telegram",
json={"update_id": 123, "message": {"text": "hello"}},
headers={"X-Telegram-Bot-Api-Secret-Token": "webhook-secret"},
)
assert response.status_code == 200
assert response.json() == {"ok": True, "processed": True}
assert duplicate.status_code == 200
assert duplicate.json() == {"ok": True, "duplicate": True, "processed": False}
channel.process_webhook_update.assert_awaited_once_with({"update_id": 123, "message": {"text": "hello"}})