Skip to content

Commit 12e966a

Browse files
authored
chore(slug): Prevent numeric sentry function slugs (#59669)
Prevent numeric slugs in SentryFunction model by changing `slugify` call to `sentry_slugify`. Add a test for this called `test_generated_slug_not_entirely_numeric`. Also refactor test file and fix a bug where we weren't correctly testing the passed in env variables in the test file. Explanation in comments below
1 parent d5d5665 commit 12e966a

File tree

4 files changed

+128
-63
lines changed

4 files changed

+128
-63
lines changed

src/sentry/api/endpoints/organization_sentry_function.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
from uuid import uuid4
22

3-
from django.utils.text import slugify
43
from rest_framework import serializers
54
from rest_framework.response import Response
65

76
from sentry import features
87
from sentry.api.api_publish_status import ApiPublishStatus
98
from sentry.api.base import region_silo_endpoint
109
from sentry.api.bases import OrganizationEndpoint
10+
from sentry.api.helpers.slugs import sentry_slugify
1111
from sentry.api.serializers import serialize
1212
from sentry.api.serializers.rest_framework import CamelSnakeSerializer
1313
from sentry.models.sentryfunction import SentryFunction
1414
from sentry.utils.cloudfunctions import create_function
1515

1616

1717
class EnvVariableSerializer(CamelSnakeSerializer):
18-
value = serializers.CharField(required=True)
19-
name = serializers.CharField(required=True)
18+
value = serializers.CharField(required=True, allow_blank=False, allow_null=False)
19+
name = serializers.CharField(required=True, allow_blank=False, allow_null=False)
2020

2121

2222
class SentryFunctionSerializer(CamelSnakeSerializer):
@@ -28,19 +28,20 @@ class SentryFunctionSerializer(CamelSnakeSerializer):
2828
env_variables = serializers.ListField(child=EnvVariableSerializer())
2929

3030
def validate_env_variables(self, env_variables):
31+
"""
32+
Convert env_variables from a list of dicts to a dict of key-value pairs
33+
"""
3134
output = {}
3235
for env_variable in env_variables:
33-
# double checking for blanks, but also checked on frontend
34-
if env_variable.get("name", None) and env_variable.get("value", None):
35-
output[env_variable["name"]] = env_variable["value"]
36+
output[env_variable["name"]] = env_variable["value"]
3637
return output
3738

3839

3940
@region_silo_endpoint
4041
class OrganizationSentryFunctionEndpoint(OrganizationEndpoint):
4142
publish_status = {
42-
"GET": ApiPublishStatus.UNKNOWN,
43-
"POST": ApiPublishStatus.UNKNOWN,
43+
"GET": ApiPublishStatus.PRIVATE,
44+
"POST": ApiPublishStatus.PRIVATE,
4445
}
4546

4647
# Creating a new sentry function
@@ -53,7 +54,9 @@ def post(self, request, organization):
5354
return Response(serializer.errors, status=400)
5455

5556
data = serializer.validated_data
56-
data["slug"] = slugify(data["name"])
57+
# sentry_slugify ensures the slug is not entirely numeric
58+
data["slug"] = sentry_slugify(data["name"])
59+
5760
# TODO: Make sure the slug is unique
5861
# Currently slug unique within organization
5962
# In future, may add "global_slug" so users can publish their functions

src/sentry/api/endpoints/organization_sentry_function_details.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
@region_silo_endpoint
1717
class OrganizationSentryFunctionDetailsEndpoint(OrganizationEndpoint):
1818
publish_status = {
19-
"DELETE": ApiPublishStatus.UNKNOWN,
20-
"GET": ApiPublishStatus.UNKNOWN,
21-
"PUT": ApiPublishStatus.UNKNOWN,
19+
"DELETE": ApiPublishStatus.PRIVATE,
20+
"GET": ApiPublishStatus.PRIVATE,
21+
"PUT": ApiPublishStatus.PRIVATE,
2222
}
2323

2424
def convert_args(self, request, organization_slug, function_slug, *args, **kwargs):

src/sentry/api/serializers/models/sentry_function.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
class SentryFunctionSerializer(Serializer):
77
def serialize(self, obj, attrs, user):
88
events = [event for event in obj.events]
9-
env_variables = map(lambda x: {"name": x[0], "value": x[1]}, obj.env_variables.items())
9+
env_variables = list(
10+
map(
11+
lambda env_variable: {"name": env_variable[0], "value": env_variable[1]},
12+
obj.env_variables.items(),
13+
)
14+
)
1015
data = {
1116
"name": obj.name,
1217
"slug": obj.slug,

tests/sentry/api/endpoints/test_organization_sentry_functions.py

Lines changed: 107 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,80 +2,137 @@
22

33
from typing import Any
44
from unittest.mock import patch
5+
from uuid import uuid4
56

6-
from django.urls import reverse
7-
7+
from sentry.models.sentryfunction import SentryFunction
88
from sentry.testutils.cases import APITestCase
9-
from sentry.testutils.helpers import Feature
9+
from sentry.testutils.helpers.features import with_feature
1010
from sentry.testutils.silo import region_silo_test
1111

1212

13-
@region_silo_test(stable=True)
14-
class OrganizationSentryFunctions(APITestCase):
13+
class OrganizationSentryFunctionBase(APITestCase):
1514
endpoint = "sentry-api-0-organization-sentry-functions"
1615

1716
def setUp(self):
1817
super().setUp()
19-
self.create_organization(owner=self.user, name="RowdyTiger")
20-
self.url = reverse(self.endpoint, args=[self.organization.slug])
2118
self.login_as(user=self.user)
19+
self.code = "exports.yourFunction = (req, res) => {\n\tlet message = req.query.message || req.body.message || 'Hello World!';\n\tconsole.log('Query: ' + req.query);\n\tconsole.log('Body: ' + req.body);\n\tres.status(200).send(message);\n};"
20+
self.data: dict[str, Any] = {
21+
"name": "foo",
22+
"author": "bar",
23+
"code": self.code,
24+
"overview": "qux",
25+
}
26+
27+
28+
@region_silo_test(stable=True)
29+
class OrganizationSentryFunctionsPost(OrganizationSentryFunctionBase):
30+
method = "POST"
31+
32+
def setUp(self):
33+
super().setUp()
34+
self.login_as(user=self.user)
35+
self.data["env_variables"] = [{"name": "foo", "value": "bar"}]
2236

37+
@with_feature("organizations:sentry-functions")
2338
@patch("sentry.api.endpoints.organization_sentry_function.create_function")
2439
def test_post_feature_true(self, mock_func):
25-
defaultCode = "exports.yourFunction = (req, res) => {\n\tlet message = req.query.message || req.body.message || 'Hello World!';\n\tconsole.log('Query: ' + req.query);\n\tconsole.log('Body: ' + req.body);\n\tres.status(200).send(message);\n};"
26-
data = {
40+
response = self.get_success_response(self.organization.slug, status_code=201, **self.data)
41+
42+
assert response.data == {
2743
"name": "foo",
44+
"slug": "foo",
2845
"author": "bar",
29-
"code": defaultCode,
46+
"code": self.code,
3047
"overview": "qux",
31-
"envVariables": [{"name": "foo", "value": "bar"}],
48+
# skip checking external id because it has a random suffix
49+
"external_id": response.data["external_id"],
50+
"events": [],
51+
"env_variables": [{"name": "foo", "value": "bar"}],
3252
}
33-
with Feature("organizations:sentry-functions"):
34-
response = self.client.post(self.url, data)
35-
assert response.status_code == 201
36-
assert response.data["name"] == "foo"
37-
assert response.data["author"] == "bar"
38-
assert response.data["code"] == defaultCode
39-
assert response.data["overview"] == "qux"
40-
mock_func.assert_called_once_with(
41-
defaultCode, response.data["external_id"], "qux", {"foo": "bar"}
42-
)
4353

54+
mock_func.assert_called_once_with(
55+
self.code, response.data["external_id"], "qux", {"foo": "bar"}
56+
)
57+
58+
@with_feature("organizations:sentry-functions")
59+
@patch("sentry.api.endpoints.organization_sentry_function.create_function")
60+
def test_generated_slug_not_entirely_numeric(self, mock_func):
61+
data = {**self.data, "name": "123"}
62+
response = self.get_success_response(self.organization.slug, status_code=201, **data)
63+
64+
assert response.data["name"] == "123"
65+
assert response.data["author"] == "bar"
66+
assert response.data["code"] == self.code
67+
assert response.data["overview"] == "qux"
68+
69+
slug = response.data["slug"]
70+
assert not slug.isdecimal()
71+
assert slug.startswith("123-")
72+
73+
mock_func.assert_called_once_with(
74+
self.code, response.data["external_id"], "qux", {"foo": "bar"}
75+
)
76+
77+
@with_feature("organizations:sentry-functions")
4478
def test_post_missing_params(self):
45-
data: dict[str, Any] = {"name": "foo", "overview": "qux"}
46-
with Feature("organizations:sentry-functions"):
47-
response = self.client.post(self.url, **data)
48-
assert response.status_code == 400
79+
data = {"name": "foo", "overview": "qux"}
80+
self.get_error_response(self.organization.slug, status_code=400, **data)
4981

5082
def test_post_feature_false(self):
51-
data: dict[str, Any] = {"name": "foo", "author": "bar"}
52-
response = self.client.post(self.url, **data)
53-
assert response.status_code == 404
83+
data = {"name": "foo", "author": "bar"}
84+
response = self.get_error_response(self.organization.slug, status_code=404, **data)
85+
assert response.data == "organizations:sentry-functions flag set to false"
5486

55-
def test_get(self):
56-
with Feature("organizations:sentry-functions"):
57-
response = self.client.get(self.url)
58-
assert response.status_code == 200
59-
assert response.data == []
6087

61-
@patch("sentry.api.endpoints.organization_sentry_function.create_function")
62-
def test_get_with_function(self, mock_func):
63-
defaultCode = "exports.yourFunction = (req, res) => {\n\tlet message = req.query.message || req.body.message || 'Hello World!';\n\tconsole.log('Query: ' + req.query);\n\tconsole.log('Body: ' + req.body);\n\tres.status(200).send(message);\n};"
64-
data = {
88+
@region_silo_test(stable=True)
89+
class OrganizationSentryFunctionsGet(OrganizationSentryFunctionBase):
90+
endpoint = "sentry-api-0-organization-sentry-functions"
91+
method = "GET"
92+
93+
def setUp(self):
94+
super().setUp()
95+
self.login_as(user=self.user)
96+
self.post_data = {
97+
**self.data,
98+
"slug": "foo",
99+
"organization_id": self.organization.id,
100+
"external_id": "foo-" + uuid4().hex,
101+
}
102+
103+
@with_feature("organizations:sentry-functions")
104+
def test_get_empty(self):
105+
response = self.get_success_response(self.organization.slug, status_code=200)
106+
assert response.data == []
107+
108+
@with_feature("organizations:sentry-functions")
109+
def test_get_with_function(self):
110+
SentryFunction.objects.create(**self.post_data)
111+
response = self.get_success_response(self.organization.slug, status_code=200)
112+
assert response.data[0] == {
113+
"name": "foo",
114+
"slug": "foo",
115+
"author": "bar",
116+
"code": self.code,
117+
"overview": "qux",
118+
"external_id": self.post_data["external_id"],
119+
"events": [],
120+
"env_variables": [],
121+
}
122+
123+
@with_feature("organizations:sentry-functions")
124+
def test_get_with_function_and_env_variables(self):
125+
# env_variables is expected to be a single dict of key-value pairs if
126+
# you're directly creating a SentryFunction object using .create()
127+
SentryFunction.objects.create(**self.post_data, env_variables={"foo": "bar", "baz": "qux"})
128+
response = self.get_success_response(self.organization.slug, status_code=200)
129+
assert response.data[0] == {
65130
"name": "foo",
131+
"slug": "foo",
66132
"author": "bar",
67-
"code": defaultCode,
133+
"code": self.code,
68134
"overview": "qux",
69-
"envVariables": [{"name": "foo", "value": "bar"}],
135+
"external_id": self.post_data["external_id"],
136+
"events": [],
137+
"env_variables": [{"name": "foo", "value": "bar"}, {"name": "baz", "value": "qux"}],
70138
}
71-
with Feature("organizations:sentry-functions"):
72-
self.client.post(self.url, data)
73-
response = self.client.get(self.url)
74-
assert response.status_code == 200
75-
assert response.data[0]["name"] == "foo"
76-
assert response.data[0]["author"] == "bar"
77-
assert response.data[0]["code"] == defaultCode
78-
assert response.data[0]["overview"] == "qux"
79-
mock_func.assert_called_once_with(
80-
defaultCode, response.data[0]["external_id"], "qux", {"foo": "bar"}
81-
)

0 commit comments

Comments
 (0)