mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 01:45:58 +00:00
Add user-owned IM channel connections
This commit is contained in:
@@ -21,6 +21,10 @@ from app.gateway.auth_middleware import AuthMiddleware, _is_public
|
||||
"/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):
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for user-facing IM channel connection configuration."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
|
||||
|
||||
|
||||
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_require_public_url_and_encryption_key():
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
ChannelConnectionsConfig(enabled=True)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "public_base_url is required" in message
|
||||
assert "encryption_key is required" in message
|
||||
|
||||
|
||||
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("unknown") == {"enabled": False, "configured": False}
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Tests for per-user IM channel connection persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from deerflow.persistence.channel_connections import (
|
||||
ChannelConnectionRepository,
|
||||
ChannelConnectionRow,
|
||||
ChannelCredentialCipher,
|
||||
ChannelCredentialRow,
|
||||
ChannelWebhookDeliveryRow,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repo(tmp_path):
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'channels.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
try:
|
||||
yield ChannelConnectionRepository(
|
||||
get_session_factory(),
|
||||
cipher=ChannelCredentialCipher.from_key("test-encryption-key"),
|
||||
)
|
||||
finally:
|
||||
await close_engine()
|
||||
|
||||
|
||||
class TestChannelConnectionRepository:
|
||||
@pytest.mark.anyio
|
||||
async def test_connections_are_listed_per_owner(self, repo):
|
||||
alice = await repo.upsert_connection(
|
||||
owner_user_id="alice",
|
||||
provider="slack",
|
||||
external_account_id="U-alice",
|
||||
external_account_name="Alice",
|
||||
workspace_id="T1",
|
||||
workspace_name="Team One",
|
||||
scopes=["chat:write"],
|
||||
)
|
||||
await repo.upsert_connection(
|
||||
owner_user_id="bob",
|
||||
provider="slack",
|
||||
external_account_id="U-bob",
|
||||
external_account_name="Bob",
|
||||
workspace_id="T1",
|
||||
workspace_name="Team One",
|
||||
scopes=["chat:write"],
|
||||
)
|
||||
|
||||
results = await repo.list_connections("alice")
|
||||
|
||||
assert [item["id"] for item in results] == [alice["id"]]
|
||||
assert results[0]["owner_user_id"] == "alice"
|
||||
assert results[0]["provider"] == "slack"
|
||||
assert results[0]["scopes"] == ["chat:write"]
|
||||
assert "encrypted_access_token" not in results[0]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_upsert_connection_updates_existing_provider_identity(self, repo):
|
||||
first = await repo.upsert_connection(
|
||||
owner_user_id="alice",
|
||||
provider="telegram",
|
||||
external_account_id="42",
|
||||
external_account_name="Alice",
|
||||
workspace_id=None,
|
||||
workspace_name=None,
|
||||
status="pending",
|
||||
)
|
||||
second = await repo.upsert_connection(
|
||||
owner_user_id="alice",
|
||||
provider="telegram",
|
||||
external_account_id="42",
|
||||
external_account_name="Alice Telegram",
|
||||
workspace_id=None,
|
||||
workspace_name=None,
|
||||
status="connected",
|
||||
)
|
||||
|
||||
assert second["id"] == first["id"]
|
||||
assert second["status"] == "connected"
|
||||
assert second["external_account_name"] == "Alice Telegram"
|
||||
assert len(await repo.list_connections("alice")) == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_credentials_are_encrypted_at_rest_and_decrypted_by_repository(self, repo):
|
||||
connection = await repo.upsert_connection(
|
||||
owner_user_id="alice",
|
||||
provider="slack",
|
||||
external_account_id="U-alice",
|
||||
workspace_id="T1",
|
||||
)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=1)
|
||||
|
||||
await repo.store_credentials(
|
||||
connection["id"],
|
||||
access_token="xoxb-secret-access-token",
|
||||
refresh_token="secret-refresh-token",
|
||||
token_type="Bearer",
|
||||
expires_at=expires_at,
|
||||
extra={"bot_user_id": "B123"},
|
||||
)
|
||||
|
||||
async with repo.session_factory() as session:
|
||||
row = (await session.execute(select(ChannelCredentialRow))).scalar_one()
|
||||
assert row.encrypted_access_token is not None
|
||||
assert "xoxb-secret-access-token" not in row.encrypted_access_token
|
||||
assert "secret-refresh-token" not in (row.encrypted_refresh_token or "")
|
||||
assert "B123" not in (row.encrypted_extra_json or "")
|
||||
|
||||
credentials = await repo.get_credentials(connection["id"])
|
||||
|
||||
assert credentials is not None
|
||||
assert credentials["access_token"] == "xoxb-secret-access-token"
|
||||
assert credentials["refresh_token"] == "secret-refresh-token"
|
||||
assert credentials["token_type"] == "Bearer"
|
||||
assert credentials["expires_at"] == expires_at
|
||||
assert credentials["extra"] == {"bot_user_id": "B123"}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_conversations_are_scoped_by_connection(self, repo):
|
||||
alice = await repo.upsert_connection(
|
||||
owner_user_id="alice",
|
||||
provider="slack",
|
||||
external_account_id="U-alice",
|
||||
workspace_id="T1",
|
||||
)
|
||||
bob = await repo.upsert_connection(
|
||||
owner_user_id="bob",
|
||||
provider="slack",
|
||||
external_account_id="U-bob",
|
||||
workspace_id="T1",
|
||||
)
|
||||
|
||||
await repo.set_thread_id(
|
||||
connection_id=alice["id"],
|
||||
owner_user_id="alice",
|
||||
provider="slack",
|
||||
external_conversation_id="C-shared",
|
||||
external_topic_id="1710000000.000100",
|
||||
thread_id="thread-alice",
|
||||
)
|
||||
await repo.set_thread_id(
|
||||
connection_id=bob["id"],
|
||||
owner_user_id="bob",
|
||||
provider="slack",
|
||||
external_conversation_id="C-shared",
|
||||
external_topic_id="1710000000.000100",
|
||||
thread_id="thread-bob",
|
||||
)
|
||||
|
||||
assert await repo.get_thread_id(alice["id"], "C-shared", "1710000000.000100") == "thread-alice"
|
||||
assert await repo.get_thread_id(bob["id"], "C-shared", "1710000000.000100") == "thread-bob"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_disconnect_connection_revokes_owner_connection_and_removes_credentials(self, repo):
|
||||
connection = await repo.upsert_connection(
|
||||
owner_user_id="alice",
|
||||
provider="telegram",
|
||||
external_account_id="42",
|
||||
)
|
||||
await repo.store_credentials(connection["id"], access_token="secret-token")
|
||||
|
||||
disconnected = await repo.disconnect_connection(
|
||||
connection_id=connection["id"],
|
||||
owner_user_id="alice",
|
||||
)
|
||||
|
||||
assert disconnected is True
|
||||
async with repo.session_factory() as session:
|
||||
connection_row = await session.get(ChannelConnectionRow, connection["id"])
|
||||
credential_row = await session.get(ChannelCredentialRow, connection["id"])
|
||||
assert connection_row is not None
|
||||
assert connection_row.status == "revoked"
|
||||
assert credential_row is None
|
||||
assert (
|
||||
await repo.find_connection_by_external_identity(
|
||||
provider="telegram",
|
||||
external_account_id="42",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_disconnect_connection_is_owner_scoped(self, repo):
|
||||
connection = await repo.upsert_connection(
|
||||
owner_user_id="alice",
|
||||
provider="telegram",
|
||||
external_account_id="42",
|
||||
)
|
||||
|
||||
disconnected = await repo.disconnect_connection(
|
||||
connection_id=connection["id"],
|
||||
owner_user_id="bob",
|
||||
)
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Router tests for browser-connectable IM channels."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
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, ChannelCredentialCipher
|
||||
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(),
|
||||
cipher=ChannelCredentialCipher.from_key("router-secret"),
|
||||
)
|
||||
|
||||
|
||||
def _make_app(config: ChannelConnectionsConfig, repo):
|
||||
app = make_authed_test_app(user_factory=_user)
|
||||
app.state.channel_connections_config = config
|
||||
app.state.channel_connection_repo = repo
|
||||
app.include_router(channel_connections.router)
|
||||
return app
|
||||
|
||||
|
||||
def test_get_providers_returns_catalog_and_current_status(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)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/channels/providers")
|
||||
|
||||
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
|
||||
|
||||
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(
|
||||
ChannelConnectionsConfig.model_validate(
|
||||
{
|
||||
"enabled": True,
|
||||
"public_base_url": "https://deerflow.example.com",
|
||||
"encryption_key": "router-secret",
|
||||
}
|
||||
),
|
||||
repo,
|
||||
)
|
||||
|
||||
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(
|
||||
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,
|
||||
)
|
||||
|
||||
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=")
|
||||
|
||||
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_unconfigured_provider_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,
|
||||
)
|
||||
|
||||
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_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"
|
||||
|
||||
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",
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
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"
|
||||
assert anyio.run(repo.get_credentials, connection_id) is None
|
||||
|
||||
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(
|
||||
ChannelConnectionsConfig.model_validate(
|
||||
{
|
||||
"enabled": True,
|
||||
"public_base_url": "https://deerflow.example.com",
|
||||
"encryption_key": "router-secret",
|
||||
}
|
||||
),
|
||||
repo,
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -468,6 +468,17 @@ def _make_mock_langgraph_client(thread_id="test-thread-123", run_result=None):
|
||||
return mock_client
|
||||
|
||||
|
||||
async def _make_channel_connection_repo(tmp_path: Path):
|
||||
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'channel-connections.db'}", sqlite_dir=str(tmp_path))
|
||||
return ChannelConnectionRepository(
|
||||
get_session_factory(),
|
||||
cipher=ChannelCredentialCipher.from_key("test-channel-key"),
|
||||
)
|
||||
|
||||
|
||||
def _make_stream_part(event: str, data):
|
||||
return SimpleNamespace(event=event, data=data)
|
||||
|
||||
@@ -1808,6 +1819,22 @@ class TestResolveRunParamsUserId:
|
||||
assert run_context["user_id"] == "123456"
|
||||
assert run_context["channel_user_id"] == "123456"
|
||||
|
||||
def test_connection_owner_user_id_takes_precedence_over_platform_user_id(self):
|
||||
manager = self._manager()
|
||||
msg = InboundMessage(
|
||||
channel_name="slack",
|
||||
chat_id="C123",
|
||||
user_id="U-platform",
|
||||
owner_user_id="deerflow-user-1",
|
||||
connection_id="connection-1",
|
||||
text="hi",
|
||||
)
|
||||
|
||||
_, _, run_context = manager._resolve_run_params(msg, "thread-1")
|
||||
|
||||
assert run_context["user_id"] == "deerflow-user-1"
|
||||
assert run_context["channel_user_id"] == "U-platform"
|
||||
|
||||
def test_unsafe_user_id_is_normalized_but_raw_preserved(self):
|
||||
from deerflow.config.paths import make_safe_user_id
|
||||
|
||||
@@ -1832,6 +1859,80 @@ class TestResolveRunParamsUserId:
|
||||
assert "channel_user_id" not in run_context
|
||||
|
||||
|
||||
class TestChannelManagerConnectionRouting:
|
||||
def test_connection_scoped_conversations_do_not_share_threads(self, tmp_path):
|
||||
from app.channels.manager import ChannelManager
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
async def go():
|
||||
repo = await _make_channel_connection_repo(tmp_path)
|
||||
alice = await repo.upsert_connection(
|
||||
owner_user_id="alice",
|
||||
provider="slack",
|
||||
external_account_id="U-alice",
|
||||
workspace_id="T1",
|
||||
)
|
||||
bob = await repo.upsert_connection(
|
||||
owner_user_id="bob",
|
||||
provider="slack",
|
||||
external_account_id="U-bob",
|
||||
workspace_id="T1",
|
||||
)
|
||||
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=tmp_path / "legacy-store.json")
|
||||
manager = ChannelManager(bus=bus, store=store, connection_repo=repo)
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
mock_client.threads.create = AsyncMock(
|
||||
side_effect=[
|
||||
{"thread_id": "thread-alice"},
|
||||
{"thread_id": "thread-bob"},
|
||||
]
|
||||
)
|
||||
manager._client = mock_client
|
||||
|
||||
await manager._handle_chat(
|
||||
InboundMessage(
|
||||
channel_name="slack",
|
||||
chat_id="C-shared",
|
||||
user_id="U-alice",
|
||||
owner_user_id="alice",
|
||||
connection_id=alice["id"],
|
||||
text="hello",
|
||||
thread_ts="1710000000.000100",
|
||||
topic_id="1710000000.000100",
|
||||
)
|
||||
)
|
||||
await manager._handle_chat(
|
||||
InboundMessage(
|
||||
channel_name="slack",
|
||||
chat_id="C-shared",
|
||||
user_id="U-bob",
|
||||
owner_user_id="bob",
|
||||
connection_id=bob["id"],
|
||||
text="hello",
|
||||
thread_ts="1710000000.000100",
|
||||
topic_id="1710000000.000100",
|
||||
)
|
||||
)
|
||||
|
||||
assert await repo.get_thread_id(alice["id"], "C-shared", "1710000000.000100") == "thread-alice"
|
||||
assert await repo.get_thread_id(bob["id"], "C-shared", "1710000000.000100") == "thread-bob"
|
||||
assert store.list_entries() == []
|
||||
|
||||
first_context = mock_client.runs.wait.call_args_list[0].kwargs["context"]
|
||||
second_context = mock_client.runs.wait.call_args_list[1].kwargs["context"]
|
||||
assert first_context["user_id"] == "alice"
|
||||
assert first_context["channel_user_id"] == "U-alice"
|
||||
assert second_context["user_id"] == "bob"
|
||||
assert second_context["channel_user_id"] == "U-bob"
|
||||
|
||||
try:
|
||||
_run(go())
|
||||
finally:
|
||||
_run(close_engine())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChannelService tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2619,6 +2720,93 @@ class TestChannelService:
|
||||
|
||||
assert service._config == {"telegram": {"enabled": False}}
|
||||
|
||||
def test_from_app_config_merges_telegram_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",
|
||||
"telegram": {
|
||||
"enabled": True,
|
||||
"bot_token": "telegram-token",
|
||||
"bot_username": "deerflow_bot",
|
||||
"webhook_secret": "webhook-secret",
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
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["discord"]["bot_token"] == "discord-bot-token"
|
||||
|
||||
def test_connection_repo_is_forwarded_to_manager(self):
|
||||
from app.channels.service import ChannelService
|
||||
|
||||
repo = object()
|
||||
service = ChannelService(channels_config={}, connection_repo=repo)
|
||||
|
||||
assert service.manager._connection_repo is repo
|
||||
|
||||
def test_disabled_channel_with_string_creds_emits_warning(self, caplog):
|
||||
"""Warning is emitted when a channel has string credentials but enabled=false."""
|
||||
import logging
|
||||
|
||||
@@ -22,6 +22,10 @@ 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
|
||||
|
||||
|
||||
@@ -233,3 +237,14 @@ def test_non_auth_mutation_rejects_mismatched_double_submit_token():
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "CSRF token mismatch."
|
||||
|
||||
|
||||
def test_channel_webhook_post_skips_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"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Discord connection routing tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.channels.discord import DiscordChannel
|
||||
from app.channels.message_bus import InboundMessage, MessageBus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repo(tmp_path):
|
||||
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||
|
||||
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'discord.db'}", sqlite_dir=str(tmp_path))
|
||||
try:
|
||||
yield ChannelConnectionRepository(
|
||||
get_session_factory(),
|
||||
cipher=ChannelCredentialCipher.from_key("discord-secret"),
|
||||
)
|
||||
finally:
|
||||
await close_engine()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_discord_inbound_attaches_owner_identity_from_user_level_connection(repo):
|
||||
connection = await repo.upsert_connection(
|
||||
owner_user_id="alice",
|
||||
provider="discord",
|
||||
external_account_id="987",
|
||||
external_account_name="Alice",
|
||||
status="connected",
|
||||
)
|
||||
channel = DiscordChannel(
|
||||
bus=MessageBus(),
|
||||
config={"bot_token": "discord-bot", "connection_repo": repo},
|
||||
)
|
||||
inbound = InboundMessage(
|
||||
channel_name="discord",
|
||||
chat_id="C123",
|
||||
user_id="987",
|
||||
text="hello",
|
||||
)
|
||||
|
||||
attached = await channel._attach_connection_identity(inbound, guild_id="G123")
|
||||
|
||||
assert attached.connection_id == connection["id"]
|
||||
assert attached.owner_user_id == "alice"
|
||||
assert attached.workspace_id is None
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Slack OAuth Events tests for user-owned channel connections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
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):
|
||||
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'slack.db'}", sqlite_dir=str(tmp_path))
|
||||
return ChannelConnectionRepository(
|
||||
get_session_factory(),
|
||||
cipher=ChannelCredentialCipher.from_key("slack-secret"),
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
import anyio
|
||||
|
||||
repo = anyio.run(_make_repo, tmp_path)
|
||||
|
||||
async def seed_connection():
|
||||
return await repo.upsert_connection(
|
||||
owner_user_id=str(_user().id),
|
||||
provider="slack",
|
||||
external_account_id="U123",
|
||||
workspace_id="T123",
|
||||
workspace_name="Deer Team",
|
||||
status="connected",
|
||||
)
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def test_slack_send_uses_connection_bot_token_when_connection_id_is_present():
|
||||
import anyio
|
||||
|
||||
from app.channels.slack import SlackChannel
|
||||
|
||||
async def go():
|
||||
repo = AsyncMock()
|
||||
repo.get_credentials.return_value = {"access_token": "xoxb-connection-token"}
|
||||
web_client = MagicMock()
|
||||
web_client_factory = MagicMock(return_value=web_client)
|
||||
channel = SlackChannel(
|
||||
bus=MessageBus(),
|
||||
config={
|
||||
"connection_repo": repo,
|
||||
"web_client_factory": web_client_factory,
|
||||
},
|
||||
)
|
||||
|
||||
msg = OutboundMessage(
|
||||
channel_name="slack",
|
||||
chat_id="C123",
|
||||
thread_id="thread-1",
|
||||
text="hello",
|
||||
connection_id="connection-1",
|
||||
)
|
||||
await channel.send(msg)
|
||||
|
||||
repo.get_credentials.assert_awaited_once_with("connection-1")
|
||||
web_client_factory.assert_called_once_with(token="xoxb-connection-token")
|
||||
web_client.chat_postMessage.assert_called_once()
|
||||
|
||||
anyio.run(go)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for Telegram deep-link channel connections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
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
|
||||
async def repo(tmp_path: Path):
|
||||
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||
|
||||
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'telegram.db'}", sqlite_dir=str(tmp_path))
|
||||
try:
|
||||
yield ChannelConnectionRepository(
|
||||
get_session_factory(),
|
||||
cipher=ChannelCredentialCipher.from_key("telegram-secret"),
|
||||
)
|
||||
finally:
|
||||
await close_engine()
|
||||
|
||||
|
||||
def _telegram_update(*, text: str = "/start", user_id: int = 42, chat_id: int = 100, chat_type: str = "private"):
|
||||
update = MagicMock()
|
||||
update.effective_user.id = user_id
|
||||
update.effective_user.username = "alice"
|
||||
update.effective_user.full_name = "Alice Example"
|
||||
update.effective_chat.id = chat_id
|
||||
update.effective_chat.type = chat_type
|
||||
update.message.text = text
|
||||
update.message.message_id = 55
|
||||
update.message.reply_to_message = None
|
||||
update.message.reply_text = AsyncMock()
|
||||
return update
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_start_with_deep_link_state_binds_telegram_chat(repo):
|
||||
state = "telegram-bind-state"
|
||||
await repo.create_oauth_state(
|
||||
owner_user_id="deerflow-user-1",
|
||||
provider="telegram",
|
||||
state=state,
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
||||
)
|
||||
channel = TelegramChannel(
|
||||
bus=MessageBus(),
|
||||
config={"bot_token": "test-token", "connection_repo": repo},
|
||||
)
|
||||
update = _telegram_update(text=f"/start {state}")
|
||||
context = MagicMock()
|
||||
context.args = [state]
|
||||
|
||||
await channel._cmd_start(update, context)
|
||||
|
||||
connections = await repo.list_connections("deerflow-user-1")
|
||||
assert len(connections) == 1
|
||||
assert connections[0]["provider"] == "telegram"
|
||||
assert connections[0]["external_account_id"] == "42"
|
||||
assert connections[0]["external_account_name"] == "Alice Example"
|
||||
assert connections[0]["workspace_id"] == "100"
|
||||
assert connections[0]["metadata"]["chat_type"] == "private"
|
||||
update.message.reply_text.assert_awaited_once()
|
||||
assert "connected" in update.message.reply_text.await_args.args[0].lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bound_telegram_message_publishes_connection_identity(repo):
|
||||
connection = await repo.upsert_connection(
|
||||
owner_user_id="deerflow-user-1",
|
||||
provider="telegram",
|
||||
external_account_id="42",
|
||||
external_account_name="Alice Example",
|
||||
workspace_id="100",
|
||||
metadata={"chat_type": "private"},
|
||||
)
|
||||
bus = MessageBus()
|
||||
channel = TelegramChannel(
|
||||
bus=bus,
|
||||
config={"bot_token": "test-token", "connection_repo": repo},
|
||||
)
|
||||
channel._main_loop = __import__("asyncio").get_event_loop()
|
||||
channel._send_running_reply = AsyncMock()
|
||||
|
||||
await channel._on_text(_telegram_update(text="hello"), None)
|
||||
inbound = await bus.get_inbound()
|
||||
|
||||
assert inbound.connection_id == connection["id"]
|
||||
assert inbound.owner_user_id == "deerflow-user-1"
|
||||
assert inbound.workspace_id == "100"
|
||||
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"}})
|
||||
Reference in New Issue
Block a user