diff --git a/src/sentry/api/endpoints/organization_monitoring_provider_index.py b/src/sentry/api/endpoints/organization_monitoring_provider_index.py index 5680087f7513..4137468586f7 100644 --- a/src/sentry/api/endpoints/organization_monitoring_provider_index.py +++ b/src/sentry/api/endpoints/organization_monitoring_provider_index.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from rest_framework.request import Request from rest_framework.response import Response @@ -11,12 +13,17 @@ ControlSiloOrganizationEndpoint, OrganizationPermission, ) +from sentry.identity.datadog.provider import DATADOG_VALID_SITES from sentry.organizations.services.organization.model import RpcOrganization from sentry.users.models.identity import OrganizationIdentity -MONITORING_PROVIDERS: dict[str, dict[str, str]] = { +MONITORING_PROVIDERS: dict[str, dict[str, Any]] = { "datadog": {"name": "Datadog"}, - "datadog_pat": {"name": "Datadog (Personal Access Token)"}, + "datadog_pat": { + "name": "Datadog (Personal Access Token)", + "pat_hint": "Create one at Organization Settings → Access → API Keys in Datadog.", + "sites": DATADOG_VALID_SITES, + }, "gcp": {"name": "Google Cloud Platform"}, } diff --git a/src/sentry/integrations/slack/message_builder/types.py b/src/sentry/integrations/slack/message_builder/types.py index a51a0be3143c..7d85209813a3 100644 --- a/src/sentry/integrations/slack/message_builder/types.py +++ b/src/sentry/integrations/slack/message_builder/types.py @@ -31,6 +31,10 @@ class SlackAction(StrEnum): SEER_AUTOFIX_VIEW_IN_SENTRY = "seer_autofix_view_in_sentry" SEER_AUTOFIX_VIEW_PR = "seer_autofix_view_pr" + CONNECT_MONITORING_PROVIDER = "connect_monitoring_provider" + SKIP_MONITORING_PROVIDER = "skip_monitoring_provider" + DECLINE_MONITORING_PROVIDER = "decline_monitoring_provider" + INCIDENT_COLOR_MAPPING = { "Resolved": "_incident_resolved", diff --git a/src/sentry/integrations/slack/webhooks/action.py b/src/sentry/integrations/slack/webhooks/action.py index 174908f3e5e8..e1a6a7529663 100644 --- a/src/sentry/integrations/slack/webhooks/action.py +++ b/src/sentry/integrations/slack/webhooks/action.py @@ -25,6 +25,7 @@ from sentry.api.helpers.group_index import update_groups from sentry.auth.access import from_member from sentry.exceptions import UnableToAcceptMemberInvitationException +from sentry.identity.services.identity import identity_service from sentry.integrations.messaging.metrics import ( MessageInteractionFailureReason, MessagingInteractionEvent, @@ -46,6 +47,11 @@ from sentry.integrations.slack.sdk_client import SlackSdkClient from sentry.integrations.slack.spec import SlackMessagingSpec from sentry.integrations.slack.utils.errors import MODAL_NOT_FOUND, unpack_slack_api_error +from sentry.integrations.slack.webhooks.monitoring_provider import ( + MONITORING_PROVIDER_CALLBACK_ID, + handle_monitoring_provider_submission, + open_monitoring_provider_modal, +) from sentry.integrations.types import ExternalProviderEnum, IntegrationProviderSlug from sentry.integrations.utils.scope import bind_org_context_from_integration from sentry.issues.action_log import ActionSource, GroupActionActor, action_context_scope @@ -756,6 +762,24 @@ def post(self, request: Request) -> Response: if action_option in NOTIFICATION_SETTINGS_ACTION_OPTIONS: return self.handle_enable_notifications(slack_request) + # Monitoring provider modals have no group context, so intercept before _handle_group_actions. + if slack_request.type == "view_submission": + view = slack_request.data.get("view", {}) + if view.get("callback_id") == MONITORING_PROVIDER_CALLBACK_ID: + result = handle_monitoring_provider_submission(slack_request) + if result: + return self.respond(result) + return self.respond() + + if action_id == SlackAction.CONNECT_MONITORING_PROVIDER.value: + return self._handle_connect_monitoring_provider(slack_request) + + if action_id in { + SlackAction.SKIP_MONITORING_PROVIDER.value, + SlackAction.DECLINE_MONITORING_PROVIDER.value, + }: + return self._handle_skip_monitoring_provider(slack_request, action_id) + action_list = self.get_action_list(slack_request=slack_request) return self._handle_group_actions(slack_request, request, action_list) @@ -899,6 +923,37 @@ def handle_link_identity(self, slack_request: SlackActionRequest) -> Response: ) return self.respond() + def _handle_connect_monitoring_provider(self, slack_request: SlackActionRequest) -> Response: + action_option, _ = self.get_action_option(slack_request=slack_request) + provider_key = action_option or "" + error = open_monitoring_provider_modal( + slack_request, + provider_key, + channel_id=slack_request.channel_id, + ) + if error: + return self.respond_ephemeral(error) + return self.respond() + + def _handle_skip_monitoring_provider( + self, slack_request: SlackActionRequest, action_id: str | None + ) -> Response: + # SKIP_MONITORING_PROVIDER: per-run suppression is handled by the caller that sends + # the prompt (the Seer operator won't re-prompt for the same run), so an ack is enough. + # DECLINE_MONITORING_PROVIDER: persists the provider on the Slack Identity so future + # unsolicited prompts are suppressed across runs. + if action_id == SlackAction.DECLINE_MONITORING_PROVIDER.value: + action_option, _ = self.get_action_option(slack_request=slack_request) + provider_key = action_option or "" + slack_identity_rpc = slack_request.get_identity() + if slack_identity_rpc and provider_key: + data = dict(slack_identity_rpc.data) if slack_identity_rpc.data else {} + declined = set(data.get("declined_monitoring_providers", [])) + declined.add(provider_key) + data["declined_monitoring_providers"] = sorted(declined) + identity_service.update_data(identity_id=slack_identity_rpc.id, data=data) + return self.respond() + class _ModalDialog(ABC): @property diff --git a/src/sentry/integrations/slack/webhooks/monitoring_provider.py b/src/sentry/integrations/slack/webhooks/monitoring_provider.py new file mode 100644 index 000000000000..a8e4fcc1afb5 --- /dev/null +++ b/src/sentry/integrations/slack/webhooks/monitoring_provider.py @@ -0,0 +1,341 @@ +import logging +from typing import Any + +import orjson +from django.db import IntegrityError +from requests.exceptions import RequestException +from slack_sdk.models.views import View + +from sentry import features +from sentry.api.endpoints.organization_monitoring_provider_index import ( + MONITORING_PROVIDERS, +) +from sentry.auth.exceptions import IdentityNotValid +from sentry.identity import default_manager as identity_manager +from sentry.identity.oauth2 import OAuth2Provider +from sentry.identity.services.identity import identity_service +from sentry.integrations.services.integration import integration_service +from sentry.integrations.slack.requests.action import SlackActionRequest +from sentry.integrations.slack.sdk_client import SlackSdkClient +from sentry.organizations.services.organization import organization_service +from sentry.users.models.identity import link_provider_identity +from sentry.utils.safe import get_path + +logger = logging.getLogger(__name__) + +MONITORING_PROVIDER_CALLBACK_ID = "monitoring_provider_connect" + + +def open_monitoring_provider_modal( + slack_request: SlackActionRequest, + provider_key: str, + *, + channel_id: str | None = None, + thread_ts: str | None = None, + run_id: str | None = None, +) -> str | None: + """Open the PAT modal for a monitoring provider. Returns an error message or None on success.""" + identity_user = slack_request.get_identity_user() + if not identity_user: + return "You need to link your Sentry identity first. Use `/sentry link` to get started." + + orgs = _get_orgs_with_feature(identity_user.id, slack_request.integration.id) + if not orgs: + return "None of your organizations have infrastructure monitoring enabled." + + if provider_key not in MONITORING_PROVIDERS: + return f"Unknown monitoring provider: `{provider_key}`." + + provider_type = identity_manager.get(provider_key) + if isinstance(provider_type, OAuth2Provider): + return f"`{provider_key}` uses OAuth and is not yet supported from Slack." + + trigger_id = slack_request.data.get("trigger_id") + if not trigger_id: + logger.warning( + "slack.monitoring_provider.no_trigger_id", + extra={"integration_id": slack_request.integration.id}, + ) + return "Unable to open the connection dialog. Please try again." + + modal = build_monitoring_provider_modal( + provider_key=provider_key, + orgs=orgs, + channel_id=channel_id or slack_request.channel_id, + thread_ts=thread_ts, + run_id=run_id, + ) + + slack_client = SlackSdkClient(integration_id=slack_request.integration.id) + slack_client.views_open(trigger_id=trigger_id, view=modal) + return None + + +def build_monitoring_provider_modal( + provider_key: str, + orgs: list[tuple[int, str]], + channel_id: str | None, + thread_ts: str | None, + run_id: str | None, +) -> View: + """Build a Slack modal View for PAT-based monitoring provider connection.""" + provider_meta = MONITORING_PROVIDERS.get(provider_key, {}) + provider_name = provider_meta.get("name", provider_key) + + blocks: list[dict[str, Any]] = [] + + if len(orgs) > 1: + org_options = [ + { + "text": {"type": "plain_text", "text": slug}, + "value": str(org_id), + } + for org_id, slug in orgs + ] + blocks.append( + { + "type": "input", + "block_id": "org_block", + "label": {"type": "plain_text", "text": "Organization"}, + "element": { + "type": "static_select", + "action_id": "org_select", + "placeholder": { + "type": "plain_text", + "text": "Select an organization", + }, + "options": org_options, + }, + } + ) + + sites: dict[str, str] | None = provider_meta.get("sites") + if sites: + site_options = [ + { + "text": { + "type": "plain_text", + "text": f"{site} ({region})" if region else site, + }, + "value": site, + } + for site, region in sorted(sites.items()) + ] + blocks.append( + { + "type": "input", + "block_id": "site_block", + "label": {"type": "plain_text", "text": "Site"}, + "element": { + "type": "static_select", + "action_id": "site_select", + "options": site_options, + "initial_option": site_options[0], + }, + } + ) + + token_block: dict[str, Any] = { + "type": "input", + "block_id": "token_block", + "label": {"type": "plain_text", "text": "Personal Access Token"}, + "element": { + "type": "plain_text_input", + "action_id": "token_input", + "placeholder": { + "type": "plain_text", + "text": "Paste your access token here", + }, + }, + } + pat_hint = provider_meta.get("pat_hint") + if pat_hint: + token_block["hint"] = {"type": "plain_text", "text": pat_hint} + blocks.append(token_block) + + private_metadata = orjson.dumps( + { + "provider_key": provider_key, + "org_id": orgs[0][0] if len(orgs) == 1 else None, + "channel_id": channel_id, + "thread_ts": thread_ts, + "run_id": run_id, + } + ).decode() + + return View( + type="modal", + title={"type": "plain_text", "text": f"Connect {provider_name}"}, + submit={"type": "plain_text", "text": "Connect"}, + close={"type": "plain_text", "text": "Cancel"}, + callback_id=MONITORING_PROVIDER_CALLBACK_ID, + private_metadata=private_metadata, + blocks=blocks, + ) + + +def handle_monitoring_provider_submission( + slack_request: SlackActionRequest, +) -> dict[str, Any] | None: + """ + Handle a monitoring provider modal view_submission. + + Returns a dict ``{"response_action": "errors", ...}`` to show validation + errors inside the modal, or ``None`` on success. + """ + view = slack_request.data.get("view", {}) + private_metadata = orjson.loads(view.get("private_metadata", "{}")) + provider_key = private_metadata.get("provider_key") + channel_id = private_metadata.get("channel_id") + + if not provider_key or provider_key not in MONITORING_PROVIDERS: + logger.error( + "slack.monitoring_provider.invalid_provider", + extra={"private_metadata": private_metadata}, + ) + return None + + identity_user = slack_request.get_identity_user() + if not identity_user: + return { + "response_action": "errors", + "errors": { + "token_block": "Your Sentry identity is no longer linked. Use /sentry link and try again." + }, + } + + state_values = get_path(view, "state", "values", default={}) + + org_id = private_metadata.get("org_id") + if not org_id: + selected = get_path(state_values, "org_block", "org_select", "selected_option") + if not selected: + return { + "response_action": "errors", + "errors": {"org_block": "Please select an organization."}, + } + org_id = int(selected["value"]) + + org_ctx = organization_service.get_organization_by_id(id=org_id, user_id=identity_user.id) + if org_ctx is None or org_ctx.member is None: + return { + "response_action": "errors", + "errors": {"org_block": "You do not have access to this organization."}, + } + + oi = integration_service.get_organization_integration( + organization_id=org_id, integration_id=slack_request.integration.id + ) + if oi is None: + return { + "response_action": "errors", + "errors": {"org_block": "This organization is not connected to this Slack workspace."}, + } + + if not features.has("organizations:seer-infra-telemetry", org_ctx.organization, actor=None): + return { + "response_action": "errors", + "errors": { + "org_block": "This organization does not have infrastructure monitoring enabled." + }, + } + + site = get_path(state_values, "site_block", "site_select", "selected_option", "value") + + access_token = ( + get_path(state_values, "token_block", "token_input", "value", default="") + ).strip() + if not access_token: + return { + "response_action": "errors", + "errors": {"token_block": "Access token is required."}, + } + + provider_type = identity_manager.get(provider_key) + build_data: dict[str, Any] = {"access_token": access_token} + if site: + build_data["site"] = site + + try: + identity_data = provider_type.build_identity(build_data) + except (ValueError, IdentityNotValid) as e: + return { + "response_action": "errors", + "errors": {"token_block": str(e)}, + } + except RequestException: + return { + "response_action": "errors", + "errors": {"token_block": "Failed to verify token with provider."}, + } + + try: + link_provider_identity( + user=identity_user, + identity_data=identity_data, + organization_id=org_id, + ) + except IntegrityError: + return { + "response_action": "errors", + "errors": {"token_block": "This account is already connected."}, + } + + _clear_declined_provider(slack_request, provider_key) + + _send_success_ephemeral( + slack_request=slack_request, + provider_key=provider_key, + channel_id=channel_id, + ) + + return None + + +def _clear_declined_provider(slack_request: SlackActionRequest, provider_key: str) -> None: + """Remove a provider from the Slack Identity's declined list.""" + slack_identity_rpc = slack_request.get_identity() + if not slack_identity_rpc: + return + data = dict(slack_identity_rpc.data) if slack_identity_rpc.data else {} + declined = set(data.get("declined_monitoring_providers", [])) + if provider_key not in declined: + return + declined.discard(provider_key) + data["declined_monitoring_providers"] = sorted(declined) + identity_service.update_data(identity_id=slack_identity_rpc.id, data=data) + + +def _send_success_ephemeral( + *, + slack_request: SlackActionRequest, + provider_key: str, + channel_id: str | None, +) -> None: + """Send an ephemeral success message after connecting a provider.""" + provider_meta = MONITORING_PROVIDERS.get(provider_key, {}) + provider_name = provider_meta.get("name", provider_key) + user_id = slack_request.user_id + + if not channel_id or not user_id: + return + + slack_client = SlackSdkClient(integration_id=slack_request.integration.id) + slack_client.chat_postEphemeral( + channel=channel_id, + user=user_id, + text=f"Connected {provider_name}. Seer can now query {provider_name} data in this org.", + ) + + +def _get_orgs_with_feature(user_id: int, integration_id: int) -> list[tuple[int, str]]: + """Return (org_id, org_slug) pairs where the org has seer-infra-telemetry enabled.""" + ois = integration_service.get_organization_integrations(integration_id=integration_id) + result: list[tuple[int, str]] = [] + for oi in ois: + ctx = organization_service.get_organization_by_id(id=oi.organization_id, user_id=user_id) + if ctx is None or ctx.member is None: + continue + if features.has("organizations:seer-infra-telemetry", ctx.organization, actor=None): + result.append((ctx.organization.id, ctx.organization.slug)) + return result diff --git a/tests/sentry/integrations/slack/webhooks/actions/test_monitoring_provider.py b/tests/sentry/integrations/slack/webhooks/actions/test_monitoring_provider.py new file mode 100644 index 000000000000..f7b5e426a58b --- /dev/null +++ b/tests/sentry/integrations/slack/webhooks/actions/test_monitoring_provider.py @@ -0,0 +1,426 @@ +from unittest.mock import MagicMock, patch + +import orjson + +from sentry.integrations.slack.webhooks.monitoring_provider import ( + MONITORING_PROVIDER_CALLBACK_ID, + build_monitoring_provider_modal, + handle_monitoring_provider_submission, +) +from sentry.silo.base import SiloMode +from sentry.testutils.cases import APITestCase, TestCase +from sentry.testutils.helpers import install_slack +from sentry.testutils.helpers.slack import add_identity +from sentry.testutils.silo import assume_test_silo_mode, control_silo_test +from sentry.users.models.identity import Identity, IdentityProvider, OrganizationIdentity + +from . import BaseEventTest + +OPEN_MODAL_PATH = "sentry.integrations.slack.webhooks.action.open_monitoring_provider_modal" + + +class TestBuildMonitoringProviderModal(TestCase): + def test_single_org_no_org_selector(self) -> None: + orgs = [(self.organization.id, self.organization.slug)] + modal = build_monitoring_provider_modal( + provider_key="datadog_pat", + orgs=orgs, + channel_id="C123", + thread_ts=None, + run_id=None, + ) + block_ids = [b.block_id for b in modal.blocks] + assert "org_block" not in block_ids + assert "site_block" in block_ids + assert "token_block" in block_ids + + assert modal.private_metadata is not None + metadata = orjson.loads(modal.private_metadata) + assert metadata["org_id"] == self.organization.id + assert metadata["provider_key"] == "datadog_pat" + assert metadata["channel_id"] == "C123" + + def test_multi_org_shows_org_selector(self) -> None: + org2 = self.create_organization(name="other-org", owner=self.user) + orgs = [ + (self.organization.id, self.organization.slug), + (org2.id, org2.slug), + ] + modal = build_monitoring_provider_modal( + provider_key="datadog_pat", + orgs=orgs, + channel_id="C123", + thread_ts=None, + run_id=None, + ) + block_ids = [b.block_id for b in modal.blocks] + assert "org_block" in block_ids + + assert modal.private_metadata is not None + metadata = orjson.loads(modal.private_metadata) + assert metadata["org_id"] is None + + def test_provider_without_sites_omits_site_block(self) -> None: + orgs = [(self.organization.id, self.organization.slug)] + modal = build_monitoring_provider_modal( + provider_key="gcp", + orgs=orgs, + channel_id="C123", + thread_ts=None, + run_id=None, + ) + block_ids = [b.block_id for b in modal.blocks] + assert "site_block" not in block_ids + assert "token_block" in block_ids + + def test_modal_title_uses_provider_name(self) -> None: + orgs = [(self.organization.id, self.organization.slug)] + modal = build_monitoring_provider_modal( + provider_key="datadog_pat", + orgs=orgs, + channel_id="C123", + thread_ts=None, + run_id=None, + ) + assert modal.title is not None + assert modal.title.text == "Connect Datadog (Personal Access Token)" + + def test_private_metadata_includes_thread_ts_and_run_id(self) -> None: + orgs = [(self.organization.id, self.organization.slug)] + modal = build_monitoring_provider_modal( + provider_key="datadog_pat", + orgs=orgs, + channel_id="C123", + thread_ts="1234567890.123456", + run_id="run-abc", + ) + assert modal.private_metadata is not None + metadata = orjson.loads(modal.private_metadata) + assert metadata["thread_ts"] == "1234567890.123456" + assert metadata["run_id"] == "run-abc" + + +def _make_mock_slack_request( + *, + integration_id: int, + team_id: str, + user_id: str, + private_metadata: dict, + token: str = "pat-abc", + site: str | None = "datadoghq.com", +) -> MagicMock: + state_values: dict = { + "token_block": { + "token_input": { + "type": "plain_text_input", + "value": token, + } + }, + } + if site is not None: + state_values["site_block"] = { + "site_select": { + "type": "static_select", + "selected_option": { + "text": {"type": "plain_text", "text": site}, + "value": site, + }, + } + } + + mock_request = MagicMock() + mock_request.data = { + "type": "view_submission", + "team": {"id": team_id}, + "user": {"id": user_id}, + "view": { + "id": "V_MODAL_123", + "type": "modal", + "callback_id": MONITORING_PROVIDER_CALLBACK_ID, + "private_metadata": orjson.dumps(private_metadata).decode(), + "state": {"values": state_values}, + }, + } + mock_request.integration = MagicMock() + mock_request.integration.id = integration_id + mock_request.user_id = user_id + return mock_request + + +@control_silo_test +class TestMonitoringProviderSubmission(APITestCase): + def setUp(self) -> None: + super().setUp() + self.integration = install_slack(self.organization) + self.idp = add_identity(self.integration, self.user, "UXXXXXXX1") + self.private_metadata = { + "provider_key": "datadog_pat", + "org_id": self.organization.id, + "channel_id": "C065W1189", + "thread_ts": None, + "run_id": None, + } + + def _make_request( + self, + *, + token: str = "pat-abc", + site: str | None = "datadoghq.com", + private_metadata: dict | None = None, + ) -> MagicMock: + mock_request = _make_mock_slack_request( + integration_id=self.integration.id, + team_id="TXXXXXXX1", + user_id="UXXXXXXX1", + private_metadata=private_metadata or self.private_metadata, + token=token, + site=site, + ) + mock_request.get_identity_user.return_value = MagicMock(id=self.user.id) + mock_request.get_identity.return_value = MagicMock( + id=Identity.objects.get(idp=self.idp, user=self.user).id, + data={}, + ) + return mock_request + + @patch("sentry.integrations.slack.webhooks.monitoring_provider._send_success_ephemeral") + @patch("sentry.identity.datadog.provider.get_user_info") + def test_successful_submission_creates_identity( + self, mock_get_user_info: MagicMock, mock_send: MagicMock + ) -> None: + mock_get_user_info.return_value = { + "user_uuid": "dd-user-123", + "org_uuid": "dd-org-456", + "user_email": "user@example.com", + "user_name": "Test User", + } + + with self.feature("organizations:seer-infra-telemetry"): + result = handle_monitoring_provider_submission(self._make_request()) + + assert result is None + + idp = IdentityProvider.objects.get(type="datadog_pat", external_id="dd-org-456") + identity = Identity.objects.get(idp=idp, user=self.user) + assert identity.external_id == "dd-user-123" + assert identity.data == {"access_token": "pat-abc", "site": "datadoghq.com"} + assert OrganizationIdentity.objects.filter( + organization_id=self.organization.id, identity=identity + ).exists() + + @patch("sentry.integrations.slack.webhooks.monitoring_provider._send_success_ephemeral") + @patch("sentry.identity.datadog.provider.get_user_info") + def test_successful_submission_sends_ephemeral( + self, mock_get_user_info: MagicMock, mock_send: MagicMock + ) -> None: + mock_get_user_info.return_value = { + "user_uuid": "dd-user-123", + "org_uuid": "dd-org-456", + } + + with self.feature("organizations:seer-infra-telemetry"): + handle_monitoring_provider_submission(self._make_request()) + + mock_send.assert_called_once() + assert mock_send.call_args[1]["provider_key"] == "datadog_pat" + assert mock_send.call_args[1]["channel_id"] == "C065W1189" + + def test_submission_empty_token_returns_error(self) -> None: + with self.feature("organizations:seer-infra-telemetry"): + result = handle_monitoring_provider_submission(self._make_request(token="")) + + assert result is not None + assert result["response_action"] == "errors" + assert "token_block" in result["errors"] + + def test_submission_whitespace_token_returns_error(self) -> None: + with self.feature("organizations:seer-infra-telemetry"): + result = handle_monitoring_provider_submission(self._make_request(token=" ")) + + assert result is not None + assert result["response_action"] == "errors" + assert "token_block" in result["errors"] + + @patch( + "sentry.identity.datadog.provider.get_user_info", side_effect=ValueError("Invalid API key") + ) + def test_submission_invalid_token_returns_error(self, mock_get_user_info: MagicMock) -> None: + with self.feature("organizations:seer-infra-telemetry"): + result = handle_monitoring_provider_submission(self._make_request()) + + assert result is not None + assert result["response_action"] == "errors" + assert "token_block" in result["errors"] + + def test_submission_without_feature_flag_returns_error(self) -> None: + result = handle_monitoring_provider_submission(self._make_request()) + + assert result is not None + assert result["response_action"] == "errors" + assert "infrastructure monitoring" in result["errors"]["org_block"] + + def test_submission_invalid_provider_returns_none(self) -> None: + with self.feature("organizations:seer-infra-telemetry"): + result = handle_monitoring_provider_submission( + self._make_request( + private_metadata={ + "provider_key": "nonexistent", + "org_id": self.organization.id, + "channel_id": "C065W1189", + "thread_ts": None, + "run_id": None, + }, + ) + ) + + assert result is None + + @patch("sentry.identity.datadog.provider.get_user_info") + def test_submission_already_connected_returns_error( + self, mock_get_user_info: MagicMock + ) -> None: + mock_get_user_info.return_value = { + "user_uuid": "dd-user-123", + "org_uuid": "dd-org-456", + } + + other_user = self.create_user() + idp = self.create_identity_provider(type="datadog_pat", external_id="dd-org-456") + self.create_identity( + user=other_user, + identity_provider=idp, + external_id="dd-user-123", + data={"access_token": "other-tok", "site": "datadoghq.com"}, + ) + + with self.feature("organizations:seer-infra-telemetry"): + result = handle_monitoring_provider_submission(self._make_request()) + + assert result is not None + assert result["response_action"] == "errors" + assert "already connected" in result["errors"]["token_block"] + + @patch("sentry.integrations.slack.webhooks.monitoring_provider._send_success_ephemeral") + @patch("sentry.identity.datadog.provider.get_user_info") + def test_submission_clears_declined_provider( + self, mock_get_user_info: MagicMock, mock_send: MagicMock + ) -> None: + mock_get_user_info.return_value = { + "user_uuid": "dd-user-123", + "org_uuid": "dd-org-456", + } + + slack_identity = Identity.objects.get(idp=self.idp, user=self.user) + slack_identity.update(data={"declined_monitoring_providers": ["datadog_pat", "gcp"]}) + + mock_request = self._make_request() + mock_request.get_identity.return_value = MagicMock( + id=slack_identity.id, + data={"declined_monitoring_providers": ["datadog_pat", "gcp"]}, + ) + + with self.feature("organizations:seer-infra-telemetry"): + handle_monitoring_provider_submission(mock_request) + + slack_identity.refresh_from_db() + assert slack_identity.data["declined_monitoring_providers"] == ["gcp"] + + +class TestConnectMonitoringProviderAction(BaseEventTest): + @patch(OPEN_MODAL_PATH, return_value=None) + def test_connect_action_opens_modal(self, mock_open_modal: MagicMock) -> None: + response = self.post_webhook_block_kit( + action_data=[ + { + "action_id": "connect_monitoring_provider", + "value": "datadog_pat", + "type": "button", + } + ], + ) + assert response.status_code == 200 + mock_open_modal.assert_called_once() + call_args = mock_open_modal.call_args + assert call_args[0][1] == "datadog_pat" + + @patch(OPEN_MODAL_PATH, return_value="You need to link your Sentry identity first.") + def test_connect_action_returns_error_as_ephemeral(self, mock_open_modal: MagicMock) -> None: + response = self.post_webhook_block_kit( + action_data=[ + { + "action_id": "connect_monitoring_provider", + "value": "datadog_pat", + "type": "button", + } + ], + ) + assert response.status_code == 200 + assert "link your Sentry identity" in response.data["text"] + + +class TestSkipMonitoringProviderAction(BaseEventTest): + def test_skip_action_returns_200(self) -> None: + response = self.post_webhook_block_kit( + action_data=[ + { + "action_id": "skip_monitoring_provider", + "value": "datadog_pat", + "type": "button", + } + ], + ) + assert response.status_code == 200 + + def test_decline_action_persists_on_identity(self) -> None: + response = self.post_webhook_block_kit( + action_data=[ + { + "action_id": "decline_monitoring_provider", + "value": "datadog_pat", + "type": "button", + } + ], + ) + assert response.status_code == 200 + + with assume_test_silo_mode(SiloMode.CONTROL): + slack_identity = Identity.objects.get(idp=self.idp, user=self.user) + assert "datadog_pat" in slack_identity.data["declined_monitoring_providers"] + + def test_decline_action_appends_to_existing(self) -> None: + with assume_test_silo_mode(SiloMode.CONTROL): + slack_identity = Identity.objects.get(idp=self.idp, user=self.user) + slack_identity.update(data={"declined_monitoring_providers": ["gcp"]}) + + self.post_webhook_block_kit( + action_data=[ + { + "action_id": "decline_monitoring_provider", + "value": "datadog_pat", + "type": "button", + } + ], + ) + + with assume_test_silo_mode(SiloMode.CONTROL): + slack_identity.refresh_from_db() + assert sorted(slack_identity.data["declined_monitoring_providers"]) == [ + "datadog_pat", + "gcp", + ] + + def test_decline_action_idempotent(self) -> None: + for _ in range(2): + self.post_webhook_block_kit( + action_data=[ + { + "action_id": "decline_monitoring_provider", + "value": "datadog_pat", + "type": "button", + } + ], + ) + + with assume_test_silo_mode(SiloMode.CONTROL): + slack_identity = Identity.objects.get(idp=self.idp, user=self.user) + assert slack_identity.data["declined_monitoring_providers"] == ["datadog_pat"]