# What this PR does Semi-related to https://github.com/grafana/oncall-private/issues/2131 Addresses occasional task failures for `apps.slack.tasks.update_slack_user_group_for_schedules` when trying to update a Slack user group for a non-paid Slack account. [Slack's documentation](https://slack.com/help/articles/212906697-Create-a-user-group) mentions this is a paid only feature, hence the error ([logs](https://ops.grafana-ops.net/goto/-AWfsrrIR?orgId=1) from an actual task): ``` 2024-08-08 16:20:36,613 source=engine:celery worker=ForkPoolWorker-16 task_id=6bdaae94-1552-4b6d-93e2-e2fa0bae57b1 task_name=apps.slack.tasks.update_slack_user_group_for_schedules name=apps.slack.models.slack_usergroup level=WARNING Slack usergroup S06LW5GJ88Z update failed: Slack API error! Response: {'ok': False, 'error': 'paid_teams_only'} ``` Updated our docs on our Slack integration to emphasize that this feature _only_ works for paid Slack accounts ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes.
266 lines
11 KiB
Python
266 lines
11 KiB
Python
import json
|
|
from contextlib import suppress
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from django.utils import timezone
|
|
from slack_sdk.web import SlackResponse
|
|
|
|
from apps.slack.client import SlackClient, server_error_retry_handler
|
|
from apps.slack.errors import (
|
|
SlackAPICannotDMBotError,
|
|
SlackAPICantUpdateMessageError,
|
|
SlackAPIChannelArchivedError,
|
|
SlackAPIChannelInactiveError,
|
|
SlackAPIChannelNotFoundError,
|
|
SlackAPIError,
|
|
SlackAPIFetchMembersFailedError,
|
|
SlackAPIInvalidAuthError,
|
|
SlackAPIMessageNotFoundError,
|
|
SlackAPIMethodNotSupportedForChannelTypeError,
|
|
SlackAPIPermissionDeniedError,
|
|
SlackAPIPlanUpgradeRequiredError,
|
|
SlackAPIRatelimitError,
|
|
SlackAPIRestrictedActionError,
|
|
SlackAPIServerError,
|
|
SlackAPITokenError,
|
|
SlackAPIUsergroupNotFoundError,
|
|
SlackAPIUsergroupPaidTeamOnlyError,
|
|
SlackAPIUserNotFoundError,
|
|
SlackAPIViewNotFoundError,
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@patch(
|
|
"slack_sdk.web.base_client.BaseClient._perform_urllib_http_request_internal",
|
|
return_value={"status": 200, "body": '{"ok": true}', "headers": {}},
|
|
)
|
|
def test_slack_client_ok(mock_request, monkeypatch, make_organization_with_slack_team_identity):
|
|
monkeypatch.undo() # undo engine.conftest.mock_slack_api_call
|
|
|
|
_, slack_team_identity = make_organization_with_slack_team_identity()
|
|
client = SlackClient(slack_team_identity)
|
|
client.api_call("auth.test")
|
|
|
|
mock_request.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize("status", [500, 503, 504])
|
|
@patch.object(
|
|
server_error_retry_handler.interval_calculator,
|
|
"calculate_sleep_duration",
|
|
return_value=0, # speed up the retries
|
|
)
|
|
@pytest.mark.django_db
|
|
def test_slack_client_unexpected_response(_, monkeypatch, status, make_organization_with_slack_team_identity):
|
|
monkeypatch.undo() # undo engine.conftest.mock_slack_api_call
|
|
|
|
_, slack_team_identity = make_organization_with_slack_team_identity()
|
|
client = SlackClient(slack_team_identity)
|
|
|
|
return_value = {"status": status, "body": "non-json", "headers": {}}
|
|
with patch(
|
|
"slack_sdk.web.base_client.BaseClient._perform_urllib_http_request_internal", return_value=return_value
|
|
) as mock_request:
|
|
with pytest.raises(SlackAPIServerError) as exc_info:
|
|
client.api_call("auth.test")
|
|
assert type(exc_info.value.response) is dict
|
|
|
|
assert mock_request.call_count == server_error_retry_handler.max_retry_count + 1
|
|
|
|
|
|
@pytest.mark.parametrize("error", ["internal_error", "fatal_error"])
|
|
@patch.object(
|
|
server_error_retry_handler.interval_calculator,
|
|
"calculate_sleep_duration",
|
|
return_value=0, # speed up the retries
|
|
)
|
|
@pytest.mark.django_db
|
|
def test_slack_client_slack_server_error(_, monkeypatch, error, make_organization_with_slack_team_identity):
|
|
monkeypatch.undo() # undo engine.conftest.mock_slack_api_call
|
|
|
|
_, slack_team_identity = make_organization_with_slack_team_identity()
|
|
client = SlackClient(slack_team_identity)
|
|
|
|
return_value = {"status": 200, "body": json.dumps({"ok": False, "error": error}), "headers": {}}
|
|
with patch(
|
|
"slack_sdk.web.base_client.BaseClient._perform_urllib_http_request_internal", return_value=return_value
|
|
) as mock_request:
|
|
with pytest.raises(SlackAPIServerError) as exc_info:
|
|
client.api_call("auth.test")
|
|
assert type(exc_info.value.response) is dict
|
|
|
|
assert mock_request.call_count == server_error_retry_handler.max_retry_count + 1
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@patch(
|
|
"slack_sdk.web.base_client.BaseClient._perform_urllib_http_request_internal",
|
|
return_value={"status": 200, "body": '{"ok": false, "error": "random_error_123"}', "headers": {}},
|
|
)
|
|
def test_slack_client_generic_error(mock_request, monkeypatch, make_organization_with_slack_team_identity):
|
|
monkeypatch.undo() # undo engine.conftest.mock_slack_api_call
|
|
|
|
_, slack_team_identity = make_organization_with_slack_team_identity()
|
|
client = SlackClient(slack_team_identity)
|
|
|
|
with pytest.raises(SlackAPIError) as exc_info:
|
|
client.api_call("auth.test")
|
|
assert type(exc_info.value) is SlackAPIError
|
|
assert type(exc_info.value.response) is SlackResponse
|
|
|
|
mock_request.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error,error_class",
|
|
[
|
|
("account_inactive", SlackAPITokenError),
|
|
("cannot_dm_bot", SlackAPICannotDMBotError),
|
|
("cant_update_message", SlackAPICantUpdateMessageError),
|
|
("channel_not_found", SlackAPIChannelNotFoundError),
|
|
("fatal_error", SlackAPIServerError),
|
|
("fetch_members_failed", SlackAPIFetchMembersFailedError),
|
|
("internal_error", SlackAPIServerError),
|
|
("invalid_auth", SlackAPIInvalidAuthError),
|
|
("is_archived", SlackAPIChannelArchivedError),
|
|
("is_inactive", SlackAPIChannelInactiveError),
|
|
("message_limit_exceeded", SlackAPIRatelimitError),
|
|
("message_not_found", SlackAPIMessageNotFoundError),
|
|
("method_not_supported_for_channel_type", SlackAPIMethodNotSupportedForChannelTypeError),
|
|
("no_such_subteam", SlackAPIUsergroupNotFoundError),
|
|
("paid_team_only", SlackAPIUsergroupPaidTeamOnlyError),
|
|
("not_found", SlackAPIViewNotFoundError),
|
|
("permission_denied", SlackAPIPermissionDeniedError),
|
|
("plan_upgrade_required", SlackAPIPlanUpgradeRequiredError),
|
|
("rate_limited", SlackAPIRatelimitError),
|
|
("ratelimited", SlackAPIRatelimitError),
|
|
("restricted_action", SlackAPIRestrictedActionError),
|
|
("token_revoked", SlackAPITokenError),
|
|
("user_not_found", SlackAPIUserNotFoundError),
|
|
],
|
|
)
|
|
@patch.object(
|
|
server_error_retry_handler.interval_calculator,
|
|
"calculate_sleep_duration",
|
|
return_value=0, # speed up the retries if any
|
|
)
|
|
@pytest.mark.django_db
|
|
def test_slack_client_specific_error(_, error, error_class, monkeypatch, make_organization_with_slack_team_identity):
|
|
monkeypatch.undo() # undo engine.conftest.mock_slack_api_call
|
|
|
|
_, slack_team_identity = make_organization_with_slack_team_identity()
|
|
client = SlackClient(slack_team_identity)
|
|
|
|
with patch(
|
|
"slack_sdk.web.base_client.BaseClient._perform_urllib_http_request_internal",
|
|
return_value={"status": 200, "body": json.dumps({"ok": False, "error": error}), "headers": {}},
|
|
):
|
|
with pytest.raises(SlackAPIError) as exc_info:
|
|
client.api_call("auth.test")
|
|
assert type(exc_info.value) is error_class
|
|
assert type(exc_info.value.response) is SlackResponse
|
|
|
|
|
|
@pytest.mark.parametrize("error", ["ratelimited", "rate_limited", "message_limit_exceeded"])
|
|
@pytest.mark.django_db
|
|
def test_slack_client_ratelimit(monkeypatch, error, make_organization_with_slack_team_identity):
|
|
monkeypatch.undo() # undo engine.conftest.mock_slack_api_call
|
|
|
|
_, slack_team_identity = make_organization_with_slack_team_identity()
|
|
client = SlackClient(slack_team_identity)
|
|
|
|
return_value = {"status": 429, "body": json.dumps({"ok": False, "error": error}), "headers": {"Retry-After": "1"}}
|
|
with patch(
|
|
"slack_sdk.web.base_client.BaseClient._perform_urllib_http_request_internal", return_value=return_value
|
|
) as mock_request:
|
|
with pytest.raises(SlackAPIRatelimitError):
|
|
client.api_call("auth.test")
|
|
|
|
# no slack built-in retries by default
|
|
assert len(mock_request.mock_calls) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("error", ["ratelimited", "rate_limited", "message_limit_exceeded"])
|
|
@pytest.mark.django_db
|
|
def test_slack_client_ratelimit_enable_retry(monkeypatch, error, make_organization_with_slack_team_identity):
|
|
monkeypatch.undo() # undo engine.conftest.mock_slack_api_call
|
|
|
|
_, slack_team_identity = make_organization_with_slack_team_identity()
|
|
client = SlackClient(slack_team_identity, enable_ratelimit_retry=True)
|
|
|
|
return_value = {"status": 429, "body": json.dumps({"ok": False, "error": error}), "headers": {"Retry-After": "1"}}
|
|
with patch(
|
|
"slack_sdk.web.base_client.BaseClient._perform_urllib_http_request_internal", return_value=return_value
|
|
) as mock_request:
|
|
with pytest.raises(SlackAPIRatelimitError) as exc_info:
|
|
client.api_call("auth.test")
|
|
|
|
assert len(mock_request.mock_calls) == 2
|
|
assert exc_info.value.retry_after == 1
|
|
|
|
|
|
@pytest.mark.parametrize("error", ["account_inactive", "token_revoked"])
|
|
@pytest.mark.django_db
|
|
def test_slack_client_mark_token_revoked(error, monkeypatch, make_organization_with_slack_team_identity):
|
|
monkeypatch.undo() # undo engine.conftest.mock_slack_api_call
|
|
|
|
_, slack_team_identity = make_organization_with_slack_team_identity()
|
|
client = SlackClient(slack_team_identity)
|
|
assert slack_team_identity.detected_token_revoked is None
|
|
|
|
with patch(
|
|
"slack_sdk.web.base_client.BaseClient._perform_urllib_http_request_internal",
|
|
return_value={"status": 200, "body": json.dumps({"ok": False, "error": error}), "headers": {}},
|
|
) as mock_request:
|
|
with pytest.raises(SlackAPITokenError):
|
|
client.api_call("auth.test")
|
|
|
|
mock_request.assert_called_once()
|
|
slack_team_identity.refresh_from_db()
|
|
assert slack_team_identity.detected_token_revoked is not None
|
|
|
|
|
|
@pytest.mark.parametrize("error", ["account_inactive", "token_revoked"])
|
|
@pytest.mark.django_db
|
|
def test_slack_client_cant_unmark_token_revoked(error, monkeypatch, make_organization_with_slack_team_identity):
|
|
monkeypatch.undo() # undo engine.conftest.mock_slack_api_call
|
|
|
|
now = timezone.now()
|
|
_, slack_team_identity = make_organization_with_slack_team_identity(detected_token_revoked=now)
|
|
client = SlackClient(slack_team_identity)
|
|
assert slack_team_identity.detected_token_revoked == now
|
|
|
|
with patch(
|
|
"slack_sdk.web.base_client.BaseClient._perform_urllib_http_request_internal",
|
|
return_value={"status": 200, "body": json.dumps({"ok": False, "error": error}), "headers": {}},
|
|
) as mock_request:
|
|
with pytest.raises(SlackAPITokenError):
|
|
client.api_call("auth.test")
|
|
|
|
mock_request.assert_called_once()
|
|
slack_team_identity.refresh_from_db()
|
|
assert slack_team_identity.detected_token_revoked == now
|
|
|
|
|
|
@pytest.mark.parametrize("body", [{"ok": False, "error": "ratelimited"}, {"ok": True}])
|
|
@pytest.mark.django_db
|
|
def test_slack_client_unmark_token_revoked(body, monkeypatch, make_organization_with_slack_team_identity):
|
|
monkeypatch.undo() # undo engine.conftest.mock_slack_api_call
|
|
|
|
now = timezone.now()
|
|
_, slack_team_identity = make_organization_with_slack_team_identity(detected_token_revoked=now)
|
|
client = SlackClient(slack_team_identity)
|
|
assert slack_team_identity.detected_token_revoked == now
|
|
|
|
with patch(
|
|
"slack_sdk.web.base_client.BaseClient._perform_urllib_http_request_internal",
|
|
return_value={"status": 200, "body": json.dumps(body), "headers": {}},
|
|
) as mock_request:
|
|
with suppress(SlackAPIError):
|
|
client.api_call("auth.test")
|
|
|
|
mock_request.assert_called_once()
|
|
slack_team_identity.refresh_from_db()
|
|
assert slack_team_identity.detected_token_revoked is None
|