mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 18:05:58 +00:00
191 lines
6.0 KiB
Python
191 lines
6.0 KiB
Python
"""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)
|