|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +from typing import Any |
1 | 4 | from unittest import mock |
2 | 5 | from urllib.parse import parse_qs, urlencode, urlparse |
3 | 6 |
|
4 | 7 | import pytest |
5 | 8 | import responses |
| 9 | +from django.urls import reverse |
6 | 10 | from responses.matchers import header_matcher, json_params_matcher |
7 | 11 |
|
8 | 12 | from sentry import audit_log, options |
|
18 | 22 | DiscordIntegrationProvider, |
19 | 23 | ) |
20 | 24 | from sentry.integrations.models.integration import Integration |
| 25 | +from sentry.integrations.models.organization_integration import OrganizationIntegration |
| 26 | +from sentry.integrations.pipeline import IntegrationPipeline |
21 | 27 | from sentry.models.auditlogentry import AuditLogEntry |
22 | 28 | from sentry.notifications.platform.discord.provider import DiscordRenderable |
23 | 29 | from sentry.notifications.platform.target import IntegrationNotificationTarget |
|
30 | 36 | IntegrationConfigurationError, |
31 | 37 | IntegrationError, |
32 | 38 | ) |
33 | | -from sentry.testutils.cases import IntegrationTestCase, TestCase |
| 39 | +from sentry.testutils.cases import APITestCase, IntegrationTestCase, TestCase |
34 | 40 | from sentry.testutils.silo import control_silo_test |
35 | 41 | from sentry.utils import json |
36 | 42 |
|
@@ -552,3 +558,128 @@ def test_send_notification_api_error(self, mock_send: mock.MagicMock) -> None: |
552 | 558 | self.installation.send_notification(target=self.target, payload=payload) |
553 | 559 |
|
554 | 560 | assert str(e.value) == error_payload |
| 561 | + |
| 562 | + |
| 563 | +@control_silo_test |
| 564 | +class DiscordApiPipelineTest(APITestCase): |
| 565 | + endpoint = "sentry-api-0-organization-pipeline" |
| 566 | + method = "post" |
| 567 | + |
| 568 | + guild_id = "1234567890" |
| 569 | + guild_name = "Cool server" |
| 570 | + |
| 571 | + def setUp(self) -> None: |
| 572 | + super().setUp() |
| 573 | + self.login_as(self.user) |
| 574 | + self.application_id = "application-id" |
| 575 | + self.public_key = "public-key" |
| 576 | + self.bot_token = "bot-token" |
| 577 | + self.client_secret = "client-secret" |
| 578 | + options.set("discord.application-id", self.application_id) |
| 579 | + options.set("discord.public-key", self.public_key) |
| 580 | + options.set("discord.bot-token", self.bot_token) |
| 581 | + options.set("discord.client-secret", self.client_secret) |
| 582 | + |
| 583 | + def tearDown(self) -> None: |
| 584 | + responses.reset() |
| 585 | + super().tearDown() |
| 586 | + |
| 587 | + def _get_pipeline_url(self) -> str: |
| 588 | + return reverse( |
| 589 | + self.endpoint, |
| 590 | + args=[self.organization.slug, IntegrationPipeline.pipeline_name], |
| 591 | + ) |
| 592 | + |
| 593 | + def _initialize_pipeline(self) -> Any: |
| 594 | + return self.client.post( |
| 595 | + self._get_pipeline_url(), |
| 596 | + data={"action": "initialize", "provider": "discord"}, |
| 597 | + format="json", |
| 598 | + ) |
| 599 | + |
| 600 | + def _advance_step(self, data: dict[str, Any]) -> Any: |
| 601 | + return self.client.post(self._get_pipeline_url(), data=data, format="json") |
| 602 | + |
| 603 | + def _get_pipeline_signature(self, resp: Any) -> str: |
| 604 | + return resp.data["data"]["oauthUrl"].split("state=")[1].split("&")[0] |
| 605 | + |
| 606 | + @responses.activate |
| 607 | + def test_initialize_pipeline(self) -> None: |
| 608 | + resp = self._initialize_pipeline() |
| 609 | + assert resp.status_code == 200 |
| 610 | + assert resp.data["step"] == "oauth_login" |
| 611 | + assert resp.data["stepIndex"] == 0 |
| 612 | + assert resp.data["totalSteps"] == 1 |
| 613 | + assert resp.data["provider"] == "discord" |
| 614 | + oauth_url = resp.data["data"]["oauthUrl"] |
| 615 | + assert "discord.com/api/oauth2/authorize" in oauth_url |
| 616 | + assert "permissions=" in oauth_url |
| 617 | + |
| 618 | + parsed = urlparse(oauth_url) |
| 619 | + params = parse_qs(parsed.query) |
| 620 | + assert params["client_id"] == [self.application_id] |
| 621 | + assert params["permissions"] == [str(DiscordIntegrationProvider.bot_permissions)] |
| 622 | + requested_scopes = set(params["scope"][0].split(" ")) |
| 623 | + assert requested_scopes == DiscordIntegrationProvider.oauth_scopes |
| 624 | + |
| 625 | + @responses.activate |
| 626 | + def test_oauth_step_missing_guild_id(self) -> None: |
| 627 | + resp = self._initialize_pipeline() |
| 628 | + pipeline_signature = self._get_pipeline_signature(resp) |
| 629 | + resp = self._advance_step({"code": "auth-code", "state": pipeline_signature}) |
| 630 | + assert resp.status_code == 400 |
| 631 | + |
| 632 | + @responses.activate |
| 633 | + @mock.patch("sentry.integrations.discord.client.DiscordClient.set_application_command") |
| 634 | + def test_full_pipeline_flow(self, mock_set_application_command: mock.MagicMock) -> None: |
| 635 | + responses.add( |
| 636 | + responses.GET, |
| 637 | + url=f"{DiscordClient.base_url}{GUILD_URL.format(guild_id=self.guild_id)}", |
| 638 | + match=[header_matcher({"Authorization": f"Bot {self.bot_token}"})], |
| 639 | + json={"id": self.guild_id, "name": self.guild_name}, |
| 640 | + ) |
| 641 | + responses.add( |
| 642 | + responses.GET, |
| 643 | + url=f"{DiscordClient.base_url}{APPLICATION_COMMANDS_URL.format(application_id=self.application_id)}", |
| 644 | + match=[header_matcher({"Authorization": f"Bot {self.bot_token}"})], |
| 645 | + json=COMMANDS, |
| 646 | + ) |
| 647 | + responses.add( |
| 648 | + responses.POST, |
| 649 | + url=f"{DISCORD_BASE_URL}/oauth2/token", |
| 650 | + json={"access_token": "access_token"}, |
| 651 | + ) |
| 652 | + responses.add( |
| 653 | + responses.GET, |
| 654 | + url=f"{DiscordClient.base_url}/users/@me", |
| 655 | + json={"id": "user_1234"}, |
| 656 | + ) |
| 657 | + responses.add( |
| 658 | + responses.GET, |
| 659 | + url=f"{DiscordClient.base_url}/users/@me/guilds/{self.guild_id}/member", |
| 660 | + json={}, |
| 661 | + ) |
| 662 | + |
| 663 | + resp = self._initialize_pipeline() |
| 664 | + assert resp.data["step"] == "oauth_login" |
| 665 | + pipeline_signature = self._get_pipeline_signature(resp) |
| 666 | + |
| 667 | + resp = self._advance_step( |
| 668 | + { |
| 669 | + "code": "discord-auth-code", |
| 670 | + "state": pipeline_signature, |
| 671 | + "guildId": self.guild_id, |
| 672 | + } |
| 673 | + ) |
| 674 | + assert resp.status_code == 200 |
| 675 | + assert resp.data["status"] == "complete" |
| 676 | + assert "data" in resp.data |
| 677 | + |
| 678 | + integration = Integration.objects.get(provider="discord") |
| 679 | + assert integration.external_id == self.guild_id |
| 680 | + assert integration.name == self.guild_name |
| 681 | + |
| 682 | + assert OrganizationIntegration.objects.filter( |
| 683 | + organization_id=self.organization.id, |
| 684 | + integration=integration, |
| 685 | + ).exists() |
0 commit comments