Skip to content

Commit 0f11561

Browse files
authored
feat(preprod): validate vcs params used during upload (#102755)
Returns 400 errors for some common integration errors we see so users should be able to self-serve how to fix.
1 parent d0be4fd commit 0f11561

File tree

2 files changed

+190
-16
lines changed

2 files changed

+190
-16
lines changed

src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,31 @@
3535
]
3636

3737

38+
def validate_vcs_parameters(data: dict[str, Any]) -> str | None:
39+
head_sha = data.get("head_sha")
40+
base_sha = data.get("base_sha")
41+
42+
if head_sha and base_sha and head_sha == base_sha:
43+
return f"Head SHA and base SHA cannot be the same ({head_sha}). Please provide a different base SHA."
44+
45+
if not head_sha and base_sha:
46+
return "Head SHA is required when base SHA is provided. Please provide a head SHA."
47+
48+
# If any VCS parameters are provided, all required ones must be present
49+
vcs_params = {
50+
"head_sha": head_sha,
51+
"head_repo_name": data.get("head_repo_name"),
52+
"provider": data.get("provider"),
53+
"head_ref": data.get("head_ref"),
54+
}
55+
56+
if any(vcs_params.values()) and any(not v for v in vcs_params.values()):
57+
missing_params = [k for k, v in vcs_params.items() if not v]
58+
return f"All required VCS parameters must be provided when using VCS features. Missing parameters: {', '.join(missing_params)}"
59+
60+
return None
61+
62+
3863
def validate_preprod_artifact_schema(request_body: bytes) -> tuple[dict[str, Any], str | None]:
3964
"""
4065
Validate the JSON schema for preprod artifact assembly requests.
@@ -144,22 +169,10 @@ def post(self, request: Request, project: Project) -> Response:
144169
checksum = str(data.get("checksum", ""))
145170
chunks = data.get("chunks", [])
146171

147-
# Validate VCS parameters - if any are provided, all required ones must be present
148-
vcs_params = {
149-
"head_sha": data.get("head_sha"),
150-
"head_repo_name": data.get("head_repo_name"),
151-
"provider": data.get("provider"),
152-
"head_ref": data.get("head_ref"),
153-
}
154-
155-
if any(vcs_params.values()) and any(not v for v in vcs_params.values()):
156-
missing_params = [k for k, v in vcs_params.items() if not v]
157-
return Response(
158-
{
159-
"error": f"All required VCS parameters must be provided when using VCS features. Missing parameters: {', '.join(missing_params)}"
160-
},
161-
status=400,
162-
)
172+
# Validate VCS parameters
173+
vcs_error = validate_vcs_parameters(data)
174+
if vcs_error:
175+
return Response({"error": vcs_error}, status=400)
163176

164177
# Check if all requested chunks have been uploaded
165178
missing_chunks = find_missing_chunks(project.organization_id, set(chunks))

tests/sentry/preprod/api/endpoints/test_organization_preprod_artifact_assemble.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from sentry.models.orgauthtoken import OrgAuthToken
1313
from sentry.preprod.api.endpoints.organization_preprod_artifact_assemble import (
1414
validate_preprod_artifact_schema,
15+
validate_vcs_parameters,
1516
)
1617
from sentry.preprod.tasks import create_preprod_artifact
1718
from sentry.silo.base import SiloMode
@@ -171,6 +172,116 @@ def test_additional_properties_rejected(self) -> None:
171172
assert result == {}
172173

173174

175+
class ValidateVcsParametersTest(TestCase):
176+
"""Unit tests for VCS parameter validation function - no database required."""
177+
178+
def test_valid_minimal_no_vcs_params(self) -> None:
179+
"""Test that validation passes when no VCS params are provided."""
180+
data = {"checksum": "a" * 40, "chunks": []}
181+
error = validate_vcs_parameters(data)
182+
assert error is None
183+
184+
def test_valid_complete_vcs_params(self) -> None:
185+
"""Test that validation passes when all required VCS params are provided."""
186+
data = {
187+
"checksum": "a" * 40,
188+
"chunks": [],
189+
"head_sha": "e" * 40,
190+
"head_repo_name": "owner/repo",
191+
"provider": "github",
192+
"head_ref": "feature/xyz",
193+
}
194+
error = validate_vcs_parameters(data)
195+
assert error is None
196+
197+
def test_valid_complete_vcs_params_with_base_sha(self) -> None:
198+
"""Test that validation passes when all VCS params including base_sha are provided."""
199+
data = {
200+
"checksum": "a" * 40,
201+
"chunks": [],
202+
"head_sha": "e" * 40,
203+
"base_sha": "f" * 40,
204+
"head_repo_name": "owner/repo",
205+
"provider": "github",
206+
"head_ref": "feature/xyz",
207+
}
208+
error = validate_vcs_parameters(data)
209+
assert error is None
210+
211+
def test_same_head_and_base_sha(self) -> None:
212+
"""Test that validation fails when head_sha and base_sha are the same."""
213+
same_sha = "e" * 40
214+
data = {
215+
"checksum": "a" * 40,
216+
"chunks": [],
217+
"head_sha": same_sha,
218+
"base_sha": same_sha,
219+
}
220+
error = validate_vcs_parameters(data)
221+
assert error is not None
222+
assert "Head SHA and base SHA cannot be the same" in error
223+
assert same_sha in error
224+
225+
def test_base_sha_without_head_sha(self) -> None:
226+
"""Test that validation fails when base_sha is provided without head_sha."""
227+
data = {"checksum": "a" * 40, "chunks": [], "base_sha": "f" * 40}
228+
error = validate_vcs_parameters(data)
229+
assert error is not None
230+
assert "Head SHA is required when base SHA is provided" in error
231+
232+
def test_missing_head_repo_name(self) -> None:
233+
"""Test that validation fails when head_repo_name is missing."""
234+
data = {
235+
"checksum": "a" * 40,
236+
"chunks": [],
237+
"head_sha": "e" * 40,
238+
"provider": "github",
239+
"head_ref": "feature/xyz",
240+
}
241+
error = validate_vcs_parameters(data)
242+
assert error is not None
243+
assert "Missing parameters" in error
244+
assert "head_repo_name" in error
245+
246+
def test_missing_provider(self) -> None:
247+
"""Test that validation fails when provider is missing."""
248+
data = {
249+
"checksum": "a" * 40,
250+
"chunks": [],
251+
"head_sha": "e" * 40,
252+
"head_repo_name": "owner/repo",
253+
"head_ref": "feature/xyz",
254+
}
255+
error = validate_vcs_parameters(data)
256+
assert error is not None
257+
assert "Missing parameters" in error
258+
assert "provider" in error
259+
260+
def test_missing_head_ref(self) -> None:
261+
"""Test that validation fails when head_ref is missing."""
262+
data = {
263+
"checksum": "a" * 40,
264+
"chunks": [],
265+
"head_sha": "e" * 40,
266+
"head_repo_name": "owner/repo",
267+
"provider": "github",
268+
}
269+
error = validate_vcs_parameters(data)
270+
assert error is not None
271+
assert "Missing parameters" in error
272+
assert "head_ref" in error
273+
274+
def test_missing_multiple_params(self) -> None:
275+
"""Test that validation fails and reports all missing params."""
276+
data = {"checksum": "a" * 40, "chunks": [], "head_sha": "e" * 40}
277+
error = validate_vcs_parameters(data)
278+
assert error is not None
279+
assert "Missing parameters" in error
280+
assert "head_repo_name" in error
281+
assert "provider" in error
282+
assert "head_ref" in error
283+
284+
174285
class ProjectPreprodArtifactAssembleTest(APITestCase):
175286
"""Integration tests for the full endpoint - requires database."""
176287

@@ -804,3 +915,53 @@ def test_assemble_missing_vcs_parameters(self) -> None:
804915
assert "head_repo_name" in response.data["error"]
805916
assert "provider" in response.data["error"]
806917
assert "head_ref" in response.data["error"]
918+
919+
def test_assemble_same_head_and_base_sha(self) -> None:
920+
"""Test that providing the same value for head_sha and base_sha returns a 400 error."""
921+
content = b"test same sha"
922+
total_checksum = sha1(content).hexdigest()
923+
924+
blob = FileBlob.from_file(ContentFile(content))
925+
FileBlobOwner.objects.get_or_create(organization_id=self.organization.id, blob=blob)
926+
927+
same_sha = "e" * 40
928+
929+
response = self.client.post(
930+
self.url,
931+
data={
932+
"checksum": total_checksum,
933+
"chunks": [blob.checksum],
934+
"head_sha": same_sha,
935+
"base_sha": same_sha,
936+
"provider": "github",
937+
"head_repo_name": "owner/repo",
938+
"head_ref": "feature/xyz",
939+
},
940+
HTTP_AUTHORIZATION=f"Bearer {self.token.token}",
941+
)
942+
assert response.status_code == 400, response.content
943+
assert "error" in response.data
944+
assert "Head SHA and base SHA cannot be the same" in response.data["error"]
945+
assert same_sha in response.data["error"]
946+
947+
def test_assemble_base_sha_without_head_sha(self) -> None:
948+
"""Test that providing base_sha without head_sha returns a 400 error."""
949+
content = b"test base sha without head sha"
950+
total_checksum = sha1(content).hexdigest()
951+
952+
blob = FileBlob.from_file(ContentFile(content))
953+
FileBlobOwner.objects.get_or_create(organization_id=self.organization.id, blob=blob)
954+
955+
response = self.client.post(
956+
self.url,
957+
data={
958+
"checksum": total_checksum,
959+
"chunks": [blob.checksum],
960+
"base_sha": "f" * 40,
961+
# Missing head_sha
962+
},
963+
HTTP_AUTHORIZATION=f"Bearer {self.token.token}",
964+
)
965+
assert response.status_code == 400, response.content
966+
assert "error" in response.data
967+
assert "Head SHA is required when base SHA is provided" in response.data["error"]

0 commit comments

Comments
 (0)