Browse Source

fix(slack): Allow issue links to unfurl properly with block kit (#63549)

Fixes an issue where links to Sentry issues posted in Slack wouldn't
unfurl when block kit is enabled. Also fixes an issue with some linked
issue action payloads being malformed (unresolved, mark as ongoing,
assignment would do the action in Sentry but would not successfully show
that it worked in Slack if a user used any one of these actions on an
unfurled Sentry issue link in Slack). Finally, this PR also adds
much-needed tests for using Slack actions through unfurled Sentry links
when block kit is enabled.

Fixes (some of) SENTRY-2DHB


![image](https://github.com/getsentry/sentry/assets/45607721/d4603ff6-c093-455c-9a77-bc50bcc17e9e)
Isabella Enriquez 1 year ago
parent
commit
34ca90b4f0

+ 12 - 0
src/sentry/integrations/slack/requests/action.py

@@ -46,6 +46,10 @@ class SlackActionRequest(SlackRequest):
         if self.data["type"] == "block_actions":
             if self.data.get("view"):
                 return json.loads(self.data["view"]["private_metadata"])
+            elif self.data.get("container", {}).get(
+                "is_app_unfurl"
+            ):  # for actions taken on interactive unfurls
+                return json.loads(self.data["app_unfurl"]["blocks"][0]["block_id"])
             return json.loads(self.data["message"]["blocks"][0]["block_id"])
 
         if self.data["type"] == "view_submission":
@@ -74,6 +78,14 @@ class SlackActionRequest(SlackRequest):
         except (KeyError, IndexError, TypeError, ValueError):
             raise SlackRequestError(status=status.HTTP_400_BAD_REQUEST)
 
+        # for interactive unfurls with block kit
+        if (
+            self.data.get("type") == "block_actions"
+            and self.data.get("container", {}).get("is_app_unfurl")
+            and ("app_unfurl" not in self.data or len(self.data["app_unfurl"]["blocks"]) == 0)
+        ):
+            raise SlackRequestError(status=status.HTTP_400_BAD_REQUEST)
+
     def _log_request(self) -> None:
         self._info("slack.action")
 

+ 2 - 0
src/sentry/integrations/slack/requests/options_load.py

@@ -18,6 +18,8 @@ class SlackOptionsLoadRequest(SlackRequest):
 
     @property
     def group_id(self) -> int:
+        if self.data.get("container", {}).get("is_app_unfurl"):
+            return int(json.loads(self.data["app_unfurl"]["blocks"][0]["block_id"])["issue"])
         return int(json.loads(self.data["message"]["blocks"][0]["block_id"])["issue"])
 
     @property

+ 8 - 0
src/sentry/integrations/slack/webhooks/action.py

@@ -522,6 +522,14 @@ class SlackActionEndpoint(Endpoint):
             response = SlackIssuesMessageBuilder(
                 group, identity=identity, actions=action_list, tags=original_tags_from_request
             ).build()
+            # XXX(isabella): for actions on link unfurls, we omit the fallback text from the
+            # response so the unfurling endpoint understands the payload
+            if (
+                slack_request.data.get("container")
+                and slack_request.data["container"].get("is_app_unfurl")
+                and "text" in response
+            ):
+                del response["text"]
             slack_client = SlackClient(integration_id=slack_request.integration.id)
 
             if not slack_request.data.get("response_url"):

+ 8 - 0
src/sentry/integrations/slack/webhooks/event.py

@@ -179,6 +179,14 @@ class SlackEventEndpoint(SlackDMEndpoint):
         if not results:
             return False
 
+        # XXX(isabella): we use our message builders to create the blocks for each link to be
+        # unfurled, so the original result will include the fallback text string, however, the
+        # unfurl endpoint does not accept fallback text.
+        if features.has("organizations:slack-block-kit", organization):
+            for link_info in results.values():
+                if "text" in link_info:
+                    del link_info["text"]
+
         payload = {
             "channel": data["channel"],
             "ts": data["message_ts"],

+ 398 - 0
tests/sentry/integrations/slack/webhooks/actions/test_status.py

@@ -74,6 +74,21 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
             ],
         }
 
+    def get_block_kit_unfurl_data(self, blocks):
+        return {
+            "container": {
+                "type": "message_attachment",
+                "is_app_unfurl": True,
+                "app_unfurl_url": "http://testserver/organizations/foo/issues/1?project=1&referrer=slack",
+            },
+            "app_unfurl": {
+                "id": 1,
+                "blocks": blocks,
+                "app_unfurl_url": "http://testserver/organizations/foo/issues/1?project=1&referrer=slack",
+                "is_app_unfurl": True,
+            },
+        }
+
     def get_ignore_status_action(self, text, selection):
         return {
             "action_id": selection,
@@ -184,6 +199,28 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
             expect_status = f"Identity not found.\n*Issue archived by <@{self.external_id}>*"
             assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status)
 
+    def test_ignore_issue_block_kit_through_unfurl(self):
+        event = self.store_event(
+            data=self.event_data,
+            project_id=self.project.id,
+        )
+        assert event.group is not None
+        group = event.group
+        original_message = self.get_original_message_block_kit(group.id)
+        status_action = self.get_ignore_status_action("Ignore", "ignored:forever")
+
+        data = self.get_block_kit_unfurl_data(original_message["blocks"])
+
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(action_data=[status_action], data=data)
+            self.group = Group.objects.get(id=event.group.id)
+
+            assert resp.status_code == 200, resp.content
+            assert self.group.get_status() == GroupStatus.IGNORED
+            assert self.group.substatus == GroupSubStatus.FOREVER
+            expect_status = f"Identity not found.\n*Issue archived by <@{self.external_id}>*"
+            assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status)
+
     def test_archive_issue(self):
         event = self.store_event(
             data=self.event_data,
@@ -243,6 +280,29 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
         expect_status = f"Identity not found.\n*Issue archived by <@{self.external_id}>*"
         assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status)
 
+    def test_archive_issue_block_kit_through_unfurl(self):
+        event = self.store_event(
+            data=self.event_data,
+            project_id=self.project.id,
+        )
+        assert event.group is not None
+        group = event.group
+        original_message = self.get_original_message_block_kit(group.id)
+        status_action = self.get_ignore_status_action("Archive", "ignored:until_escalating")
+
+        data = self.get_block_kit_unfurl_data(original_message["blocks"])
+
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(action_data=[status_action], data=data)
+        self.group = Group.objects.get(id=event.group.id)
+
+        assert resp.status_code == 200, resp.content
+        assert self.group.get_status() == GroupStatus.IGNORED
+        assert self.group.substatus == GroupSubStatus.UNTIL_ESCALATING
+
+        expect_status = f"Identity not found.\n*Issue archived by <@{self.external_id}>*"
+        assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status)
+
     def test_ignore_issue_with_additional_user_auth(self):
         """
         Ensure that we can act as a user even when the organization has SSO enabled
@@ -305,6 +365,35 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
         expect_status = f"*Issue archived by <@{self.external_id}>*"
         assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status)
 
+    def test_ignore_issue_with_additional_user_auth_block_kit_through_unfurl(self):
+        """
+        Ensure that we can act as a user even when the organization has SSO enabled
+        """
+        with assume_test_silo_mode(SiloMode.CONTROL):
+            auth_idp = AuthProvider.objects.create(
+                organization_id=self.organization.id, provider="dummy"
+            )
+            AuthIdentity.objects.create(auth_provider=auth_idp, user=self.user)
+        event = self.store_event(
+            data=self.event_data,
+            project_id=self.project.id,
+        )
+        assert event.group is not None
+        original_message = self.get_original_message_block_kit(event.group.id)
+        status_action = self.get_ignore_status_action("Ignore", "ignored:forever")
+        data = self.get_block_kit_unfurl_data(original_message["blocks"])
+
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(action_data=[status_action], data=data)
+        self.group = Group.objects.get(id=event.group.id)
+
+        assert resp.status_code == 200, resp.content
+        assert self.group.get_status() == GroupStatus.IGNORED
+        assert self.group.substatus == GroupSubStatus.FOREVER
+
+        expect_status = f"*Issue archived by <@{self.external_id}>*"
+        assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status)
+
     def test_assign_issue(self):
         user2 = self.create_user(is_superuser=False)
         self.create_member(user=user2, organization=self.organization, teams=[self.team])
@@ -398,6 +487,42 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
         expect_status = f"*Issue assigned to #{self.team.slug} by <@{self.external_id}>*"
         assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status), resp.data["text"]
 
+    def test_assign_issue_block_kit_through_unfurl(self):
+        user2 = self.create_user(is_superuser=False)
+        self.create_member(user=user2, organization=self.organization, teams=[self.team])
+        status_action = self.get_assign_status_action("user", user2.email, user2.id)
+        original_message = self.get_original_message_block_kit(self.group.id)
+        data = self.get_block_kit_unfurl_data(original_message["blocks"])
+
+        # Assign to user
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(action_data=[status_action], data=data)
+
+        assert resp.status_code == 200, resp.content
+        assert GroupAssignee.objects.filter(group=self.group, user_id=user2.id).exists()
+
+        expect_status = f"*Issue assigned to {user2.get_display_name()} by <@{self.external_id}>*"
+        assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status), resp.data["text"]
+
+        # Assign to team
+        status_action = self.get_assign_status_action("team", self.team.slug, self.team.id)
+        original_message = self.get_original_message_block_kit(self.group.id)
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(action_data=[status_action], data=data)
+
+        assert resp.status_code == 200, resp.content
+        assert GroupAssignee.objects.filter(group=self.group, team=self.team).exists()
+        activity = Activity.objects.filter(group=self.group).first()
+        assert activity.data == {
+            "assignee": str(user2.id),
+            "assigneeEmail": user2.email,
+            "assigneeType": "user",
+            "integration": ActivityIntegration.SLACK.value,
+        }
+
+        expect_status = f"*Issue assigned to #{self.team.slug} by <@{self.external_id}>*"
+        assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status), resp.data["text"]
+
     def test_assign_issue_where_team_not_in_project(self):
         user2 = self.create_user(is_superuser=False)
 
@@ -444,6 +569,25 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
         assert resp.data["text"].endswith("Cannot assign to a team without access to the project")
         assert not GroupAssignee.objects.filter(group=self.group).exists()
 
+    def test_assign_issue_where_team_not_in_project_block_kit_through_unfurl(self):
+        user2 = self.create_user(is_superuser=False)
+        team2 = self.create_team(
+            organization=self.organization, members=[self.user], name="Ecosystem"
+        )
+        self.create_member(user=user2, organization=self.organization, teams=[team2])
+        self.create_project(name="hellboy", organization=self.organization, teams=[team2])
+        # Assign to team
+        status_action = self.get_assign_status_action("team", team2.slug, team2.id)
+        original_message = self.get_original_message_block_kit(self.group.id)
+        data = self.get_block_kit_unfurl_data(original_message["blocks"])
+
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook(action_data=[status_action], data=data)
+
+        assert resp.status_code == 200, resp.content
+        assert resp.data["text"].endswith("Cannot assign to a team without access to the project")
+        assert not GroupAssignee.objects.filter(group=self.group).exists()
+
     def test_assign_issue_user_has_identity(self):
         user2 = self.create_user(is_superuser=False)
         self.create_member(user=user2, organization=self.organization, teams=[self.team])
@@ -500,6 +644,29 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
         )
         assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status), resp.data["text"]
 
+    def test_assign_issue_user_has_identity_block_kit_through_unfurl(self):
+        user2 = self.create_user(is_superuser=False)
+        self.create_member(user=user2, organization=self.organization, teams=[self.team])
+
+        user2_identity = self.create_identity(
+            external_id="slack_id2",
+            identity_provider=self.idp,
+            user=user2,
+        )
+        status_action = self.get_assign_status_action("user", user2.email, user2.id)
+        original_message = self.get_original_message_block_kit(self.group.id)
+        data = self.get_block_kit_unfurl_data(original_message["blocks"])
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(action_data=[status_action], data=data)
+
+        assert resp.status_code == 200, resp.content
+        assert GroupAssignee.objects.filter(group=self.group, user_id=user2.id).exists()
+
+        expect_status = (
+            f"*Issue assigned to <@{user2_identity.external_id}> by <@{self.external_id}>*"
+        )
+        assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status), resp.data["text"]
+
     def test_response_differs_on_bot_message(self):
         status_action = {"name": "status", "value": "ignored:forever", "type": "button"}
 
@@ -604,6 +771,34 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
         )
         assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status), resp.data["text"]
 
+    def test_assign_user_with_multiple_identities_block_kit_through_unfurl(self):
+        org2 = self.create_organization(owner=None)
+
+        integration2 = self.create_integration(
+            organization=org2,
+            provider="slack",
+            external_id="TXXXXXXX2",
+        )
+        idp2 = self.create_identity_provider(integration=integration2)
+        self.create_identity(
+            external_id="slack_id2",
+            identity_provider=idp2,
+            user=self.user,
+        )
+        status_action = self.get_assign_status_action("user", self.user.email, self.user.id)
+        original_message = self.get_original_message_block_kit(self.group.id)
+        data = self.get_block_kit_unfurl_data(original_message["blocks"])
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(action_data=[status_action], data=data)
+
+        assert resp.status_code == 200, resp.content
+        assert GroupAssignee.objects.filter(group=self.group, user_id=self.user.id).exists()
+
+        expect_status = "*Issue assigned to <@{assignee}> by <@{assignee}>*".format(
+            assignee=self.external_id
+        )
+        assert resp.data["blocks"][0]["text"]["text"].endswith(expect_status), resp.data["text"]
+
     @responses.activate
     def test_resolve_issue(self):
         status_action = {"name": "resolve_dialog", "value": "resolve_dialog"}
@@ -764,6 +959,59 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
         expect_status = f"*Issue resolved by <@{self.external_id}>*"
         assert update_data["blocks"][0]["text"]["text"].endswith(expect_status)
 
+    @responses.activate
+    def test_resolve_issue_block_kit_through_unfurl(self):
+        status_action = self.get_resolve_status_action()
+        original_message = self.get_original_message_block_kit(self.group.id)
+        # Expect request to open dialog on slack
+        responses.add(
+            method=responses.POST,
+            url="https://slack.com/api/views.open",
+            body='{"ok": true}',
+            status=200,
+            content_type="application/json",
+        )
+        payload_data = self.get_block_kit_unfurl_data(original_message["blocks"])
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(action_data=[status_action], data=payload_data)
+        assert resp.status_code == 200, resp.content
+
+        # Opening dialog should *not* cause the current message to be updated
+        assert resp.content == b""
+
+        data = json.loads(responses.calls[0].request.body)
+        assert data["trigger_id"] == self.trigger_id
+        assert "view" in data
+
+        view = json.loads(data["view"])
+        private_metadata = json.loads(view["private_metadata"])
+        assert int(private_metadata["issue"]) == self.group.id
+        assert private_metadata["orig_response_url"] == self.response_url
+
+        # Completing the dialog will update the message
+        responses.add(
+            method=responses.POST,
+            url=self.response_url,
+            body='{"ok": true}',
+            status=200,
+            content_type="application/json",
+        )
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(
+                type="view_submission",
+                private_metadata=json.dumps(private_metadata),
+                selected_option="resolved",
+            )
+
+        assert resp.status_code == 200, resp.content
+        self.group = Group.objects.get(id=self.group.id)
+        assert self.group.get_status() == GroupStatus.RESOLVED
+
+        update_data = json.loads(responses.calls[1].request.body)
+
+        expect_status = f"*Issue resolved by <@{self.external_id}>*"
+        assert update_data["blocks"][0]["text"]["text"].endswith(expect_status)
+
     @responses.activate
     def test_resolve_issue_in_next_release(self):
         status_action = {"name": "resolve_dialog", "value": "resolve_dialog"}
@@ -940,6 +1188,63 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
         expect_status = f"*Issue resolved by <@{self.external_id}>*"
         assert update_data["blocks"][0]["text"]["text"].endswith(expect_status)
 
+    @responses.activate
+    def test_resolve_in_next_release_block_kit_through_unfurl(self):
+        release = Release.objects.create(
+            organization_id=self.organization.id,
+            version="1.0",
+        )
+        release.add_project(self.project)
+        status_action = self.get_resolve_status_action()
+        original_message = self.get_original_message_block_kit(self.group.id)
+        # Expect request to open dialog on slack
+        responses.add(
+            method=responses.POST,
+            url="https://slack.com/api/views.open",
+            body='{"ok": true}',
+            status=200,
+            content_type="application/json",
+        )
+        payload_data = self.get_block_kit_unfurl_data(original_message["blocks"])
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(action_data=[status_action], data=payload_data)
+        assert resp.status_code == 200, resp.content
+
+        # Opening dialog should *not* cause the current message to be updated
+        assert resp.content == b""
+
+        data = json.loads(responses.calls[0].request.body)
+        assert data["trigger_id"] == self.trigger_id
+        assert "view" in data
+
+        view = json.loads(data["view"])
+        private_metadata = json.loads(view["private_metadata"])
+        assert int(private_metadata["issue"]) == self.group.id
+        assert private_metadata["orig_response_url"] == self.response_url
+
+        # Completing the dialog will update the message
+        responses.add(
+            method=responses.POST,
+            url=self.response_url,
+            body='{"ok": true}',
+            status=200,
+            content_type="application/json",
+        )
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(
+                type="view_submission",
+                private_metadata=json.dumps(private_metadata),
+                selected_option="resolved:inNextRelease",
+            )
+
+        assert resp.status_code == 200, resp.content
+        self.group = Group.objects.get(id=self.group.id)
+        assert self.group.get_status() == GroupStatus.RESOLVED
+        update_data = json.loads(responses.calls[1].request.body)
+
+        expect_status = f"*Issue resolved by <@{self.external_id}>*"
+        assert update_data["blocks"][0]["text"]["text"].endswith(expect_status)
+
     def test_permission_denied(self):
         user2 = self.create_user(is_superuser=False)
 
@@ -1007,6 +1312,35 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
             associate_url=associate_url, user_email=user2.email, org_name=self.organization.name
         )
 
+    def test_permission_denied_block_kit_through_unfurl(self):
+        user2 = self.create_user(is_superuser=False)
+        user2_identity = self.create_identity(
+            external_id="slack_id2",
+            identity_provider=self.idp,
+            user=user2,
+        )
+        status_action = self.get_ignore_status_action("Ignore", "ignored:forever")
+        original_message = self.get_original_message_block_kit(self.group.id)
+        data = self.get_block_kit_unfurl_data(original_message["blocks"])
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(
+                action_data=[status_action],
+                data=data,
+                slack_user={"id": user2_identity.external_id},
+            )
+        self.group = Group.objects.get(id=self.group.id)
+
+        associate_url = build_unlinking_url(
+            self.integration.id, "slack_id2", "C065W1189", self.response_url
+        )
+
+        assert resp.status_code == 200, resp.content
+        assert resp.data["response_type"] == "ephemeral"
+        assert not resp.data["replace_original"]
+        assert resp.data["text"] == UNLINK_IDENTITY_MESSAGE.format(
+            associate_url=associate_url, user_email=user2.email, org_name=self.organization.name
+        )
+
     @freeze_time("2021-01-14T12:27:28.303Z")
     @responses.activate
     def test_handle_submission_fail(self):
@@ -1154,6 +1488,70 @@ class StatusActionTest(BaseEventTest, HybridCloudTestMixin):
             org_name=self.organization.name,
         )
 
+    @freeze_time("2021-01-14T12:27:28.303Z")
+    @responses.activate
+    def test_handle_submission_fail_block_kit_through_unfurl(self):
+        status_action = self.get_resolve_status_action()
+        original_message = self.get_original_message_block_kit(self.group.id)
+        payload_data = self.get_block_kit_unfurl_data(original_message["blocks"])
+        # Expect request to open dialog on slack
+        responses.add(
+            method=responses.POST,
+            url="https://slack.com/api/views.open",
+            body='{"ok": true}',
+            status=200,
+            content_type="application/json",
+        )
+        with self.feature("organizations:slack-block-kit"):
+            resp = self.post_webhook_block_kit(action_data=[status_action], data=payload_data)
+        assert resp.status_code == 200, resp.content
+
+        # Opening dialog should *not* cause the current message to be updated
+        assert resp.content == b""
+
+        data = json.loads(responses.calls[0].request.body)
+        assert data["trigger_id"] == self.trigger_id
+        assert "view" in data
+
+        view = json.loads(data["view"])
+        private_metadata = json.loads(view["private_metadata"])
+        assert int(private_metadata["issue"]) == self.group.id
+        assert private_metadata["orig_response_url"] == self.response_url
+
+        # Completing the dialog will update the message
+        responses.add(
+            method=responses.POST,
+            url=self.response_url,
+            body='{"ok": true}',
+            status=200,
+            content_type="application/json",
+        )
+
+        # Remove the user from the organization.
+        member = OrganizationMember.objects.get(
+            user_id=self.user.id, organization=self.organization
+        )
+        member.remove_user()
+        member.save()
+        with self.feature("organizations:slack-block-kit"):
+            response = self.post_webhook_block_kit(
+                type="view_submission",
+                private_metadata=json.dumps(private_metadata),
+                selected_option="resolved",
+            )
+
+        assert response.status_code == 200, response.content
+        assert response.data["text"] == UNLINK_IDENTITY_MESSAGE.format(
+            associate_url=build_unlinking_url(
+                integration_id=self.integration.id,
+                slack_id=self.external_id,
+                channel_id="C065W1189",
+                response_url=self.response_url,
+            ),
+            user_email=self.user.email,
+            org_name=self.organization.name,
+        )
+
     @patch(
         "sentry.integrations.slack.requests.SlackRequest._check_signing_secret", return_value=True
     )

+ 17 - 0
tests/sentry/integrations/slack/webhooks/events/__init__.py

@@ -2,6 +2,7 @@ from unittest.mock import patch
 
 from sentry.testutils.cases import APITestCase
 from sentry.testutils.helpers import install_slack
+from sentry.utils import json
 
 UNSET = object()
 
@@ -29,6 +30,22 @@ LINK_SHARED_EVENT = """{
 }"""
 
 
+def build_test_block(link):
+    return {
+        "blocks": [
+            {
+                "type": "section",
+                "text": {
+                    "type": "mrkdwn",
+                    "text": f"<{link}/1|*wow an issue very cool*> \n",
+                },
+                "block_id": json.dumps({"issue": 1}),
+            }
+        ],
+        "text": "[foo] wow an issue very cool",
+    }
+
+
 class BaseEventTest(APITestCase):
     def setUp(self):
         super().setUp()

+ 48 - 1
tests/sentry/integrations/slack/webhooks/events/test_link_shared.py

@@ -5,10 +5,11 @@ from urllib.parse import parse_qsl
 import responses
 
 from sentry.integrations.slack.unfurl import Handler, make_type_coercer
+from sentry.testutils.helpers.features import with_feature
 from sentry.testutils.silo import region_silo_test
 from sentry.utils import json
 
-from . import LINK_SHARED_EVENT, BaseEventTest
+from . import LINK_SHARED_EVENT, BaseEventTest, build_test_block
 
 
 @region_silo_test
@@ -48,3 +49,49 @@ class LinkSharedEventTest(BaseEventTest):
         assert len(unfurls) == 2
         assert unfurls["link1"] == "unfurl"
         assert unfurls["link2"] == "unfurl"
+
+    @responses.activate
+    @with_feature("organizations:slack-block-kit")
+    @patch(
+        "sentry.integrations.slack.webhooks.event.match_link",
+        # match_link will be called twice, for each our links. Resolve into
+        # two unique links and one duplicate.
+        side_effect=[
+            ("mock_link", {"arg1": "value1"}),
+            ("mock_link", {"arg1", "value2"}),
+            ("mock_link", {"arg1": "value1"}),
+        ],
+    )
+    @patch(
+        "sentry.integrations.slack.webhooks.event.link_handlers",
+        {
+            "mock_link": Handler(
+                matcher=[re.compile(r"test")],
+                arg_mapper=make_type_coercer({}),
+                fn=Mock(
+                    return_value={
+                        "link1": build_test_block(LINK_SHARED_EVENT[0]),
+                        "link2": build_test_block(LINK_SHARED_EVENT[1]),
+                    }
+                ),
+            )
+        },
+    )
+    def test_share_links_block_kit(self, mock_match_link):
+        responses.add(responses.POST, "https://slack.com/api/chat.unfurl", json={"ok": True})
+
+        resp = self.post_webhook(event_data=json.loads(LINK_SHARED_EVENT))
+        assert resp.status_code == 200, resp.content
+        assert len(mock_match_link.mock_calls) == 3
+
+        data = dict(parse_qsl(responses.calls[0].request.body))
+        unfurls = json.loads(data["unfurls"])
+
+        # We only have two unfurls since one link was duplicated
+        assert len(unfurls) == 2
+        result1 = build_test_block(LINK_SHARED_EVENT[0])
+        del result1["text"]
+        result2 = build_test_block(LINK_SHARED_EVENT[1])
+        del result2["text"]
+        assert unfurls["link1"] == result1
+        assert unfurls["link2"] == result2