diff --git a/surfsense_backend/tests/e2e/fakes/fixtures/slack_messages.json b/surfsense_backend/tests/e2e/fakes/fixtures/slack_messages.json new file mode 100644 index 000000000..d443a30c6 --- /dev/null +++ b/surfsense_backend/tests/e2e/fakes/fixtures/slack_messages.json @@ -0,0 +1,18 @@ +{ + "team": { + "id": "T_FAKE_SLACK_TEAM", + "name": "SurfSense E2E Slack Workspace" + }, + "channel": { + "id": "C_FAKE_SLACK_CANARY", + "name": "e2e-canary", + "purpose": "SurfSense E2E Slack canary channel" + }, + "messages": [ + { + "ts": "1715000000.000100", + "user": "U_FAKE_SLACK_USER", + "text": "This Slack message proves the live MCP tool fetched channel content. SURFSENSE_E2E_CANARY_TOKEN_SLACK_001" + } + ] +} diff --git a/surfsense_backend/tests/e2e/fakes/slack_module.py b/surfsense_backend/tests/e2e/fakes/slack_module.py new file mode 100644 index 000000000..dfb011e3c --- /dev/null +++ b/surfsense_backend/tests/e2e/fakes/slack_module.py @@ -0,0 +1,213 @@ +"""Strict Slack MCP OAuth/tool fakes for Playwright E2E.""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch + +from tests.e2e.fakes import mcp_oauth_runtime, mcp_runtime + +_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "slack_messages.json" + +_AUTHORIZATION_URL = "https://slack.com/oauth/v2_user/authorize" +_REGISTRATION_URL = "https://e2e-fake.invalid/mcp/slack-unused-register" +_TOKEN_URL = "https://slack.com/api/oauth.v2.user.access" +_MCP_URL = "https://mcp.slack.com/mcp" + +_CLIENT_ID = "fake-slack-mcp-client-id" +_CLIENT_SECRET = "fake-slack-mcp-client-secret" +_ACCESS_TOKEN = "fake-slack-mcp-access-token" +_REFRESH_TOKEN = "fake-slack-mcp-refresh-token" +_OAUTH_CODE = "fake-slack-oauth-code" +_SCOPE = ( + "search:read.public search:read.private search:read.mpim search:read.im " + "channels:history groups:history mpim:history im:history" +) + + +def _load_fixture() -> dict[str, Any]: + with _FIXTURE_PATH.open() as f: + return json.load(f) + + +_FIXTURE = _load_fixture() + + +async def _list_tools() -> SimpleNamespace: + return SimpleNamespace( + tools=[ + SimpleNamespace( + name="slack_search_channels", + description="Search Slack channels visible to the authenticated user.", + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Text to search for in Slack channel names.", + }, + "limit": { + "type": "integer", + "description": "Maximum number of channels to return.", + }, + }, + "required": [], + }, + ), + SimpleNamespace( + name="slack_read_channel", + description="Read messages from a Slack channel.", + inputSchema={ + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "description": "Slack channel id.", + } + }, + "required": ["channel_id"], + }, + ), + SimpleNamespace( + name="slack_read_thread", + description="Read a Slack thread from a channel.", + inputSchema={ + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "description": "Slack channel id.", + }, + "thread_ts": { + "type": "string", + "description": "Slack thread timestamp.", + }, + }, + "required": ["channel_id", "thread_ts"], + }, + ), + ] + ) + + +async def _call_tool( + tool_name: str, arguments: dict[str, Any] | None = None +) -> SimpleNamespace: + arguments = arguments or {} + channel = _FIXTURE["channel"] + message = _FIXTURE["messages"][0] + + if tool_name == "slack_search_channels": + query = str(arguments.get("query", "")) + if query and channel["name"].lower() not in query.lower(): + raise ValueError(f"Unexpected Slack channel query: {query!r}") + text = ( + f"#{channel['name']} ({channel['id']})\n" + f"purpose: {channel['purpose']}\n" + f"latest_message: {message['text']}" + ) + return SimpleNamespace(content=[SimpleNamespace(text=text)]) + + if tool_name in {"slack_read_channel", "slack_read_thread"}: + raise NotImplementedError( + f"Slack E2E fake does not exercise {tool_name!r}; " + "extend slack_module.py before using it in a journey." + ) + + raise NotImplementedError(f"Unexpected Slack MCP tool call: {tool_name!r}") + + +async def _fake_exchange_code_for_tokens( + token_endpoint: str, + code: str, + redirect_uri: str, + client_id: str, + client_secret: str, + code_verifier: str, + *, + timeout: float = 30.0, +) -> dict[str, Any]: + if token_endpoint != _TOKEN_URL: + return await mcp_oauth_runtime._fake_exchange_code_for_tokens( # noqa: SLF001 + token_endpoint, + code, + redirect_uri, + client_id, + client_secret, + code_verifier, + timeout=timeout, + ) + del timeout + + if code != _OAUTH_CODE: + raise ValueError(f"Unexpected fake Slack OAuth code: {code!r}") + if "/api/v1/auth/mcp/slack/connector/callback" not in redirect_uri: + raise ValueError(f"Unexpected Slack redirect_uri={redirect_uri!r}") + if client_id != _CLIENT_ID or client_secret != _CLIENT_SECRET: + raise ValueError( + "Unexpected Slack client credentials: " + f"client_id={client_id!r} client_secret={client_secret!r}" + ) + if not code_verifier: + raise ValueError("Slack token exchange missing code_verifier.") + + team = _FIXTURE["team"] + return { + "ok": True, + "scope": _SCOPE, + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": _REFRESH_TOKEN, + "authed_user": { + "id": "U_FAKE_SLACK_USER", + "scope": _SCOPE, + "access_token": _ACCESS_TOKEN, + "refresh_token": _REFRESH_TOKEN, + "expires_in": 3600, + "token_type": "Bearer", + }, + "team": { + "id": team["id"], + "name": team["name"], + }, + } + + +def install(active_patches: list[Any]) -> None: + """Register Slack MCP OAuth/tool handlers with the shared dispatchers.""" + mcp_oauth_runtime.register_service( + mcp_url=_MCP_URL, + discovery_metadata={ + "issuer": "https://slack.com", + "authorization_endpoint": _AUTHORIZATION_URL, + "token_endpoint": _TOKEN_URL, + "registration_endpoint": _REGISTRATION_URL, + "code_challenge_methods_supported": ["S256"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "response_types_supported": ["code"], + }, + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + token_endpoint=_TOKEN_URL, + registration_endpoint=_REGISTRATION_URL, + oauth_code=_OAUTH_CODE, + access_token=_ACCESS_TOKEN, + refresh_token=_REFRESH_TOKEN, + scope=_SCOPE, + redirect_uri_substring="/api/v1/auth/mcp/slack/connector/callback", + ) + mcp_runtime.register( + url=_MCP_URL, + expected_bearer=_ACCESS_TOKEN, + list_tools=_list_tools, + call_tool=_call_tool, + ) + p = patch( + "app.services.mcp_oauth.discovery.exchange_code_for_tokens", + _fake_exchange_code_for_tokens, + ) + p.start() + active_patches.append(p)