diff --git a/fixtures/integrations/fake_slack/__init__.py b/fixtures/integrations/fake_slack/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/integrations/fake_slack/__main__.py b/fixtures/integrations/fake_slack/__main__.py new file mode 100644 index 000000000000..72e58ccdd438 --- /dev/null +++ b/fixtures/integrations/fake_slack/__main__.py @@ -0,0 +1,23 @@ +import argparse +import sys +from pathlib import Path + +from fixtures.integrations.fake_slack.server import FakeSlack, make_server + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run a fake Slack Web API for local development.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8099) + parser.add_argument("--state-file", type=Path, default=Path(".artifacts/fake-slack.json")) + args = parser.parse_args() + + slack = FakeSlack(args.state_file, f"http://localhost:{args.port}") + server = make_server(args.host, args.port, slack) + sys.stdout.write(f"Fake Slack is running on port {args.port}\n") + sys.stdout.flush() + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/fixtures/integrations/fake_slack/server.py b/fixtures/integrations/fake_slack/server.py new file mode 100644 index 000000000000..475c4d01d019 --- /dev/null +++ b/fixtures/integrations/fake_slack/server.py @@ -0,0 +1,311 @@ +"""A fake Slack Web API for SENTRY_USE_FAKE_SLACK. It accepts every call and validates nothing. + +Standard library only, so the devserver can start it without loading Sentry. +""" + +from __future__ import annotations + +import copy +import hashlib +import itertools +import json # noqa: S003 +import sys +import threading +import time +from collections.abc import Mapping +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit + +TEAM = {"id": "T0FAKE", "name": "Fake Slack", "domain": "fake-slack"} +DEFAULT_CHANNELS = { + "C0ALERTS": "alerts", + "C0SEER": "seer", +} +VIEWER = Path(__file__).with_name("viewer.html") +MAX_UNSUPPORTED_CALLS = 100 + +Response = dict[str, Any] + + +def _maybe_json(value: Any) -> Any: + if isinstance(value, str): + try: + return json.loads(value) + except ValueError: + return value + return value + + +class FakeSlack: + def __init__(self, state_file: Path | None, public_url: str) -> None: + self.state_file = state_file + self.public_url = public_url.rstrip("/") + self.lock = threading.Lock() + self.state: dict[str, Any] = self._load() + self.scheduled: dict[str, str] = {} + self._scheduled_ids = itertools.count(1) + + def _load(self) -> dict[str, Any]: + if self.state_file and self.state_file.exists(): + return {**self._empty(), **json.loads(self.state_file.read_text())} + return self._empty() + + @staticmethod + def _empty() -> dict[str, Any]: + return {"channels": dict(DEFAULT_CHANNELS), "messages": [], "unsupported": []} + + def _save(self) -> None: + if self.state_file: + self.state_file.parent.mkdir(parents=True, exist_ok=True) + self.state_file.write_text(json.dumps(self.state, indent=1)) + + def reset(self) -> None: + with self.lock: + self.state = self._empty() + self._save() + + def snapshot(self) -> dict[str, Any]: + with self.lock: + return {"team": TEAM, **copy.deepcopy(self.state)} + + def _existing_channel_id(self, channel: str) -> str | None: + channel = channel.lstrip("#") + channels: dict[str, str] = self.state["channels"] + if channel in channels: + return channel + return next((cid for cid, name in channels.items() if name == channel), None) + + def _channel_id(self, channel: str) -> str: + if channel_id := self._existing_channel_id(channel): + return channel_id + name = channel.lstrip("#") + channel_id = "C" + hashlib.sha1(name.encode()).hexdigest()[:9].upper() + self.state["channels"][channel_id] = name + return channel_id + + def _next_ts(self) -> str: + messages = self.state["messages"] + last = float(messages[-1]["ts"]) if messages else 0 + return f"{max(time.time(), last + 0.000001):.6f}" + + def _find(self, channel: str, ts: str) -> dict[str, Any] | None: + return next( + (m for m in self.state["messages"] if m["channel"] == channel and m["ts"] == ts), + None, + ) + + def call(self, method: str, args: Mapping[str, Any]) -> Response: + with self.lock: + handler = getattr(self, "_" + method.replace(".", "_"), None) + if handler is None: + self._record_unsupported(method, args) + return {"ok": True, "fake_slack": f"{method} is not simulated"} + result: Response = handler(args) + return result + + def _record_unsupported(self, method: str, args: Mapping[str, Any]) -> None: + channel = self._existing_channel_id(args.get("channel") or "") + ts = args.get("ts") or args.get("timestamp") or args.get("thread_ts") or "" + message = self._find(channel, ts) if channel else None + calls = self.state["unsupported"] + calls.append( + { + "method": method, + "at": time.time(), + "channel": channel if message else None, + "ts": ts if message else None, + } + ) + del calls[:-MAX_UNSUPPORTED_CALLS] + self._save() + + def _chat_postMessage(self, args: Mapping[str, Any], ephemeral: bool = False) -> Response: + channel = self._channel_id(args.get("channel") or "general") + message = { + "channel": channel, + "ts": self._next_ts(), + "thread_ts": args.get("thread_ts"), + "text": args.get("text") or "", + "blocks": _maybe_json(args.get("blocks")) or [], + "attachments": _maybe_json(args.get("attachments")) or [], + "reply_broadcast": bool(args.get("reply_broadcast")), + "ephemeral_user": args.get("user") if ephemeral else None, + "edited": None, + } + self.state["messages"].append(message) + self._save() + if ephemeral: + return {"ok": True, "message_ts": message["ts"]} + return {"ok": True, "channel": channel, "ts": message["ts"], "message": message} + + def _chat_postEphemeral(self, args: Mapping[str, Any]) -> Response: + return self._chat_postMessage(args, ephemeral=True) + + def _chat_scheduleMessage(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "general") + scheduled_message_id = f"Q{next(self._scheduled_ids):09d}" + self.scheduled[scheduled_message_id] = channel + self._save() + return { + "ok": True, + "channel": channel, + "scheduled_message_id": scheduled_message_id, + "post_at": args.get("post_at"), + } + + def _chat_deleteScheduledMessage(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + scheduled_message_id = args.get("scheduled_message_id") or "" + if self.scheduled.get(scheduled_message_id) != channel: + return {"ok": False, "error": "invalid_scheduled_message_id"} + del self.scheduled[scheduled_message_id] + return {"ok": True} + + def _chat_update(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + message = self._find(channel, args.get("ts") or "") + if message is None: + return {"ok": False, "error": "message_not_found"} + for key in ("text", "blocks", "attachments"): + if args.get(key) is not None: + message[key] = _maybe_json(args[key]) + message["edited"] = time.time() + self._save() + return {"ok": True, "channel": channel, "ts": message["ts"], "text": message["text"]} + + def _chat_delete(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + ts = args.get("ts") + self.state["messages"] = [ + m for m in self.state["messages"] if not (m["channel"] == channel and m["ts"] == ts) + ] + self._save() + return {"ok": True, "channel": channel, "ts": ts} + + def _chat_getPermalink(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + ts = args.get("message_ts") or "" + message = self._find(channel, ts) + thread = (message or {}).get("thread_ts") or ts + return { + "ok": True, + "channel": channel, + "permalink": f"{self.public_url}/#{channel}/{thread}/{ts}", + } + + def _conversations_list(self, args: Mapping[str, Any]) -> Response: + channels = [ + {"id": cid, "name": name, "is_channel": True, "is_private": False, "is_member": True} + for cid, name in self.state["channels"].items() + ] + return {"ok": True, "channels": channels, "response_metadata": {"next_cursor": ""}} + + def _users_conversations(self, args: Mapping[str, Any]) -> Response: + return self._conversations_list(args) + + def _conversations_info(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + return { + "ok": True, + "channel": {"id": channel, "name": self.state["channels"][channel], "is_member": True}, + } + + def _conversations_history(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + messages = [m for m in self.state["messages"] if m["channel"] == channel] + if latest := args.get("latest"): + messages = [m for m in messages if m["ts"] == latest] + return {"ok": True, "messages": messages, "has_more": False} + + def _conversations_replies(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + ts = args.get("ts") + messages = [ + m + for m in self.state["messages"] + if m["channel"] == channel and (m["ts"] == ts or m["thread_ts"] == ts) + ] + return {"ok": True, "messages": messages, "has_more": False} + + def _auth_test(self, args: Mapping[str, Any]) -> Response: + return { + "ok": True, + "team": TEAM["name"], + "team_id": TEAM["id"], + "user_id": "U0SENTRY", + "bot_id": "B0SENTRY", + } + + def _team_info(self, args: Mapping[str, Any]) -> Response: + return {"ok": True, "team": TEAM} + + def _users_info(self, args: Mapping[str, Any]) -> Response: + user = args.get("user") or "U0FAKE" + return { + "ok": True, + "user": { + "id": user, + "name": user, + "real_name": user, + "profile": {"display_name": user}, + }, + } + + def _users_list(self, args: Mapping[str, Any]) -> Response: + return {"ok": True, "members": [], "response_metadata": {"next_cursor": ""}} + + +def _flatten(params: Mapping[str, list[str]]) -> dict[str, Any]: + return {key: values[0] if len(values) == 1 else values for key, values in params.items()} + + +def make_server(host: str, port: int, slack: FakeSlack) -> ThreadingHTTPServer: + class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: Any) -> None: + pass + + def _send(self, status: int, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _send_json(self, data: Mapping[str, Any]) -> None: + self._send(200, json.dumps(data).encode(), "application/json") + + def do_GET(self) -> None: + url = urlsplit(self.path) + if url.path == "/state.json": + self._send_json(slack.snapshot()) + elif url.path.startswith("/api/"): + self._api(url.path, _flatten(parse_qs(url.query))) + else: + self._send(200, VIEWER.read_bytes(), "text/html; charset=utf-8") + + def do_POST(self) -> None: + url = urlsplit(self.path) + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length).decode() if length else "" + if url.path == "/reset": + slack.reset() + self._send_json({"ok": True}) + return + if "json" in self.headers.get("Content-Type", ""): + args = json.loads(raw or "{}") + else: + args = _flatten(parse_qs(raw)) + self._api(url.path, {**_flatten(parse_qs(url.query)), **args}) + + def _api(self, path: str, args: dict[str, Any]) -> None: + method = path.removeprefix("/api/") + result = slack.call(method, args) + channel = args.get("channel") or "" + sys.stdout.write(f"{method} {channel} -> ok={result.get('ok')}\n") + sys.stdout.flush() + self._send_json(result) + + return ThreadingHTTPServer((host, port), Handler) diff --git a/fixtures/integrations/fake_slack/viewer.html b/fixtures/integrations/fake_slack/viewer.html new file mode 100644 index 000000000000..c11374f77733 --- /dev/null +++ b/fixtures/integrations/fake_slack/viewer.html @@ -0,0 +1,377 @@ + + + + + +Fake Slack + + + + + +
+ +
+
+
+
+ +
+ + + + diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 5c41cc2e7731..477057c1286f 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2618,6 +2618,10 @@ def custom_parameter_sort(parameter: _OperationParameter) -> tuple[str, int]: # This flag activates the objectstore in devservices SENTRY_USE_OBJECTSTORE = False +# This flag starts a fake Slack workspace with the devserver; see `sentry django fake_slack_setup` +SENTRY_USE_FAKE_SLACK = False +SENTRY_FAKE_SLACK_PORT = 8099 + # Max file size for serialized file uploads in API SENTRY_MAX_SERIALIZED_FILE_SIZE = 5000000 @@ -2826,6 +2830,7 @@ def custom_parameter_sort(parameter: _OperationParameter) -> tuple[str, int]: SENTRY_GITHUB_CONSOLE_SDK_APP_ID: int = 0 SENTRY_SLACK_CLIENT_ID: str = "" SENTRY_SLACK_STAGING_CLIENT_ID: str = "" +SENTRY_SLACK_API_BASE_URL: str | None = None SENTRY_MSTEAMS_CLIENT_ID: str = "" SENTRY_MSTEAMS_TENANT_ID: str = "" SENTRY_VERCEL_CLIENT_ID: str = "" diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 4e997d7bad93..83c801eba25c 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -24,7 +24,7 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.slack import workspace from sentry.integrations.slack.metrics import translate_slack_api_error -from sentry.integrations.slack.sdk_client import SlackSdkClient +from sentry.integrations.slack.sdk_client import SlackSdkClient, slack_api_base_url from sentry.integrations.slack.tasks.link_slack_user_identities import link_slack_user_identities from sentry.integrations.slack.utils.constants import SlackScope from sentry.integrations.slack.utils.errors import SLACK_SDK_MESSAGE_GONE_ERROR_CATEGORIES @@ -388,7 +388,7 @@ def _get_team_info(self, access_token: str) -> Any: # first try with new SDK client (not attached to integration) try: - client = WebClient(token=access_token) + client = WebClient(token=access_token, base_url=slack_api_base_url()) sdk_response = client.team_info() return sdk_response.get("team") diff --git a/src/sentry/integrations/slack/sdk_client.py b/src/sentry/integrations/slack/sdk_client.py index c0307419ef82..d374c5ebd58f 100644 --- a/src/sentry/integrations/slack/sdk_client.py +++ b/src/sentry/integrations/slack/sdk_client.py @@ -3,6 +3,7 @@ from functools import wraps from types import FunctionType +from django.conf import settings from slack_sdk import WebClient from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse @@ -43,6 +44,10 @@ def track_response_data(response: SlackResponse, method: str, error: str | None logger.info("integration.http_response", extra=extra) +def slack_api_base_url() -> str: + return settings.SENTRY_SLACK_API_BASE_URL or WebClient.BASE_URL + + def is_response_fatal(response: SlackResponse) -> bool: if not response.get("ok"): if "account_inactive" == response.get("error", ""): @@ -120,4 +125,4 @@ def __init__(self, integration_id: int): raise ValueError(f"Missing token for integration with id {integration_id}") # TODO: missing from old SlackClient: verify_ssl, logging_context - super().__init__(token=access_token) + super().__init__(token=access_token, base_url=slack_api_base_url()) diff --git a/src/sentry/management/commands/fake_slack_setup.py b/src/sentry/management/commands/fake_slack_setup.py new file mode 100644 index 000000000000..43ed9e121ced --- /dev/null +++ b/src/sentry/management/commands/fake_slack_setup.py @@ -0,0 +1,64 @@ +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError, CommandParser + +from sentry.constants import ObjectStatus +from sentry.integrations.models.integration import Integration +from sentry.integrations.slack.integration import SlackIntegrationProvider +from sentry.integrations.types import IntegrationProviderSlug +from sentry.models.organization import Organization + +TEAM_ID = "T0FAKE" +TEAM_NAME = "Fake Slack" +TEAM_DOMAIN = "fake-slack" + + +class Command(BaseCommand): + help = "Connects an organization to the fake Slack workspace started by SENTRY_USE_FAKE_SLACK." + + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument( + "--org", help="Organization slug. Defaults to the only organization, if there is one." + ) + + def handle(self, **options: str | None) -> None: + if not settings.SENTRY_USE_FAKE_SLACK: + raise CommandError( + "Set SENTRY_USE_FAKE_SLACK = True in your Sentry config first, " + "or Slack messages will go to the real Slack." + ) + + organization = self._get_organization(options["org"]) + integration, _ = Integration.objects.update_or_create( + provider=IntegrationProviderSlug.SLACK.value, + external_id=TEAM_ID, + defaults={ + "name": TEAM_NAME, + "status": ObjectStatus.ACTIVE, + "metadata": { + "access_token": "xoxb-fake-slack", + "installation_type": "born_as_bot", + "domain_name": f"{TEAM_DOMAIN}.slack.com", + "scopes": sorted(SlackIntegrationProvider.identity_oauth_scopes), + }, + }, + ) + integration.add_organization(organization) + + port = settings.SENTRY_FAKE_SLACK_PORT + self.stdout.write( + f"Connected {organization.slug} to the {TEAM_NAME} workspace " + f"(integration {integration.id}).\n" + f"Send Slack notifications to any channel, like #alerts, and see them in Fake Slack " + f"on port {port} (http://localhost:{port} locally) while the devserver runs." + ) + + def _get_organization(self, slug: str | None) -> Organization: + if slug: + try: + return Organization.objects.get(slug=slug) + except Organization.DoesNotExist: + raise CommandError(f"No organization with slug {slug!r}") + organizations = list(Organization.objects.all()[:2]) + if len(organizations) != 1: + raise CommandError("Pass --org to choose an organization.") + return organizations[0] diff --git a/src/sentry/runner/commands/devserver.py b/src/sentry/runner/commands/devserver.py index 2263aec88da4..9787bf495096 100644 --- a/src/sentry/runner/commands/devserver.py +++ b/src/sentry/runner/commands/devserver.py @@ -375,6 +375,24 @@ def devserver( click.echo("--ingest was provided, implicitly enabling --workers") workers = True + if settings.SENTRY_USE_FAKE_SLACK and silo != "region": + daemons.append( + ( + "fake-slack", + [ + sys.executable, + "-m", + "fixtures.integrations.fake_slack", + "--host", + host, + "--port", + str(settings.SENTRY_FAKE_SLACK_PORT), + "--state-file", + ".artifacts/fake-slack.json", + ], + ) + ) + if task_scheduler and silo != "control": daemons.append(_get_daemon("taskworker-scheduler")) diff --git a/src/sentry/runner/initializer.py b/src/sentry/runner/initializer.py index 90e106132ceb..cc6d2c01aa16 100644 --- a/src/sentry/runner/initializer.py +++ b/src/sentry/runner/initializer.py @@ -435,6 +435,11 @@ def _trace(step: str) -> None: if settings.SENTRY_SINGLE_ORGANIZATION: settings.SENTRY_FEATURES["organizations:create"] = False + if settings.SENTRY_USE_FAKE_SLACK and not settings.SENTRY_SLACK_API_BASE_URL: + settings.SENTRY_SLACK_API_BASE_URL = ( + f"http://127.0.0.1:{settings.SENTRY_FAKE_SLACK_PORT}/api/" + ) + if not hasattr(settings, "SUDO_COOKIE_SECURE"): settings.SUDO_COOKIE_SECURE = getattr(settings, "SESSION_COOKIE_SECURE", False) if not hasattr(settings, "SUDO_COOKIE_DOMAIN"): diff --git a/tests/sentry/integrations/slack/test_fake_slack.py b/tests/sentry/integrations/slack/test_fake_slack.py new file mode 100644 index 000000000000..fa283414f0b6 --- /dev/null +++ b/tests/sentry/integrations/slack/test_fake_slack.py @@ -0,0 +1,98 @@ +import threading +from collections.abc import Generator +from pathlib import Path +from unittest import mock +from urllib.request import Request, urlopen + +import pytest +from django.test import override_settings +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from fixtures.integrations.fake_slack.server import FakeSlack, make_server +from sentry.integrations.slack.sdk_client import SlackSdkClient, slack_api_base_url +from sentry.testutils.cases import TestCase +from sentry.utils import json + + +class FakeSlackServerTest(TestCase): + @pytest.fixture(autouse=True) + def _fake_slack(self, tmp_path: Path) -> Generator[None]: + self.state_file = tmp_path / "fake-slack.json" + self.slack = FakeSlack(self.state_file, "https://fake-slack.example.com") + server = make_server("127.0.0.1", 0, self.slack) + thread = threading.Thread(target=server.serve_forever) + thread.start() + self.url = f"http://127.0.0.1:{server.server_address[1]}" + try: + yield + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_slack_client_posts_to_fake_slack(self) -> None: + assert slack_api_base_url() == WebClient.BASE_URL + + integration = self.create_integration( + organization=self.organization, + provider="slack", + external_id="T0FAKE", + metadata={"access_token": "xoxb-fake-slack"}, + ) + blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "*Issue* alert"}}] + with override_settings(SENTRY_SLACK_API_BASE_URL=f"{self.url}/api/"): + client = SlackSdkClient(integration_id=integration.id) + assert client.base_url == f"{self.url}/api/" + + posted = client.chat_postMessage(channel="#alerts", text="Issue alert", blocks=blocks) + reply = client.chat_postMessage(channel="C0ALERTS", thread_ts=posted["ts"], text="Re") + client.chat_update(channel="#alerts", ts=posted["ts"], text="Issue resolved") + permalink = client.chat_getPermalink(channel="#alerts", message_ts=reply["ts"]) + new_channel = client.chat_postMessage(channel="#new-channel", text="Hello") + scheduled = client.chat_scheduleMessage(channel="#seer", text="Check", post_at=1) + client.chat_deleteScheduledMessage( + channel=scheduled["channel"], + scheduled_message_id=scheduled["scheduled_message_id"], + ) + with pytest.raises(SlackApiError, match="invalid_scheduled_message_id"): + client.chat_deleteScheduledMessage( + channel=scheduled["channel"], + scheduled_message_id=scheduled["scheduled_message_id"], + ) + unsimulated = client.api_call("views.open", json={"trigger_id": "1"}) + client.api_call( + "reactions.add", + json={"channel": "#alerts", "timestamp": posted["ts"], "name": "eyes"}, + ) + with pytest.raises(SlackApiError, match="message_not_found"): + client.chat_update(channel="#alerts", ts="1.0", text="Missing") + + assert posted["channel"] == "C0ALERTS" + assert scheduled["channel"] == "C0SEER" + assert unsimulated["fake_slack"] == "views.open is not simulated" + assert permalink["permalink"] == ( + f"https://fake-slack.example.com/#C0ALERTS/{posted['ts']}/{reply['ts']}" + ) + + with urlopen(f"{self.url}/state.json") as response: + state = json.loads(response.read()) + assert state["team"]["id"] == "T0FAKE" + assert state["channels"][new_channel["channel"]] == "new-channel" + root, thread_reply, hello = state["messages"] + assert root["text"] == "Issue resolved" + assert root["blocks"] == blocks + assert root["edited"] is not None + assert thread_reply["thread_ts"] == posted["ts"] + assert hello["channel"] == new_channel["channel"] + assert state["unsupported"] == [ + {"method": "views.open", "at": mock.ANY, "channel": None, "ts": None}, + {"method": "reactions.add", "at": mock.ANY, "channel": "C0ALERTS", "ts": posted["ts"]}, + ] + assert FakeSlack(self.state_file, self.url).snapshot() == state + + with urlopen(self.url) as response: + assert b"Fake Slack" in response.read() + urlopen(Request(f"{self.url}/reset", method="POST")).close() + assert self.slack.snapshot()["messages"] == [] + assert self.slack.snapshot()["unsupported"] == [] diff --git a/tests/sentry/management/commands/test_fake_slack_setup.py b/tests/sentry/management/commands/test_fake_slack_setup.py new file mode 100644 index 000000000000..54d8559e8806 --- /dev/null +++ b/tests/sentry/management/commands/test_fake_slack_setup.py @@ -0,0 +1,46 @@ +from io import StringIO + +import pytest +from django.core.management import CommandError, call_command +from django.test import override_settings + +from fixtures.integrations.fake_slack.server import TEAM +from sentry.integrations.models.integration import Integration +from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import no_silo_test + + +@no_silo_test +class FakeSlackSetupTest(TestCase): + def run_command(self, *args: str) -> str: + out = StringIO() + call_command("fake_slack_setup", *args, stdout=out, stderr=StringIO()) + return out.getvalue() + + def test_requires_fake_slack(self) -> None: + with pytest.raises(CommandError, match="SENTRY_USE_FAKE_SLACK"): + self.run_command("--org", self.organization.slug) + assert not Integration.objects.filter(provider="slack").exists() + + @override_settings(SENTRY_USE_FAKE_SLACK=True) + def test_connects_organization(self) -> None: + output = self.run_command("--org", self.organization.slug) + self.run_command("--org", self.organization.slug) + + integration = Integration.objects.get(provider="slack") + assert integration.external_id == TEAM["id"] + assert integration.name == TEAM["name"] + assert integration.metadata["domain_name"] == f"{TEAM['domain']}.slack.com" + assert integration.metadata["access_token"] == "xoxb-fake-slack" + assert OrganizationIntegration.objects.filter( + integration=integration, organization_id=self.organization.id + ).exists() + assert f"Connected {self.organization.slug}" in output + assert "on port 8099 (http://localhost:8099 locally)" in output + + self.create_organization() + with pytest.raises(CommandError, match="--org"): + self.run_command() + with pytest.raises(CommandError, match="nope"): + self.run_command("--org", "nope")