test_sentry_apps.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902
  1. from collections import namedtuple
  2. from datetime import datetime, timedelta
  3. from unittest.mock import ANY, patch
  4. import pytest
  5. from celery import Task
  6. from django.core import mail
  7. from django.test import override_settings
  8. from django.urls import reverse
  9. from freezegun import freeze_time
  10. from requests.exceptions import Timeout
  11. from sentry import audit_log
  12. from sentry.api.serializers import serialize
  13. from sentry.constants import SentryAppStatus
  14. from sentry.integrations.notify_disable import notify_disable
  15. from sentry.integrations.request_buffer import IntegrationRequestBuffer
  16. from sentry.models import (
  17. Activity,
  18. AuditLogEntry,
  19. Group,
  20. Rule,
  21. SentryApp,
  22. SentryAppInstallation,
  23. SentryFunction,
  24. )
  25. from sentry.models.integrations.utils import get_redis_key
  26. from sentry.shared_integrations.exceptions import ClientError
  27. from sentry.tasks.post_process import post_process_group
  28. from sentry.tasks.sentry_apps import (
  29. build_comment_webhook,
  30. installation_webhook,
  31. notify_sentry_app,
  32. process_resource_change_bound,
  33. send_alert_event,
  34. send_webhooks,
  35. workflow_notification,
  36. )
  37. from sentry.testutils.cases import TestCase
  38. from sentry.testutils.helpers import with_feature
  39. from sentry.testutils.helpers.datetime import before_now, iso_format
  40. from sentry.testutils.helpers.eventprocessing import write_event_to_cache
  41. from sentry.testutils.silo import region_silo_test
  42. from sentry.types.activity import ActivityType
  43. from sentry.types.rules import RuleFuture
  44. from sentry.utils import json
  45. from sentry.utils.http import absolute_uri
  46. from sentry.utils.sentry_apps import SentryAppWebhookRequestsBuffer
  47. def raiseStatusFalse():
  48. return False
  49. def raiseStatusTrue():
  50. return True
  51. def raiseException():
  52. raise Exception
  53. class RequestMock:
  54. def __init__(self):
  55. self.body = "blah blah"
  56. headers = {"Sentry-Hook-Error": "d5111da2c28645c5889d072017e3445d", "Sentry-Hook-Project": "1"}
  57. html_content = "a bunch of garbage HTML"
  58. json_content = '{"error": "bad request"}'
  59. MockResponse = namedtuple(
  60. "MockResponse",
  61. ["headers", "content", "text", "ok", "status_code", "raise_for_status", "request"],
  62. )
  63. MockFailureHTMLContentResponseInstance = MockResponse(
  64. headers, html_content, "", True, 400, raiseStatusFalse, RequestMock()
  65. )
  66. MockFailureJSONContentResponseInstance = MockResponse(
  67. headers, json_content, "", True, 400, raiseStatusFalse, RequestMock()
  68. )
  69. MockFailureResponseInstance = MockResponse(
  70. headers, html_content, "", True, 400, raiseStatusFalse, RequestMock()
  71. )
  72. MockResponseWithHeadersInstance = MockResponse(
  73. headers, html_content, "", True, 400, raiseStatusFalse, RequestMock()
  74. )
  75. MockResponseInstance = MockResponse({}, {}, "", True, 200, raiseStatusFalse, None)
  76. MockResponse404 = MockResponse({}, {}, "", False, 404, raiseException, None)
  77. @region_silo_test
  78. class TestSendAlertEvent(TestCase):
  79. def setUp(self):
  80. self.sentry_app = self.create_sentry_app(organization=self.organization)
  81. self.rule = Rule.objects.create(project=self.project, label="Issa Rule")
  82. self.install = self.create_sentry_app_installation(
  83. organization=self.organization, slug=self.sentry_app.slug
  84. )
  85. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
  86. def test_no_sentry_app(self, safe_urlopen):
  87. event = self.store_event(data={}, project_id=self.project.id)
  88. send_alert_event(event, self.rule, 9999)
  89. assert not safe_urlopen.called
  90. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
  91. def test_no_sentry_app_in_future(self, safe_urlopen):
  92. event = self.store_event(data={}, project_id=self.project.id)
  93. rule_future = RuleFuture(rule=self.rule, kwargs={})
  94. with self.tasks():
  95. notify_sentry_app(event, [rule_future])
  96. assert not safe_urlopen.called
  97. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
  98. def test_no_installation(self, safe_urlopen):
  99. sentry_app = self.create_sentry_app(organization=self.organization)
  100. event = self.store_event(data={}, project_id=self.project.id)
  101. rule_future = RuleFuture(rule=self.rule, kwargs={"sentry_app": sentry_app})
  102. with self.tasks():
  103. notify_sentry_app(event, [rule_future])
  104. assert not safe_urlopen.called
  105. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance)
  106. def test_send_alert_event(self, safe_urlopen):
  107. event = self.store_event(data={}, project_id=self.project.id)
  108. assert event.group is not None
  109. group = event.group
  110. rule_future = RuleFuture(rule=self.rule, kwargs={"sentry_app": self.sentry_app})
  111. with self.tasks():
  112. notify_sentry_app(event, [rule_future])
  113. ((args, kwargs),) = safe_urlopen.call_args_list
  114. data = json.loads(kwargs["data"])
  115. assert data == {
  116. "action": "triggered",
  117. "installation": {"uuid": self.install.uuid},
  118. "data": {
  119. "event": ANY, # tested below
  120. "triggered_rule": self.rule.label,
  121. },
  122. "actor": {"type": "application", "id": "sentry", "name": "Sentry"},
  123. }
  124. assert data["data"]["event"]["event_id"] == event.event_id
  125. assert data["data"]["event"]["url"] == absolute_uri(
  126. reverse(
  127. "sentry-api-0-project-event-details",
  128. args=[self.organization.slug, self.project.slug, event.event_id],
  129. )
  130. )
  131. assert data["data"]["event"]["web_url"] == absolute_uri(
  132. reverse(
  133. "sentry-organization-event-detail",
  134. args=[self.organization.slug, group.id, event.event_id],
  135. )
  136. )
  137. assert data["data"]["event"]["issue_url"] == absolute_uri(f"/api/0/issues/{group.id}/")
  138. assert data["data"]["event"]["issue_id"] == str(group.id)
  139. assert kwargs["headers"].keys() >= {
  140. "Content-Type",
  141. "Request-ID",
  142. "Sentry-Hook-Resource",
  143. "Sentry-Hook-Timestamp",
  144. "Sentry-Hook-Signature",
  145. }
  146. buffer = SentryAppWebhookRequestsBuffer(self.sentry_app)
  147. requests = buffer.get_requests()
  148. assert len(requests) == 1
  149. assert requests[0]["response_code"] == 200
  150. assert requests[0]["event_type"] == "event_alert.triggered"
  151. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance)
  152. def test_send_alert_event_with_additional_payload(self, safe_urlopen):
  153. event = self.store_event(data={}, project_id=self.project.id)
  154. settings = [
  155. {"name": "alert_prefix", "value": "[Not Good]"},
  156. {"name": "channel", "value": "#ignored-errors"},
  157. {"name": "best_emoji", "value": ":fire:"},
  158. {"name": "teamId", "value": 1},
  159. {"name": "assigneeId", "value": 3},
  160. ]
  161. rule_future = RuleFuture(
  162. rule=self.rule,
  163. kwargs={"sentry_app": self.sentry_app, "schema_defined_settings": settings},
  164. )
  165. with self.tasks():
  166. notify_sentry_app(event, [rule_future])
  167. ((args, kwargs),) = safe_urlopen.call_args_list
  168. payload = json.loads(kwargs["data"])
  169. assert payload["action"] == "triggered"
  170. assert payload["data"]["triggered_rule"] == self.rule.label
  171. assert payload["data"]["issue_alert"] == {
  172. "id": self.rule.id,
  173. "title": self.rule.label,
  174. "sentry_app_id": self.sentry_app.id,
  175. "settings": settings,
  176. }
  177. buffer = SentryAppWebhookRequestsBuffer(self.sentry_app)
  178. requests = buffer.get_requests()
  179. assert len(requests) == 1
  180. assert requests[0]["response_code"] == 200
  181. assert requests[0]["event_type"] == "event_alert.triggered"
  182. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance)
  183. class TestProcessResourceChange(TestCase):
  184. def setUp(self):
  185. self.sentry_app = self.create_sentry_app(
  186. organization=self.organization, events=["issue.created"]
  187. )
  188. self.install = self.create_sentry_app_installation(
  189. organization=self.organization, slug=self.sentry_app.slug
  190. )
  191. def test_group_created_sends_webhook(self, safe_urlopen):
  192. event = self.store_event(data={}, project_id=self.project.id)
  193. assert event.group is not None
  194. with self.tasks():
  195. post_process_group(
  196. is_new=True,
  197. is_regression=False,
  198. is_new_group_environment=False,
  199. cache_key=write_event_to_cache(event),
  200. group_id=event.group_id,
  201. )
  202. ((args, kwargs),) = safe_urlopen.call_args_list
  203. data = json.loads(kwargs["data"])
  204. assert data["action"] == "created"
  205. assert data["installation"]["uuid"] == self.install.uuid
  206. assert data["data"]["issue"]["id"] == str(event.group.id)
  207. assert kwargs["headers"].keys() >= {
  208. "Content-Type",
  209. "Request-ID",
  210. "Sentry-Hook-Resource",
  211. "Sentry-Hook-Timestamp",
  212. "Sentry-Hook-Signature",
  213. }
  214. def test_does_not_process_disallowed_event(self, safe_urlopen):
  215. process_resource_change_bound("delete", "Group", self.create_group().id)
  216. assert len(safe_urlopen.mock_calls) == 0
  217. def test_does_not_process_sentry_apps_without_issue_webhooks(self, safe_urlopen):
  218. SentryAppInstallation.objects.all().delete()
  219. SentryApp.objects.all().delete()
  220. # DOES NOT subscribe to Issue events
  221. self.create_sentry_app_installation(organization=self.organization)
  222. process_resource_change_bound("created", "Group", self.create_group().id)
  223. assert len(safe_urlopen.mock_calls) == 0
  224. @patch("sentry.tasks.sentry_apps._process_resource_change")
  225. def test_process_resource_change_bound_passes_retry_object(self, process, safe_urlopen):
  226. group = self.create_group(project=self.project)
  227. process_resource_change_bound("created", "Group", group.id)
  228. ((_, kwargs),) = process.call_args_list
  229. task = kwargs["retryer"]
  230. assert isinstance(task, Task)
  231. @with_feature("organizations:integrations-event-hooks")
  232. def test_error_created_sends_webhook(self, safe_urlopen):
  233. sentry_app = self.create_sentry_app(
  234. organization=self.project.organization, events=["error.created"]
  235. )
  236. install = self.create_sentry_app_installation(
  237. organization=self.project.organization, slug=sentry_app.slug
  238. )
  239. one_min_ago = iso_format(before_now(minutes=1))
  240. event = self.store_event(
  241. data={
  242. "message": "Foo bar",
  243. "exception": {"type": "Foo", "value": "oh no"},
  244. "level": "error",
  245. "timestamp": one_min_ago,
  246. },
  247. project_id=self.project.id,
  248. assert_no_errors=False,
  249. )
  250. with self.tasks():
  251. post_process_group(
  252. is_new=False,
  253. is_regression=False,
  254. is_new_group_environment=False,
  255. cache_key=write_event_to_cache(event),
  256. group_id=event.group_id,
  257. )
  258. ((args, kwargs),) = safe_urlopen.call_args_list
  259. data = json.loads(kwargs["data"])
  260. assert data["action"] == "created"
  261. assert data["installation"]["uuid"] == install.uuid
  262. assert data["data"]["error"]["event_id"] == event.event_id
  263. assert data["data"]["error"]["issue_id"] == str(event.group_id)
  264. assert kwargs["headers"].keys() >= {
  265. "Content-Type",
  266. "Request-ID",
  267. "Sentry-Hook-Resource",
  268. "Sentry-Hook-Timestamp",
  269. "Sentry-Hook-Signature",
  270. }
  271. # TODO(nola): Enable this test whenever we prevent infinite loops w/ error.created integrations
  272. @pytest.mark.skip(reason="enable this when/if we do prevent infinite error.created loops")
  273. @with_feature("organizations:integrations-event-hooks")
  274. def test_integration_org_error_created_doesnt_send_webhook(self, safe_urlopen):
  275. sentry_app = self.create_sentry_app(
  276. organization=self.project.organization, events=["error.created"]
  277. )
  278. self.create_sentry_app_installation(
  279. organization=self.project.organization, slug=sentry_app.slug
  280. )
  281. one_min_ago = iso_format(before_now(minutes=1))
  282. event = self.store_event(
  283. data={
  284. "message": "Foo bar",
  285. "exception": {"type": "Foo", "value": "oh no"},
  286. "level": "error",
  287. "timestamp": one_min_ago,
  288. },
  289. project_id=self.project.id,
  290. assert_no_errors=False,
  291. )
  292. with self.tasks():
  293. post_process_group(
  294. is_new=False,
  295. is_regression=False,
  296. is_new_group_environment=False,
  297. cache_key=write_event_to_cache(event),
  298. group_id=event.group_id,
  299. )
  300. assert not safe_urlopen.called
  301. @patch("sentry.tasks.sentry_functions.send_sentry_function_webhook.delay")
  302. class TestProcessResourceChangeSentryFunctions(TestCase):
  303. def setUp(self):
  304. self.sentryFunction = self.create_sentry_function(
  305. organization_id=self.organization.id,
  306. name="foo",
  307. author="bar",
  308. code="baz",
  309. overview="qux",
  310. events=["issue", "comment", "error"],
  311. )
  312. @with_feature("organizations:sentry-functions")
  313. def test_group_created_sends_webhook(self, send_sentry_function_webhook):
  314. event = self.store_event(data={}, project_id=self.project.id)
  315. with self.tasks():
  316. post_process_group(
  317. is_new=True,
  318. is_regression=False,
  319. is_new_group_environment=False,
  320. cache_key=write_event_to_cache(event),
  321. group_id=event.group_id,
  322. )
  323. data = {}
  324. data["issue"] = serialize(Group.objects.get(id=event.group_id))
  325. send_sentry_function_webhook.assert_called_once_with(
  326. self.sentryFunction.external_id,
  327. "issue.created",
  328. data["issue"]["id"],
  329. data,
  330. )
  331. @with_feature("organizations:sentry-functions")
  332. def test_does_not_process_disallowed_event(self, send_sentry_function_webhook):
  333. process_resource_change_bound("delete", "Group", self.create_group().id)
  334. assert len(send_sentry_function_webhook.mock_calls) == 0
  335. @with_feature("organizations:sentry-functions")
  336. def test_does_not_process_sentry_apps_without_issue_webhooks(
  337. self, send_sentry_function_webhook
  338. ):
  339. SentryFunction.objects.all().delete()
  340. # DOES NOT subscribe to Issue events
  341. self.create_sentry_function(
  342. organization_id=self.organization.id,
  343. name="foo",
  344. author="bar",
  345. code="baz",
  346. overview="qux",
  347. events=["comment", "error"],
  348. )
  349. process_resource_change_bound("created", "Group", self.create_group().id)
  350. assert len(send_sentry_function_webhook.mock_calls) == 0
  351. @with_feature("organizations:sentry-functions")
  352. def test_error_created_does_not_sends_webhook(self, send_sentry_function_webhook):
  353. one_min_ago = iso_format(before_now(minutes=1))
  354. event = self.store_event(
  355. data={
  356. "message": "Foo bar",
  357. "exception": {"type": "Foo", "value": "oh no"},
  358. "level": "error",
  359. "timestamp": one_min_ago,
  360. },
  361. project_id=self.project.id,
  362. assert_no_errors=False,
  363. )
  364. with self.tasks():
  365. post_process_group(
  366. is_new=False,
  367. is_regression=False,
  368. is_new_group_environment=False,
  369. cache_key=write_event_to_cache(event),
  370. group_id=event.group_id,
  371. )
  372. assert len(send_sentry_function_webhook.mock_calls) == 0
  373. class TestSendResourceChangeWebhook(TestCase):
  374. def setUp(self):
  375. self.project = self.create_project()
  376. self.sentry_app_1 = self.create_sentry_app(
  377. organization=self.project.organization,
  378. events=["issue.created"],
  379. webhook_url="https://google.com",
  380. )
  381. self.install_1 = self.create_sentry_app_installation(
  382. organization=self.project.organization, slug=self.sentry_app_1.slug
  383. )
  384. self.sentry_app_2 = self.create_sentry_app(
  385. organization=self.project.organization,
  386. events=["issue.created"],
  387. webhook_url="https://apple.com",
  388. )
  389. self.install_2 = self.create_sentry_app_installation(
  390. organization=self.project.organization,
  391. slug=self.sentry_app_2.slug,
  392. )
  393. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponse404)
  394. @with_feature("organizations:integrations-event-hooks")
  395. def test_sends_webhooks_to_all_installs(self, safe_urlopen):
  396. one_min_ago = iso_format(before_now(minutes=1))
  397. event = self.store_event(
  398. data={
  399. "message": "Foo bar",
  400. "exception": {"type": "Foo", "value": "oh no"},
  401. "level": "error",
  402. "timestamp": one_min_ago,
  403. },
  404. project_id=self.project.id,
  405. assert_no_errors=False,
  406. )
  407. with self.tasks():
  408. post_process_group(
  409. is_new=True,
  410. is_regression=False,
  411. is_new_group_environment=False,
  412. cache_key=write_event_to_cache(event),
  413. group_id=event.group_id,
  414. )
  415. assert len(safe_urlopen.mock_calls) == 2
  416. call_urls = [call.kwargs["url"] for call in safe_urlopen.mock_calls]
  417. assert self.sentry_app_1.webhook_url in call_urls
  418. assert self.sentry_app_2.webhook_url in call_urls
  419. @patch("sentry.mediators.sentry_app_installations.InstallationNotifier.run")
  420. class TestInstallationWebhook(TestCase):
  421. def setUp(self):
  422. self.project = self.create_project()
  423. self.user = self.create_user()
  424. self.sentry_app = self.create_sentry_app(organization=self.project.organization)
  425. self.install = self.create_sentry_app_installation(
  426. organization=self.project.organization, slug=self.sentry_app.slug
  427. )
  428. def test_sends_installation_notification(self, run):
  429. installation_webhook(self.install.id, self.user.id)
  430. run.assert_called_with(install=self.install, user=self.user, action="created")
  431. def test_gracefully_handles_missing_install(self, run):
  432. installation_webhook(999, self.user.id)
  433. assert len(run.mock_calls) == 0
  434. def test_gracefully_handles_missing_user(self, run):
  435. installation_webhook(self.install.id, 999)
  436. assert len(run.mock_calls) == 0
  437. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance)
  438. class TestCommentWebhook(TestCase):
  439. def setUp(self):
  440. self.project = self.create_project()
  441. self.user = self.create_user()
  442. self.sentry_app = self.create_sentry_app(
  443. organization=self.project.organization,
  444. events=["comment.updated", "comment.created", "comment.deleted"],
  445. )
  446. self.install = self.create_sentry_app_installation(
  447. organization=self.project.organization, slug=self.sentry_app.slug
  448. )
  449. self.issue = self.create_group(project=self.project)
  450. self.note = Activity.objects.create(
  451. group=self.issue,
  452. project=self.project,
  453. type=ActivityType.NOTE.value,
  454. user_id=self.user.id,
  455. data={"text": "hello world"},
  456. )
  457. self.data = {
  458. "comment_id": self.note.id,
  459. "timestamp": self.note.datetime,
  460. "comment": self.note.data.get("text"),
  461. "project_slug": self.note.project.slug,
  462. }
  463. def test_sends_comment_created_webhook(self, safe_urlopen):
  464. build_comment_webhook(
  465. self.install.id, self.issue.id, "comment.created", self.user.id, data=self.data
  466. )
  467. ((_, kwargs),) = safe_urlopen.call_args_list
  468. assert kwargs["url"] == self.sentry_app.webhook_url
  469. assert kwargs["headers"]["Sentry-Hook-Resource"] == "comment"
  470. data = json.loads(kwargs["data"])
  471. assert data["action"] == "created"
  472. assert data["data"]["issue_id"] == self.issue.id
  473. def test_sends_comment_updated_webhook(self, safe_urlopen):
  474. self.data.update(data={"text": "goodbye world"})
  475. build_comment_webhook(
  476. self.install.id, self.issue.id, "comment.updated", self.user.id, data=self.data
  477. )
  478. ((_, kwargs),) = safe_urlopen.call_args_list
  479. assert kwargs["url"] == self.sentry_app.webhook_url
  480. assert kwargs["headers"]["Sentry-Hook-Resource"] == "comment"
  481. data = json.loads(kwargs["data"])
  482. assert data["action"] == "updated"
  483. assert data["data"]["issue_id"] == self.issue.id
  484. def test_sends_comment_deleted_webhook(self, safe_urlopen):
  485. self.note.delete()
  486. build_comment_webhook(
  487. self.install.id, self.issue.id, "comment.deleted", self.user.id, data=self.data
  488. )
  489. ((_, kwargs),) = safe_urlopen.call_args_list
  490. assert kwargs["url"] == self.sentry_app.webhook_url
  491. assert kwargs["headers"]["Sentry-Hook-Resource"] == "comment"
  492. data = json.loads(kwargs["data"])
  493. assert data["action"] == "deleted"
  494. assert data["data"]["issue_id"] == self.issue.id
  495. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance)
  496. class TestWorkflowNotification(TestCase):
  497. def setUp(self):
  498. self.project = self.create_project()
  499. self.user = self.create_user()
  500. self.sentry_app = self.create_sentry_app(
  501. organization=self.project.organization,
  502. events=["issue.resolved", "issue.ignored", "issue.assigned"],
  503. )
  504. self.install = self.create_sentry_app_installation(
  505. organization=self.project.organization, slug=self.sentry_app.slug
  506. )
  507. self.issue = self.create_group(project=self.project)
  508. def test_sends_resolved_webhook(self, safe_urlopen):
  509. workflow_notification(self.install.id, self.issue.id, "resolved", self.user.id)
  510. ((_, kwargs),) = safe_urlopen.call_args_list
  511. assert kwargs["url"] == self.sentry_app.webhook_url
  512. assert kwargs["headers"]["Sentry-Hook-Resource"] == "issue"
  513. data = json.loads(kwargs["data"])
  514. assert data["action"] == "resolved"
  515. assert data["data"]["issue"]["id"] == str(self.issue.id)
  516. def test_sends_resolved_webhook_as_Sentry_without_user(self, safe_urlopen):
  517. workflow_notification(self.install.id, self.issue.id, "resolved", None)
  518. ((_, kwargs),) = safe_urlopen.call_args_list
  519. data = json.loads(kwargs["data"])
  520. assert data["actor"]["type"] == "application"
  521. assert data["actor"]["id"] == "sentry"
  522. assert data["actor"]["name"] == "Sentry"
  523. def test_does_not_send_if_no_service_hook_exists(self, safe_urlopen):
  524. sentry_app = self.create_sentry_app(
  525. name="Another App", organization=self.project.organization, events=[]
  526. )
  527. install = self.create_sentry_app_installation(
  528. organization=self.project.organization, slug=sentry_app.slug
  529. )
  530. workflow_notification(install.id, self.issue.id, "assigned", self.user.id)
  531. assert not safe_urlopen.called
  532. def test_does_not_send_if_event_not_in_app_events(self, safe_urlopen):
  533. sentry_app = self.create_sentry_app(
  534. name="Another App",
  535. organization=self.project.organization,
  536. events=["issue.resolved", "issue.ignored"],
  537. )
  538. install = self.create_sentry_app_installation(
  539. organization=self.project.organization, slug=sentry_app.slug
  540. )
  541. workflow_notification(install.id, self.issue.id, "assigned", self.user.id)
  542. assert not safe_urlopen.called
  543. class TestWebhookRequests(TestCase):
  544. def setUp(self):
  545. self.organization = self.create_organization(owner=self.user, id=1)
  546. self.sentry_app = self.create_sentry_app(
  547. name="Test App",
  548. organization=self.organization,
  549. events=["issue.resolved", "issue.ignored", "issue.assigned"],
  550. webhook_url="https://example.com",
  551. )
  552. self.sentry_app.update(status=SentryAppStatus.PUBLISHED)
  553. self.install = self.create_sentry_app_installation(
  554. organization=self.organization, slug=self.sentry_app.slug
  555. )
  556. self.issue = self.create_group(project=self.project)
  557. self.buffer = SentryAppWebhookRequestsBuffer(self.sentry_app)
  558. self.integration_buffer = IntegrationRequestBuffer(
  559. get_redis_key(self.sentry_app, self.organization.id)
  560. )
  561. @patch(
  562. "sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockFailureResponseInstance
  563. )
  564. def test_saves_error_if_webhook_request_fails(self, safe_urlopen):
  565. data = {"issue": serialize(self.issue)}
  566. with pytest.raises(ClientError):
  567. send_webhooks(
  568. installation=self.install, event="issue.assigned", data=data, actor=self.user
  569. )
  570. requests = self.buffer.get_requests()
  571. first_request = requests[0]
  572. assert safe_urlopen.called
  573. assert len(requests) == 1
  574. assert first_request["response_code"] == 400
  575. assert first_request["event_type"] == "issue.assigned"
  576. assert first_request["organization_id"] == self.install.organization_id
  577. assert self.integration_buffer._get_all_from_buffer() == []
  578. assert self.integration_buffer.is_integration_broken() is False
  579. @patch(
  580. "sentry.utils.sentry_apps.webhooks.safe_urlopen",
  581. return_value=MockFailureHTMLContentResponseInstance,
  582. )
  583. def test_saves_error_if_webhook_request_with_html_content_fails(self, safe_urlopen):
  584. data = {"issue": serialize(self.issue)}
  585. with pytest.raises(ClientError):
  586. send_webhooks(
  587. installation=self.install, event="issue.assigned", data=data, actor=self.user
  588. )
  589. requests = self.buffer.get_requests()
  590. first_request = requests[0]
  591. assert safe_urlopen.called
  592. assert len(requests) == 1
  593. assert first_request["response_code"] == 400
  594. assert first_request["event_type"] == "issue.assigned"
  595. assert first_request["organization_id"] == self.install.organization_id
  596. assert first_request["response_body"] == html_content
  597. assert self.integration_buffer._get_all_from_buffer() == []
  598. assert self.integration_buffer.is_integration_broken() is False
  599. @patch(
  600. "sentry.utils.sentry_apps.webhooks.safe_urlopen",
  601. return_value=MockFailureJSONContentResponseInstance,
  602. )
  603. def test_saves_error_if_webhook_request_with_json_content_fails(self, safe_urlopen):
  604. data = {"issue": serialize(self.issue)}
  605. with pytest.raises(ClientError):
  606. send_webhooks(
  607. installation=self.install, event="issue.assigned", data=data, actor=self.user
  608. )
  609. requests = self.buffer.get_requests()
  610. first_request = requests[0]
  611. assert safe_urlopen.called
  612. assert len(requests) == 1
  613. assert first_request["response_code"] == 400
  614. assert first_request["event_type"] == "issue.assigned"
  615. assert first_request["organization_id"] == self.install.organization_id
  616. assert json.loads(first_request["response_body"]) == json_content
  617. assert self.integration_buffer._get_all_from_buffer() == []
  618. assert self.integration_buffer.is_integration_broken() is False
  619. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance)
  620. def test_saves_request_if_webhook_request_succeeds(self, safe_urlopen):
  621. data = {"issue": serialize(self.issue)}
  622. send_webhooks(installation=self.install, event="issue.assigned", data=data, actor=self.user)
  623. requests = self.buffer.get_requests()
  624. first_request = requests[0]
  625. assert safe_urlopen.called
  626. assert len(requests) == 1
  627. assert first_request["response_code"] == 200
  628. assert first_request["event_type"] == "issue.assigned"
  629. assert first_request["organization_id"] == self.install.organization_id
  630. assert self.integration_buffer._get_all_from_buffer() == []
  631. assert self.integration_buffer.is_integration_broken() is False
  632. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", side_effect=Timeout)
  633. def test_saves_error_for_request_timeout(self, safe_urlopen):
  634. data = {"issue": serialize(self.issue)}
  635. # we don't log errors for unpublished and internal apps
  636. with pytest.raises(Timeout):
  637. send_webhooks(
  638. installation=self.install, event="issue.assigned", data=data, actor=self.user
  639. )
  640. requests = self.buffer.get_requests()
  641. first_request = requests[0]
  642. assert safe_urlopen.called
  643. assert len(requests) == 1
  644. assert first_request["response_code"] == 0
  645. assert first_request["event_type"] == "issue.assigned"
  646. assert first_request["organization_id"] == self.install.organization_id
  647. assert self.integration_buffer._get_all_from_buffer() == []
  648. assert self.integration_buffer.is_integration_broken() is False
  649. @patch(
  650. "sentry.utils.sentry_apps.webhooks.safe_urlopen",
  651. return_value=MockResponseWithHeadersInstance,
  652. )
  653. def test_saves_error_event_id_if_in_header(self, safe_urlopen):
  654. data = {"issue": serialize(self.issue)}
  655. with pytest.raises(ClientError):
  656. send_webhooks(
  657. installation=self.install, event="issue.assigned", data=data, actor=self.user
  658. )
  659. requests = self.buffer.get_requests()
  660. first_request = requests[0]
  661. assert safe_urlopen.called
  662. assert len(requests) == 1
  663. assert first_request["response_code"] == 400
  664. assert first_request["event_type"] == "issue.assigned"
  665. assert first_request["organization_id"] == self.install.organization_id
  666. assert first_request["error_id"] == "d5111da2c28645c5889d072017e3445d"
  667. assert first_request["project_id"] == "1"
  668. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", side_effect=Timeout)
  669. def test_does_not_raise_error_if_unpublished(self, safe_urlopen):
  670. """
  671. Tests that buffer records when unpublished app has a timeout but error is not raised
  672. """
  673. self.sentry_app.update(status=SentryAppStatus.INTERNAL)
  674. events = self.sentry_app.events
  675. data = {"issue": serialize(self.issue)}
  676. # we don't raise errors for unpublished and internal apps
  677. send_webhooks(installation=self.install, event="issue.assigned", data=data, actor=self.user)
  678. requests = self.buffer.get_requests()
  679. assert safe_urlopen.called
  680. assert len(requests) == 1
  681. assert (self.integration_buffer._get_all_from_buffer()[0]["timeout_count"]) == "1"
  682. assert self.integration_buffer.is_integration_broken() is False
  683. self.sentry_app.refresh_from_db() # reload to get updated events
  684. assert self.sentry_app.events == events # check that events are the same / app is enabled
  685. @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", side_effect=Timeout)
  686. @override_settings(BROKEN_TIMEOUT_THRESHOLD=3)
  687. def test_timeout_disable(self, safe_urlopen):
  688. """
  689. Test that the integration is disabled after BROKEN_TIMEOUT_THRESHOLD number of timeouts
  690. """
  691. self.sentry_app.update(status=SentryAppStatus.INTERNAL)
  692. data = {"issue": serialize(self.issue)}
  693. # we don't raise errors for unpublished and internal apps
  694. for i in range(3):
  695. send_webhooks(
  696. installation=self.install, event="issue.assigned", data=data, actor=self.user
  697. )
  698. assert safe_urlopen.called
  699. assert [len(item) == 0 for item in self.integration_buffer._get_broken_range_from_buffer()]
  700. assert len(self.integration_buffer._get_all_from_buffer()) == 0
  701. self.sentry_app.refresh_from_db() # reload to get updated events
  702. assert len(self.sentry_app.events) == 0 # check that events are empty / app is disabled
  703. @patch(
  704. "sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockFailureResponseInstance
  705. )
  706. def test_slow_should_disable(self, safe_urlopen):
  707. """
  708. Tests that the integration is broken after 7 days of errors and disabled
  709. Slow shut off
  710. """
  711. self.sentry_app.update(status=SentryAppStatus.INTERNAL)
  712. data = {"issue": serialize(self.issue)}
  713. now = datetime.now() + timedelta(hours=1)
  714. for i in reversed(range(7)):
  715. with freeze_time(now - timedelta(days=i)):
  716. send_webhooks(
  717. installation=self.install, event="issue.assigned", data=data, actor=self.user
  718. )
  719. assert safe_urlopen.called
  720. assert [len(item) == 0 for item in self.integration_buffer._get_broken_range_from_buffer()]
  721. self.sentry_app.refresh_from_db() # reload to get updated events
  722. assert len(self.sentry_app.events) == 0 # check that events are empty / app is disabled
  723. assert len(self.integration_buffer._get_all_from_buffer()) == 0
  724. assert AuditLogEntry.objects.filter(
  725. event=audit_log.get_event_id("INTERNAL_INTEGRATION_DISABLED"),
  726. organization_id=self.organization.id,
  727. ).exists()
  728. def test_notify_disabled_email(self):
  729. with self.tasks():
  730. notify_disable(
  731. self.organization,
  732. self.sentry_app.name,
  733. get_redis_key(self.sentry_app, self.organization.id),
  734. self.sentry_app.slug,
  735. self.sentry_app.webhook_url,
  736. )
  737. assert len(mail.outbox) == 1
  738. msg = mail.outbox[0]
  739. assert msg.subject == f"Action required: Fix your {self.sentry_app.name} integration"
  740. assert (
  741. self.organization.absolute_url(
  742. f"/settings/{self.organization.slug}/developer-settings/{self.sentry_app.slug}"
  743. )
  744. in msg.body
  745. )
  746. assert (
  747. self.organization.absolute_url(
  748. f"/settings/{self.organization.slug}/developer-settings/{self.sentry_app.slug}/?referrer=disabled-sentry-app"
  749. )
  750. in msg.body
  751. )
  752. assert (
  753. self.organization.absolute_url(
  754. f"/settings/{self.organization.slug}/developer-settings/{self.sentry_app.slug}/dashboard"
  755. )
  756. in msg.body
  757. )
  758. assert (
  759. self.organization.absolute_url(
  760. f"/settings/{self.organization.slug}/developer-settings/{self.sentry_app.slug}/dashboard/?referrer=disabled-sentry-app/"
  761. )
  762. in msg.body
  763. )
  764. assert (self.sentry_app.webhook_url) in msg.body