Files
2024-02-15 17:01:50 +00:00

1146 lines
45 KiB
Python

from __future__ import annotations
from functools import cached_property
from typing import Any
from unittest.mock import patch
import pytest
import responses
from django.test import override_settings
from rest_framework import serializers
from rest_framework.exceptions import ErrorDetail
from sentry.auth.access import from_user
from sentry.incidents.logic import (
DEFAULT_ALERT_RULE_RESOLUTION,
DEFAULT_CMP_ALERT_RULE_RESOLUTION,
ChannelLookupTimeoutError,
create_alert_rule_trigger,
)
from sentry.incidents.models import AlertRule, AlertRuleThresholdType, AlertRuleTriggerAction
from sentry.incidents.serializers import (
ACTION_TARGET_TYPE_TO_STRING,
QUERY_TYPE_VALID_DATASETS,
STRING_TO_ACTION_TARGET_TYPE,
STRING_TO_ACTION_TYPE,
AlertRuleSerializer,
AlertRuleTriggerActionSerializer,
AlertRuleTriggerSerializer,
)
from sentry.models.actor import ACTOR_TYPES, get_actor_for_user
from sentry.models.environment import Environment
from sentry.models.user import User
from sentry.services.hybrid_cloud.app import app_service
from sentry.services.hybrid_cloud.integration import integration_service
from sentry.services.hybrid_cloud.integration.serial import serialize_integration
from sentry.shared_integrations.exceptions import ApiError
from sentry.silo import SiloMode
from sentry.snuba.dataset import Dataset
from sentry.snuba.models import SnubaQuery, SnubaQueryEventType
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import assume_test_silo_mode, region_silo_test
from sentry.testutils.skips import requires_snuba
from sentry.utils import json
pytestmark = [pytest.mark.sentry_metrics, requires_snuba]
class TestAlertRuleSerializerBase(TestCase):
def setUp(self):
self.integration, _ = self.create_provider_integration_for(
self.organization,
self.user,
external_id="1",
provider="slack",
metadata={"access_token": "xoxp-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx"},
)
@region_silo_test
class TestAlertRuleSerializer(TestAlertRuleSerializerBase):
@cached_property
def valid_params(self):
return {
"name": "hello",
"owner": self.user.id,
"time_window": 10,
"dataset": Dataset.Events.value,
"query": "level:error",
"threshold_type": 0,
"resolve_threshold": 100,
"aggregate": "count()",
"threshold_period": 1,
"projects": [self.project.slug],
"triggers": [
{
"label": "critical",
"alertThreshold": 200,
"actions": [
{"type": "email", "targetType": "team", "targetIdentifier": self.team.id}
],
},
{
"label": "warning",
"alertThreshold": 150,
"actions": [
{"type": "email", "targetType": "team", "targetIdentifier": self.team.id},
{"type": "email", "targetType": "user", "targetIdentifier": self.user.id},
],
},
],
"event_types": [SnubaQueryEventType.EventType.DEFAULT.name.lower()],
}
@cached_property
def valid_transaction_params(self):
params = self.valid_params.copy()
params["dataset"] = Dataset.Transactions.value
params["event_types"] = [SnubaQueryEventType.EventType.TRANSACTION.name.lower()]
return params
@cached_property
def access(self):
return from_user(self.user, self.organization)
@cached_property
def context(self):
return {
"organization": self.organization,
"access": self.access,
"user": self.user,
"installations": app_service.get_installed_for_organization(
organization_id=self.organization.id
),
"integrations": integration_service.get_integrations(
organization_id=self.organization.id
),
}
def run_fail_validation_test(self, params, errors):
base_params = self.valid_params.copy()
base_params.update(params)
serializer = AlertRuleSerializer(context=self.context, data=base_params)
assert not serializer.is_valid()
assert serializer.errors == errors
def test_validation_no_params(self):
serializer = AlertRuleSerializer(context=self.context, data={})
assert not serializer.is_valid()
field_is_required = ["This field is required."]
assert serializer.errors == {
"name": field_is_required,
"timeWindow": field_is_required,
"query": field_is_required,
"triggers": field_is_required,
"aggregate": field_is_required,
"thresholdType": field_is_required,
}
def test_environment_non_list(self):
base_params = self.valid_params.copy()
env_1 = Environment.objects.create(organization_id=self.organization.id, name="test_env_1")
base_params.update({"environment": env_1.name})
serializer = AlertRuleSerializer(context=self.context, data=base_params)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
assert alert_rule.snuba_query.environment == env_1
def test_time_window(self):
self.run_fail_validation_test(
{"timeWindow": "a"}, {"timeWindow": ["A valid integer is required."]}
)
self.run_fail_validation_test(
{"timeWindow": 1441},
{"timeWindow": ["Ensure this value is less than or equal to 1440."]},
)
self.run_fail_validation_test(
{"timeWindow": 0}, {"timeWindow": ["Ensure this value is greater than or equal to 1."]}
)
def test_dataset(self):
invalid_values = ["Invalid dataset, valid values are %s" % [item.value for item in Dataset]]
self.run_fail_validation_test({"dataset": "events_wrong"}, {"dataset": invalid_values})
valid_datasets_for_type = sorted(
dataset.name.lower()
for dataset in QUERY_TYPE_VALID_DATASETS[SnubaQuery.Type.PERFORMANCE]
)
self.run_fail_validation_test(
{
"queryType": SnubaQuery.Type.PERFORMANCE.value,
"dataset": Dataset.Events.value,
},
{
"nonFieldErrors": [
f"Invalid dataset for this query type. Valid datasets are {valid_datasets_for_type}"
]
},
)
valid_datasets_for_type = sorted(
dataset.name.lower() for dataset in QUERY_TYPE_VALID_DATASETS[SnubaQuery.Type.ERROR]
)
self.run_fail_validation_test(
{
"queryType": SnubaQuery.Type.ERROR.value,
"dataset": Dataset.Metrics.value,
},
{
"nonFieldErrors": [
f"Invalid dataset for this query type. Valid datasets are {valid_datasets_for_type}"
]
},
)
self.run_fail_validation_test(
{
"queryType": SnubaQuery.Type.PERFORMANCE.value,
"dataset": Dataset.PerformanceMetrics.value,
},
{
"nonFieldErrors": [
"This project does not have access to the `generic_metrics` dataset"
]
},
)
with self.feature("organizations:mep-rollout-flag"):
base_params = self.valid_params.copy()
base_params["queryType"] = SnubaQuery.Type.PERFORMANCE.value
base_params["eventTypes"] = [SnubaQueryEventType.EventType.TRANSACTION.name.lower()]
base_params["dataset"] = Dataset.PerformanceMetrics.value
base_params["query"] = ""
serializer = AlertRuleSerializer(context=self.context, data=base_params)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
assert alert_rule.snuba_query.type == SnubaQuery.Type.PERFORMANCE.value
assert alert_rule.snuba_query.dataset == Dataset.PerformanceMetrics.value
def test_aggregate(self):
self.run_fail_validation_test(
{"aggregate": "what()"},
{"aggregate": ["Invalid Metric: what() is not a valid function"]},
)
self.run_fail_validation_test(
{"aggregate": "what"},
{"nonFieldErrors": ["Invalid Metric: Please pass a valid function for aggregation"]},
)
self.run_fail_validation_test(
{"aggregate": "123"},
{"nonFieldErrors": ["Invalid Metric: Please pass a valid function for aggregation"]},
)
self.run_fail_validation_test(
{"aggregate": "count_unique(123, hello)"},
{
"aggregate": [
"Invalid Metric: count_unique(123, hello): expected at most 1 argument(s)"
]
},
)
self.run_fail_validation_test(
{"aggregate": "max()"}, {"aggregate": ["Invalid Metric: max(): expected 1 argument(s)"]}
)
aggregate = "count_unique(tags[sentry:user])"
base_params = self.valid_params.copy()
base_params["aggregate"] = aggregate
serializer = AlertRuleSerializer(context=self.context, data=base_params)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
assert alert_rule.snuba_query.aggregate == aggregate
aggregate = "sum(measurements.fp)"
base_params = self.valid_transaction_params.copy()
base_params["name"] = "measurement test"
base_params["aggregate"] = aggregate
serializer = AlertRuleSerializer(context=self.context, data=base_params)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
assert alert_rule.snuba_query.aggregate == aggregate
def test_alert_rule_resolved_invalid(self):
self.run_fail_validation_test(
{"resolve_threshold": 500},
{"nonFieldErrors": ["critical alert threshold must be above resolution threshold"]},
)
base_params = self.valid_params.copy()
base_params["resolve_threshold"] = 0.5
base_params["triggers"].pop()
base_params["triggers"][0]["alertThreshold"] = 0.3
serializer = AlertRuleSerializer(context=self.context, data=base_params)
assert not serializer.is_valid()
assert serializer.errors == {
"nonFieldErrors": ["critical alert threshold must be above resolution threshold"]
}
def test_transaction_dataset(self):
serializer = AlertRuleSerializer(context=self.context, data=self.valid_transaction_params)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
assert alert_rule.snuba_query.dataset == Dataset.Transactions.value
assert alert_rule.snuba_query.aggregate == "count()"
def test_decimal(self):
params = self.valid_transaction_params.copy()
alert_threshold = 0.8
resolve_threshold = 0.7
params["triggers"][0]["alertThreshold"] = alert_threshold
params["resolve_threshold"] = resolve_threshold
# Drop off the warning trigger
params["triggers"].pop()
serializer = AlertRuleSerializer(context=self.context, data=params)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
trigger = alert_rule.alertruletrigger_set.filter(label="critical").get()
assert trigger.alert_threshold == alert_threshold
def test_simple_below_threshold(self):
payload: dict[str, Any] = {
"name": "hello_im_a_test",
"time_window": 10,
"query": "level:error",
"aggregate": "count()",
"threshold_period": 1,
"projects": [self.project.slug],
"resolveThreshold": None,
"thresholdType": 1,
"triggers": [
{
"label": "critical",
"alertThreshold": 98,
"actions": [
{"type": "email", "targetType": "team", "targetIdentifier": self.team.id}
],
}
],
}
serializer = AlertRuleSerializer(context=self.context, data=payload, partial=True)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["threshold_type"] == AlertRuleThresholdType.BELOW
# Now do a two trigger test:
payload["triggers"].append(
{
"label": "warning",
"alertThreshold": 99,
"actions": [
{"type": "email", "targetType": "team", "targetIdentifier": self.team.id},
{"type": "email", "targetType": "user", "targetIdentifier": self.user.id},
],
}
)
serializer = AlertRuleSerializer(context=self.context, data=payload, partial=True)
assert serializer.is_valid(), serializer.errors
def test_alert_rule_threshold_resolve_only(self):
resolve_threshold = 10
payload = {
"name": "hello_im_a_test",
"time_window": 10,
"query": "level:error",
"aggregate": "count()",
"thresholdType": 0,
"resolveThreshold": 10,
"threshold_period": 1,
"projects": [self.project.slug],
"triggers": [
{
"label": "critical",
"alertThreshold": 98,
"actions": [
{"type": "email", "targetType": "team", "targetIdentifier": self.team.id}
],
}
],
}
serializer = AlertRuleSerializer(context=self.context, data=payload, partial=True)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["threshold_type"] == AlertRuleThresholdType.ABOVE
assert serializer.validated_data["resolve_threshold"] == resolve_threshold
def test_boundary(self):
payload: dict[str, Any] = {
"name": "hello_im_a_test",
"time_window": 10,
"query": "level:error",
"aggregate": "count()",
"threshold_period": 1,
"resolveThreshold": 2,
"thresholdType": AlertRuleThresholdType.ABOVE.value,
"projects": [self.project.slug],
"triggers": [
{
"label": "critical",
"alertThreshold": 1,
"actions": [
{"type": "email", "targetType": "team", "targetIdentifier": self.team.id}
],
}
],
}
serializer = AlertRuleSerializer(context=self.context, data=payload, partial=True)
assert serializer.is_valid(), serializer.errors
# Now do a two trigger test:
payload["triggers"][0]["alertThreshold"] = 2
payload["triggers"].append(
{
"label": "warning",
"alertThreshold": 1,
"actions": [
{"type": "email", "targetType": "team", "targetIdentifier": self.team.id},
{"type": "email", "targetType": "user", "targetIdentifier": self.user.id},
],
}
)
serializer = AlertRuleSerializer(context=self.context, data=payload, partial=True)
assert serializer.is_valid(), serializer.errors
payload["thresholdType"] = AlertRuleThresholdType.BELOW.value
payload["resolveThreshold"] = 0
payload["triggers"][0]["alertThreshold"] = 1
payload["triggers"].pop()
serializer = AlertRuleSerializer(context=self.context, data=payload, partial=True)
assert serializer.is_valid(), serializer.errors
def test_boundary_off_by_one(self):
self.run_fail_validation_test(
{
"thresholdType": AlertRuleThresholdType.ABOVE.value,
"resolveThreshold": 2,
"triggers": [
{
"label": "critical",
"alertThreshold": 0,
"actions": [],
},
],
},
{
"nonFieldErrors": [
ErrorDetail(
string="critical alert threshold must be above resolution threshold",
code="invalid",
)
]
},
)
self.run_fail_validation_test(
{
"thresholdType": AlertRuleThresholdType.BELOW.value,
"resolveThreshold": 0,
"triggers": [
{
"label": "critical",
"alertThreshold": 2,
"actions": [],
},
],
},
{
"nonFieldErrors": [
ErrorDetail(
string="critical alert threshold must be below resolution threshold",
code="invalid",
)
]
},
)
def test_invalid_slack_channel(self):
# We had an error where an invalid slack channel was spitting out unclear
# error for the user, and CREATING THE RULE. So the next save (after fixing slack action)
# says "Name already in use". This test makes sure that is not happening anymore.
# We save a rule with an invalid slack, make sure we get back a useful error
# and that the rule is not created.
base_params = self.valid_params.copy()
base_params["name"] = "Aun1qu3n4m3"
base_params["triggers"][0]["actions"].append(
{
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.SLACK
).slug,
"targetType": ACTION_TARGET_TYPE_TO_STRING[
AlertRuleTriggerAction.TargetType.SPECIFIC
],
"targetIdentifier": "123",
"integration": str(self.integration.id),
}
)
with assume_test_silo_mode(SiloMode.REGION), override_settings(SILO_MODE=SiloMode.REGION):
serializer = AlertRuleSerializer(context=self.context, data=base_params)
assert serializer.is_valid()
with pytest.raises(ApiError):
serializer.save()
# Make sure the rule was not created.
assert len(list(AlertRule.objects.filter(name="Aun1qu3n4m3"))) == 0
# Make sure the action was not created.
alert_rule_trigger_actions = list(
AlertRuleTriggerAction.objects.filter(integration_id=self.integration.id)
)
assert len(alert_rule_trigger_actions) == 0
def test_valid_metric_field(self):
base_params = self.valid_params.copy()
base_params.update({"name": "Aun1qu3n4m3", "aggregate": "count_unique(user)"})
serializer = AlertRuleSerializer(context=self.context, data=base_params)
assert serializer.is_valid()
serializer.save()
assert len(list(AlertRule.objects.filter(name="Aun1qu3n4m3"))) == 1
alert_rule = AlertRule.objects.filter(name="Aun1qu3n4m3").first()
assert alert_rule.snuba_query.aggregate == "count_unique(tags[sentry:user])"
def test_invalid_metric_field(self):
self.run_fail_validation_test(
{"name": "Aun1qu3n4m3", "aggregate": "percentile(transaction.length,0.5)"},
{
"aggregate": [
"Invalid Metric: percentile(transaction.length,0.5): column argument invalid: transaction.length is not a valid column"
]
},
)
def test_unsupported_metric_field(self):
self.run_fail_validation_test(
{"name": "Aun1qu3n4m3", "aggregate": "count_unique(stack.filename)"},
{"aggregate": ["Invalid Metric: We do not currently support this field."]},
)
def test_threshold_type(self):
invalid_values = [
"Invalid threshold type, valid values are %s"
% [item.value for item in AlertRuleThresholdType]
]
self.run_fail_validation_test(
{"thresholdType": "a"}, {"thresholdType": ["A valid integer is required."]}
)
self.run_fail_validation_test({"thresholdType": 50}, {"thresholdType": invalid_values})
@patch(
"sentry.integrations.slack.utils.channel.get_channel_id_with_timeout",
return_value=("#", None, True),
)
def test_channel_timeout(self, mock_get_channel_id):
trigger = {
"label": "critical",
"alertThreshold": 200,
"actions": [
{
"type": "slack",
"targetIdentifier": "my-channel",
"targetType": "specific",
"integration": self.integration.id,
}
],
}
base_params = self.valid_params.copy()
base_params.update({"triggers": [trigger]})
serializer = AlertRuleSerializer(context=self.context, data=base_params)
# error is raised during save
assert serializer.is_valid()
with pytest.raises(ChannelLookupTimeoutError) as excinfo:
serializer.save()
assert (
str(excinfo.value)
== "Could not find channel my-channel. We have timed out trying to look for it."
)
@patch(
"sentry.integrations.slack.utils.channel.get_channel_id_with_timeout",
return_value=("#", None, True),
)
def test_invalid_team_with_channel_timeout(self, mock_get_channel_id):
other_org = self.create_organization()
new_team = self.create_team(organization=other_org)
trigger = {
"label": "critical",
"alertThreshold": 200,
"actions": [
{
"type": "slack",
"targetIdentifier": "my-channel",
"targetType": "specific",
"integration": self.integration.id,
},
{"type": "email", "targetType": "team", "targetIdentifier": new_team.id},
],
}
base_params = self.valid_params.copy()
base_params.update({"triggers": [trigger]})
serializer = AlertRuleSerializer(context=self.context, data=base_params)
# error is raised during save
assert serializer.is_valid()
with pytest.raises(serializers.ValidationError) as excinfo:
serializer.save()
assert excinfo.value.detail == {"nonFieldErrors": ["Team does not exist"]}
mock_get_channel_id.assert_called_with(
serialize_integration(self.integration), "my-channel", 10
)
def test_event_types(self):
invalid_values = [
"Invalid event_type, valid values are %s"
% [item.name.lower() for item in SnubaQueryEventType.EventType]
]
self.run_fail_validation_test({"event_types": ["a"]}, {"eventTypes": invalid_values})
self.run_fail_validation_test({"event_types": [1]}, {"eventTypes": invalid_values})
self.run_fail_validation_test(
{"event_types": ["transaction"]},
{
"nonFieldErrors": [
"Invalid event types for this dataset. Valid event types are ['default', 'error']"
]
},
)
params = self.valid_params.copy()
serializer = AlertRuleSerializer(context=self.context, data=params, partial=True)
assert serializer.is_valid()
alert_rule = serializer.save()
assert set(alert_rule.snuba_query.event_types) == {SnubaQueryEventType.EventType.DEFAULT}
params["event_types"] = [SnubaQueryEventType.EventType.ERROR.name.lower()]
serializer = AlertRuleSerializer(
context=self.context, instance=alert_rule, data=params, partial=True
)
assert serializer.is_valid()
alert_rule = serializer.save()
assert set(alert_rule.snuba_query.event_types) == {SnubaQueryEventType.EventType.ERROR}
def test_unsupported_query(self):
self.run_fail_validation_test(
{"name": "Aun1qu3n4m3", "query": "release:latest"},
{"query": ["Unsupported Query: We do not currently support the release:latest query"]},
)
def test_owner_validation(self):
self.run_fail_validation_test(
{"owner": f"meow:{self.user.id}"},
{
"owner": [
"Could not parse actor. Format should be `type:id` where type is `team` or `user`."
]
},
)
self.run_fail_validation_test(
{"owner": "user:1234567"},
{"owner": ["User does not exist"]},
)
self.run_fail_validation_test(
{"owner": "team:1234567"},
{"owner": ["Team does not exist"]},
)
other_org = self.create_organization()
other_team = self.create_team(organization=other_org)
other_user = self.create_user()
self.create_member(user=other_user, organization=other_org, teams=[other_team])
self.run_fail_validation_test(
{"owner": f"user:{other_user.id}"},
{"owner": ["User is not a member of this organization"]},
)
self.run_fail_validation_test(
{"owner": f"team:{other_team.id}"},
{"owner": ["Team is not a member of this organization"]},
)
base_params = self.valid_params.copy()
base_params.update({"owner": f"team:{self.team.id}"})
serializer = AlertRuleSerializer(context=self.context, data=base_params)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
assert alert_rule.owner == self.team.actor
assert alert_rule.owner.type == ACTOR_TYPES["team"]
base_params.update({"name": "another_test", "owner": f"user:{self.user.id}"})
serializer = AlertRuleSerializer(context=self.context, data=base_params)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
# Reload user for actor
with assume_test_silo_mode(SiloMode.CONTROL):
self.user = User.objects.get(id=self.user.id)
assert alert_rule.owner == get_actor_for_user(self.user)
assert alert_rule.owner.type == ACTOR_TYPES["user"]
def test_comparison_delta_above(self):
params = self.valid_params.copy()
params["comparison_delta"] = 60
params["resolveThreshold"] = 10
params["triggers"][0]["alertThreshold"] = 50
params["triggers"][1]["alertThreshold"] = 40
serializer = AlertRuleSerializer(context=self.context, data=params, partial=True)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
assert alert_rule.comparison_delta == 60 * 60
assert alert_rule.resolve_threshold == 110
triggers = {trigger.label: trigger for trigger in alert_rule.alertruletrigger_set.all()}
assert triggers["critical"].alert_threshold == 150
assert triggers["warning"].alert_threshold == 140
assert alert_rule.snuba_query.resolution == DEFAULT_CMP_ALERT_RULE_RESOLUTION * 60
def test_comparison_delta_below(self):
params = self.valid_params.copy()
params["threshold_type"] = AlertRuleThresholdType.BELOW.value
params["comparison_delta"] = 60
params["resolveThreshold"] = 10
params["triggers"][0]["alertThreshold"] = 50
params["triggers"][1]["alertThreshold"] = 40
serializer = AlertRuleSerializer(context=self.context, data=params, partial=True)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
assert alert_rule.comparison_delta == 60 * 60
assert alert_rule.resolve_threshold == 90
triggers = {trigger.label: trigger for trigger in alert_rule.alertruletrigger_set.all()}
assert triggers["critical"].alert_threshold == 50
assert triggers["warning"].alert_threshold == 60
assert alert_rule.snuba_query.resolution == DEFAULT_CMP_ALERT_RULE_RESOLUTION * 60
params["comparison_delta"] = None
params["resolveThreshold"] = 100
params["triggers"][0]["alertThreshold"] = 40
params["triggers"][1]["alertThreshold"] = 50
serializer = AlertRuleSerializer(
context=self.context, instance=alert_rule, data=params, partial=True
)
assert serializer.is_valid(), serializer.errors
alert_rule = serializer.save()
assert alert_rule.comparison_delta is None
assert alert_rule.snuba_query.resolution == DEFAULT_ALERT_RULE_RESOLUTION * 60
@override_settings(MAX_QUERY_SUBSCRIPTIONS_PER_ORG=1)
def test_enforce_max_subscriptions(self):
serializer = AlertRuleSerializer(context=self.context, data=self.valid_params)
assert serializer.is_valid(), serializer.errors
serializer.save()
serializer = AlertRuleSerializer(context=self.context, data=self.valid_params)
assert serializer.is_valid(), serializer.errors
with pytest.raises(serializers.ValidationError) as excinfo:
serializer.save()
assert isinstance(excinfo.value.detail, list)
assert excinfo.value.detail[0] == "You may not exceed 1 metric alerts per organization"
def test_error_issue_status(self):
params = self.valid_params.copy()
params["query"] = "status:abcd"
with self.feature("organizations:metric-alert-ignore-archived"):
serializer = AlertRuleSerializer(context=self.context, data=params, partial=True)
assert not serializer.is_valid()
assert serializer.errors == {
"nonFieldErrors": [
ErrorDetail(
string="Invalid Query or Metric: invalid status value of 'abcd'", code="invalid"
)
]
}
params = self.valid_params.copy()
params["query"] = "status:unresolved"
with self.feature("organizations:metric-alert-ignore-archived"):
serializer = AlertRuleSerializer(context=self.context, data=params, partial=True)
assert serializer.is_valid()
alert_rule = serializer.save()
assert alert_rule.snuba_query.query == "status:unresolved"
@region_silo_test
class TestAlertRuleTriggerSerializer(TestAlertRuleSerializerBase):
@cached_property
def other_project(self):
return self.create_project()
@cached_property
def alert_rule(self):
return self.create_alert_rule(projects=[self.project, self.other_project])
@cached_property
def valid_params(self):
return {
"label": "something",
"threshold_type": 0,
"resolve_threshold": 1,
"alert_threshold": 0,
"excluded_projects": [self.project.slug],
"actions": [{"type": "email", "targetType": "team", "targetIdentifier": self.team.id}],
}
@cached_property
def access(self):
return from_user(self.user, self.organization)
@cached_property
def context(self):
return {
"organization": self.organization,
"access": self.access,
"alert_rule": self.alert_rule,
}
def run_fail_validation_test(self, params, errors):
base_params = self.valid_params.copy()
base_params.update(params)
serializer = AlertRuleTriggerSerializer(context=self.context, data=base_params)
assert not serializer.is_valid()
assert serializer.errors == errors
def test_validation_no_params(self):
serializer = AlertRuleTriggerSerializer(context=self.context, data={})
assert not serializer.is_valid()
field_is_required = ["This field is required."]
assert serializer.errors == {
"label": field_is_required,
"alertThreshold": field_is_required,
}
@region_silo_test
class TestAlertRuleTriggerActionSerializer(TestAlertRuleSerializerBase):
@cached_property
def other_project(self):
return self.create_project()
@cached_property
def alert_rule(self):
return self.create_alert_rule(projects=[self.project, self.other_project])
@cached_property
def trigger(self):
return create_alert_rule_trigger(self.alert_rule, "hello", 100)
@cached_property
def valid_params(self):
return {
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.EMAIL
).slug,
"target_type": ACTION_TARGET_TYPE_TO_STRING[AlertRuleTriggerAction.TargetType.SPECIFIC],
"target_identifier": "[email protected]",
}
@cached_property
def access(self):
return from_user(self.user, self.organization)
@cached_property
def context(self):
return {
"organization": self.organization,
"access": self.access,
"user": self.user,
"alert_rule": self.alert_rule,
"trigger": self.trigger,
}
@cached_property
def sentry_app(self):
return self.create_sentry_app(
organization=self.organization,
published=True,
verify_install=False,
name="Super Awesome App",
schema={
"elements": [
self.create_alert_rule_action_schema(),
]
},
)
@cached_property
def sentry_app_installation(self):
return self.create_sentry_app_installation(
slug=self.sentry_app.slug, organization=self.organization, user=self.user
)
def run_fail_validation_test(self, params, errors):
base_params = self.valid_params.copy()
base_params.update(params)
serializer = AlertRuleTriggerActionSerializer(context=self.context, data=base_params)
assert not serializer.is_valid()
assert serializer.errors == errors
def test_validation_no_params(self):
serializer = AlertRuleTriggerActionSerializer(context=self.context, data={})
assert not serializer.is_valid()
field_is_required = ["This field is required."]
assert serializer.errors == {
"type": field_is_required,
"targetType": field_is_required,
"targetIdentifier": field_is_required,
}
def test_type(self):
invalid_values = [
"Invalid type, valid values are [%s]" % ", ".join(STRING_TO_ACTION_TYPE.keys())
]
self.run_fail_validation_test({"type": 50}, {"type": invalid_values})
def test_target_type(self):
invalid_values = [
"Invalid targetType, valid values are [%s]"
% ", ".join(STRING_TO_ACTION_TARGET_TYPE.keys())
]
self.run_fail_validation_test({"targetType": 50}, {"targetType": invalid_values})
def test_user_perms(self):
self.run_fail_validation_test(
{
"target_type": ACTION_TARGET_TYPE_TO_STRING[AlertRuleTriggerAction.TargetType.USER],
"target_identifier": "1234567",
},
{"nonFieldErrors": ["User does not belong to this organization"]},
)
other_user = self.create_user()
self.run_fail_validation_test(
{
"target_type": ACTION_TARGET_TYPE_TO_STRING[AlertRuleTriggerAction.TargetType.USER],
"target_identifier": str(other_user.id),
},
{"nonFieldErrors": ["User does not belong to this organization"]},
)
def test_discord(self):
self.run_fail_validation_test(
{
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.DISCORD
).slug,
"targetType": ACTION_TARGET_TYPE_TO_STRING[
AlertRuleTriggerAction.TargetType.SPECIFIC
],
"targetIdentifier": "123",
},
{"integration": ["Integration must be provided for discord"]},
)
def test_slack(self):
self.run_fail_validation_test(
{
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.SLACK
).slug,
"target_type": ACTION_TARGET_TYPE_TO_STRING[AlertRuleTriggerAction.TargetType.USER],
"target_identifier": "123",
},
{"targetType": ["Invalid target type for slack. Valid types are [specific]"]},
)
self.run_fail_validation_test(
{
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.SLACK
).slug,
"targetType": ACTION_TARGET_TYPE_TO_STRING[
AlertRuleTriggerAction.TargetType.SPECIFIC
],
"targetIdentifier": "123",
},
{"integration": ["Integration must be provided for slack"]},
)
base_params = self.valid_params.copy()
base_params.update(
{
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.SLACK
).slug,
"targetType": ACTION_TARGET_TYPE_TO_STRING[
AlertRuleTriggerAction.TargetType.SPECIFIC
],
"targetIdentifier": "123",
"integration": str(self.integration.id),
}
)
serializer = AlertRuleTriggerActionSerializer(context=self.context, data=base_params)
assert serializer.is_valid()
with pytest.raises(ApiError):
serializer.save()
@responses.activate
def test_valid_slack_channel_id(self):
"""
Test that when a valid Slack channel ID is provided, we look up the channel name and validate it against the targetIdentifier.
"""
base_params = self.valid_params.copy()
base_params.update(
{
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.SLACK
).slug,
"targetType": ACTION_TARGET_TYPE_TO_STRING[
AlertRuleTriggerAction.TargetType.SPECIFIC
],
"targetIdentifier": "merp",
"integration": str(self.integration.id),
}
)
context = self.context.copy()
context.update({"input_channel_id": "CSVK0921"})
responses.add(
method=responses.GET,
url="https://slack.com/api/conversations.info",
status=200,
content_type="application/json",
body=json.dumps({"ok": "true", "channel": {"name": "merp", "id": "CSVK0921"}}),
)
serializer = AlertRuleTriggerActionSerializer(context=context, data=base_params)
assert serializer.is_valid()
serializer.save()
# # Make sure the action was created.
alert_rule_trigger_actions = list(
AlertRuleTriggerAction.objects.filter(integration_id=self.integration.id)
)
assert len(alert_rule_trigger_actions) == 1
@responses.activate
def test_invalid_slack_channel_id(self):
"""
Test that an invalid Slack channel ID is detected and blocks the action from being saved.
"""
base_params = self.valid_params.copy()
base_params.update(
{
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.SLACK
).slug,
"targetType": ACTION_TARGET_TYPE_TO_STRING[
AlertRuleTriggerAction.TargetType.SPECIFIC
],
"targetIdentifier": "merp",
"integration": str(self.integration.id),
}
)
context = self.context.copy()
context.update({"input_channel_id": "M40W931"})
responses.add(
method=responses.GET,
url="https://slack.com/api/conversations.info",
status=200,
content_type="application/json",
body=json.dumps({"ok": False, "error": "channel_not_found"}),
)
serializer = AlertRuleTriggerActionSerializer(context=context, data=base_params)
assert not serializer.is_valid()
# # Make sure the action was not created.
alert_rule_trigger_actions = list(
AlertRuleTriggerAction.objects.filter(integration_id=self.integration.id)
)
assert len(alert_rule_trigger_actions) == 0
@responses.activate
def test_invalid_slack_channel_name(self):
"""
Test that an invalid Slack channel name is detected and blocks the action from being saved.
"""
base_params = self.valid_params.copy()
base_params.update(
{
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.SLACK
).slug,
"targetType": ACTION_TARGET_TYPE_TO_STRING[
AlertRuleTriggerAction.TargetType.SPECIFIC
],
"targetIdentifier": "123",
"integration": str(self.integration.id),
}
)
context = self.context.copy()
context.update({"input_channel_id": "CSVK0921"})
responses.add(
method=responses.GET,
url="https://slack.com/api/conversations.info",
status=200,
content_type="application/json",
body=json.dumps({"ok": "true", "channel": {"name": "merp", "id": "CSVK0921"}}),
)
serializer = AlertRuleTriggerActionSerializer(context=context, data=base_params)
assert not serializer.is_valid()
# # Make sure the action was not created.
alert_rule_trigger_actions = list(
AlertRuleTriggerAction.objects.filter(integration_id=self.integration.id)
)
assert len(alert_rule_trigger_actions) == 0
def test_sentry_app_action_missing_params(self):
self.run_fail_validation_test(
{
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.SENTRY_APP
).slug,
"target_type": ACTION_TARGET_TYPE_TO_STRING[
AlertRuleTriggerAction.TargetType.SENTRY_APP
],
"target_identifier": "123",
"sentry_app": self.sentry_app.id,
"sentry_app_config": {"tag": "asdfasdfads"},
},
{"sentryApp": ["Missing parameter: sentry_app_installation_uuid"]},
)
def test_create_and_update_sentry_app_action_success(self):
serializer = AlertRuleTriggerActionSerializer(
context=self.context,
data={
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.SENTRY_APP
).slug,
"target_type": ACTION_TARGET_TYPE_TO_STRING[
AlertRuleTriggerAction.TargetType.SENTRY_APP
],
"target_identifier": "1",
"sentry_app": self.sentry_app.id,
"sentry_app_config": {"channel": "#general"},
"sentry_app_installation_uuid": self.sentry_app_installation.uuid,
},
)
assert serializer.is_valid()
# Create action
serializer.save()
# # Make sure the action was created.
alert_rule_trigger_actions = list(
AlertRuleTriggerAction.objects.filter(sentry_app_id=self.sentry_app.id)
)
assert len(alert_rule_trigger_actions) == 1
# Update action
serializer = AlertRuleTriggerActionSerializer(
context=self.context,
data={
"type": AlertRuleTriggerAction.get_registered_type(
AlertRuleTriggerAction.Type.SENTRY_APP
).slug,
"target_type": ACTION_TARGET_TYPE_TO_STRING[
AlertRuleTriggerAction.TargetType.SENTRY_APP
],
"target_identifier": "1",
"sentry_app": self.sentry_app.id,
"sentry_app_config": {"channel": "#announcements"},
"sentry_app_installation_uuid": self.sentry_app_installation.uuid,
},
instance=alert_rule_trigger_actions[0],
)
assert serializer.is_valid()
# Update action
serializer.save()
alert_rule_trigger_action = AlertRuleTriggerAction.objects.get(
sentry_app_id=self.sentry_app.id
)
# Make sure the changes got applied
assert alert_rule_trigger_action.sentry_app_config == {"channel": "#announcements"}