Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit c98c803

Browse files
Merge branch 'main' into 2815-add-repo-signal-fields
2 parents 6499f80 + 63124e2 commit c98c803

File tree

13 files changed

+198
-13
lines changed

13 files changed

+198
-13
lines changed

codecov/settings_base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@
106106

107107
GRAPHQL_INTROSPECTION_ENABLED = False
108108

109+
GRAPHQL_MAX_DEPTH = get_config("setup", "graphql", "max_depth", default=20)
110+
111+
GRAPHQL_MAX_ALIASES = get_config("setup", "graphql", "max_aliases", default=10)
112+
109113
# Database
110114
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
111115

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from graphql import (
2+
GraphQLField,
3+
GraphQLObjectType,
4+
GraphQLSchema,
5+
GraphQLString,
6+
parse,
7+
validate,
8+
)
9+
10+
from ..validation import (
11+
create_max_aliases_rule,
12+
create_max_depth_rule,
13+
)
14+
15+
16+
def resolve_field(*args):
17+
return "test"
18+
19+
20+
QueryType = GraphQLObjectType(
21+
"Query", {"field": GraphQLField(GraphQLString, resolve=resolve_field)}
22+
)
23+
schema = GraphQLSchema(query=QueryType)
24+
25+
26+
def validate_query(query, *rules):
27+
ast = parse(query)
28+
return validate(schema, ast, rules=rules)
29+
30+
31+
def test_max_depth_rule_allows_within_depth():
32+
query = """
33+
query {
34+
field
35+
}
36+
"""
37+
errors = validate_query(query, create_max_depth_rule(2))
38+
assert not errors, "Expected no errors for depth within the limit"
39+
40+
41+
def test_max_depth_rule_rejects_exceeding_depth():
42+
query = """
43+
query {
44+
field {
45+
field {
46+
field
47+
}
48+
}
49+
}
50+
"""
51+
errors = validate_query(query, create_max_depth_rule(2))
52+
assert errors, "Expected errors for exceeding depth limit"
53+
assert any(
54+
"Query depth exceeds the maximum allowed depth" in str(e) for e in errors
55+
)
56+
57+
58+
def test_max_depth_rule_exact_depth():
59+
query = """
60+
query {
61+
field
62+
}
63+
"""
64+
errors = validate_query(query, create_max_depth_rule(2))
65+
assert not errors, "Expected no errors when query depth matches the limit"
66+
67+
68+
def test_max_aliases_rule_allows_within_alias_limit():
69+
query = """
70+
query {
71+
alias1: field
72+
alias2: field
73+
}
74+
"""
75+
errors = validate_query(query, create_max_aliases_rule(2))
76+
assert not errors, "Expected no errors for alias count within the limit"
77+
78+
79+
def test_max_aliases_rule_rejects_exceeding_alias_limit():
80+
query = """
81+
query {
82+
alias1: field
83+
alias2: field
84+
alias3: field
85+
}
86+
"""
87+
errors = validate_query(query, create_max_aliases_rule(2))
88+
assert errors, "Expected errors for exceeding alias limit"
89+
assert any("Query uses too many aliases" in str(e) for e in errors)
90+
91+
92+
def test_max_aliases_rule_exact_alias_limit():
93+
query = """
94+
query {
95+
alias1: field
96+
alias2: field
97+
}
98+
"""
99+
errors = validate_query(query, create_max_aliases_rule(2))
100+
assert not errors, "Expected no errors when alias count matches the limit"

graphql_api/tests/test_views.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ async def test_when_debug_is_false_and_exception_we_know(self):
113113
assert data["errors"][0]["type"] == "Unauthorized"
114114
assert data["errors"][0].get("extensions") is None
115115

116-
@override_settings(DEBUG=False)
116+
@override_settings(DEBUG=True)
117117
async def test_when_bad_query(self):
118118
schema = generate_schema_that_raise_with(Unauthorized())
119119
data = await self.do_query(schema, " { fieldThatDoesntExist }")
@@ -123,6 +123,13 @@ async def test_when_bad_query(self):
123123
== "Cannot query field 'fieldThatDoesntExist' on type 'Query'."
124124
)
125125

126+
@override_settings(DEBUG=False)
127+
async def test_when_bad_query_and_anonymous(self):
128+
schema = generate_schema_that_raise_with(Unauthorized())
129+
data = await self.do_query(schema, " { fieldThatDoesntExist }")
130+
assert data["errors"] is not None
131+
assert data["errors"][0]["message"] == "INTERNAL SERVER ERROR"
132+
126133
@override_settings(DEBUG=False, GRAPHQL_QUERY_COST_THRESHOLD=1000)
127134
@patch("logging.Logger.error")
128135
async def test_when_costly_query(self, mock_error_logger):

graphql_api/validation.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from typing import Any, Type
2+
3+
from graphql import GraphQLError, ValidationRule
4+
from graphql.language.ast import DocumentNode, FieldNode, OperationDefinitionNode
5+
from graphql.validation import ValidationContext
6+
7+
8+
def create_max_depth_rule(max_depth: int) -> Type[ValidationRule]:
9+
class MaxDepthRule(ValidationRule):
10+
def __init__(self, context: ValidationContext) -> None:
11+
super().__init__(context)
12+
self.operation_depth: int = 1
13+
self.max_depth_reached: bool = False
14+
self.max_depth: int = max_depth
15+
16+
def enter_operation_definition(
17+
self, node: OperationDefinitionNode, *_args: Any
18+
) -> None:
19+
self.operation_depth = 1
20+
self.max_depth_reached = False
21+
22+
def enter_field(self, node: FieldNode, *_args: Any) -> None:
23+
self.operation_depth += 1
24+
25+
if self.operation_depth > self.max_depth and not self.max_depth_reached:
26+
self.max_depth_reached = True
27+
self.report_error(
28+
GraphQLError(
29+
"Query depth exceeds the maximum allowed depth",
30+
node,
31+
)
32+
)
33+
34+
def leave_field(self, node: FieldNode, *_args: Any) -> None:
35+
self.operation_depth -= 1
36+
37+
return MaxDepthRule
38+
39+
40+
def create_max_aliases_rule(max_aliases: int) -> Type[ValidationRule]:
41+
class MaxAliasesRule(ValidationRule):
42+
def __init__(self, context: ValidationContext) -> None:
43+
super().__init__(context)
44+
self.alias_count: int = 0
45+
self.has_reported_error: bool = False
46+
self.max_aliases: int = max_aliases
47+
48+
def enter_document(self, node: DocumentNode, *_args: Any) -> None:
49+
self.alias_count = 0
50+
self.has_reported_error = False
51+
52+
def enter_field(self, node: FieldNode, *_args: Any) -> None:
53+
if node.alias:
54+
self.alias_count += 1
55+
56+
if self.alias_count > self.max_aliases and not self.has_reported_error:
57+
self.has_reported_error = True
58+
self.report_error(
59+
GraphQLError(
60+
"Query uses too many aliases",
61+
node,
62+
)
63+
)
64+
65+
return MaxAliasesRule

graphql_api/views.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from services.redis_configuration import get_redis_connection
2929

3030
from .schema import schema
31+
from .validation import create_max_aliases_rule, create_max_depth_rule
3132

3233
log = logging.getLogger(__name__)
3334

@@ -188,7 +189,7 @@ def __exit__(self, exc_type, exc_value, exc_traceback):
188189
class AsyncGraphqlView(GraphQLAsyncView):
189190
schema = schema
190191
extensions = [QueryMetricsExtension]
191-
introspection = getattr(settings, "GRAPHQL_INTROSPECTION_ENABLED", False)
192+
introspection = settings.GRAPHQL_INTROSPECTION_ENABLED
192193

193194
def get_validation_rules(
194195
self,
@@ -197,11 +198,13 @@ def get_validation_rules(
197198
data: dict,
198199
) -> Optional[Collection]:
199200
return [
201+
create_max_aliases_rule(max_aliases=settings.GRAPHQL_MAX_ALIASES),
202+
create_max_depth_rule(max_depth=settings.GRAPHQL_MAX_DEPTH),
200203
cost_validator(
201204
maximum_cost=settings.GRAPHQL_QUERY_COST_THRESHOLD,
202205
default_cost=1,
203206
variables=data.get("variables"),
204-
)
207+
),
205208
]
206209

207210
validation_rules = get_validation_rules # type: ignore
@@ -293,6 +296,8 @@ async def post(self, request, *args, **kwargs):
293296

294297
def context_value(self, request, *_):
295298
request_body = json.loads(request.body.decode("utf-8")) if request.body else {}
299+
self.request = request
300+
296301
return {
297302
"request": request,
298303
"service": request.resolver_match.kwargs["service"],
@@ -301,9 +306,11 @@ def context_value(self, request, *_):
301306
}
302307

303308
def error_formatter(self, error, debug=False):
309+
user = self.request.user
310+
is_anonymous = user.is_anonymous if user else True
304311
# the only way to check for a malformed query
305312
is_bad_query = "Cannot query field" in error.formatted["message"]
306-
if debug or is_bad_query:
313+
if debug or (not is_anonymous and is_bad_query):
307314
return format_error(error, debug)
308315
formatted = error.formatted
309316
formatted["message"] = "INTERNAL SERVER ERROR"

upload/helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -754,9 +754,9 @@ def dispatch_upload_task(
754754

755755
def validate_activated_repo(repository):
756756
if repository.active and not repository.activated:
757-
settings_url = f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/settings"
757+
config_url = f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/config/general"
758758
raise ValidationError(
759-
f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings_url}"
759+
f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {config_url}"
760760
)
761761

762762

upload/tests/test_helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,12 +259,12 @@ def test_validate_upload_too_many_uploads_for_commit(
259259

260260
def test_deactivated_repo(db, mocker):
261261
repository = RepositoryFactory.create(active=True, activated=False)
262-
settings_url = f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/settings"
262+
config_url = f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/config/general"
263263

264264
with pytest.raises(ValidationError) as exp:
265265
validate_activated_repo(repository)
266266
assert exp.match(
267-
f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings_url}"
267+
f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {config_url}"
268268
)
269269

270270

upload/tests/views/test_commits.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def test_deactivated_repo(db):
6868
response_json = response.json()
6969
assert response.status_code == 400
7070
assert response_json == [
71-
f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings.CODECOV_DASHBOARD_URL}/github/codecov/the_repo/settings"
71+
f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings.CODECOV_DASHBOARD_URL}/github/codecov/the_repo/config/general"
7272
]
7373

7474

upload/tests/views/test_reports.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def test_deactivated_repo(db):
6565
response_json = response.json()
6666
assert response.status_code == 400
6767
assert response_json == [
68-
f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings.CODECOV_DASHBOARD_URL}/github/codecov/the_repo/settings"
68+
f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings.CODECOV_DASHBOARD_URL}/github/codecov/the_repo/config/general"
6969
]
7070

7171

upload/tests/views/test_uploads.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,7 @@ def test_deactivated_repo(db, mocker, mock_redis):
793793
response_json = response.json()
794794
assert response.status_code == 400
795795
assert response_json == [
796-
f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings.CODECOV_DASHBOARD_URL}/github/codecov/the_repo/settings"
796+
f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings.CODECOV_DASHBOARD_URL}/github/codecov/the_repo/config/general"
797797
]
798798

799799

0 commit comments

Comments
 (0)