Skip to content

Commit c5561d3

Browse files
committed
Updates
1 parent 65c05de commit c5561d3

File tree

4 files changed

+173
-25
lines changed

4 files changed

+173
-25
lines changed

src/sentry/preprod/analytics.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,27 +22,27 @@ class PreprodArtifactApiAssembleGenericEvent(analytics.Event):
2222
project_id: int
2323

2424

25-
@analytics.eventclass("preprod_artifact.api.size_analysis_download")
26-
class PreprodArtifactApiSizeAnalysisDownloadEvent(analytics.Event):
25+
@analytics.eventclass("preprod_artifact.api.get_build_details")
26+
class PreprodArtifactApiGetBuildDetailsEvent(analytics.Event):
2727
organization_id: int
2828
project_id: int
2929
user_id: int | None = None
3030
artifact_id: str
3131

3232

33-
@analytics.eventclass("preprod_artifact.api.get_build_details")
34-
class PreprodArtifactApiGetBuildDetailsEvent(analytics.Event):
33+
@analytics.eventclass("preprod_artifact.api.list_builds")
34+
class PreprodArtifactApiListBuildsEvent(analytics.Event):
3535
organization_id: int
3636
project_id: int
3737
user_id: int | None = None
38-
artifact_id: str
3938

4039

41-
@analytics.eventclass("preprod_artifact.api.list_builds")
42-
class PreprodArtifactApiListBuildsEvent(analytics.Event):
40+
@analytics.eventclass("preprod_artifact.api.install_details")
41+
class PreprodArtifactApiInstallDetailsEvent(analytics.Event):
4342
organization_id: int
4443
project_id: int
4544
user_id: int | None = None
45+
artifact_id: str
4646

4747

4848
@analytics.eventclass("preprod_artifact.api.admin_rerun_analysis")
@@ -77,6 +77,23 @@ class PreprodArtifactApiDeleteEvent(analytics.Event):
7777
artifact_id: str
7878

7979

80+
@analytics.eventclass("preprod_artifact.api.image")
81+
class PreprodArtifactApiImageEvent(analytics.Event):
82+
organization_id: int
83+
project_id: int
84+
user_id: int | None = None
85+
image_id: str
86+
87+
88+
# Size analysis
89+
@analytics.eventclass("preprod_artifact.api.size_analysis_download")
90+
class PreprodArtifactApiSizeAnalysisDownloadEvent(analytics.Event):
91+
organization_id: int
92+
project_id: int
93+
user_id: int | None = None
94+
artifact_id: str
95+
96+
8097
@analytics.eventclass("preprod_artifact.api.size_analysis_compare.get")
8198
class PreprodArtifactApiSizeAnalysisCompareGetEvent(analytics.Event):
8299
organization_id: int
@@ -95,14 +112,6 @@ class PreprodArtifactApiSizeAnalysisComparePostEvent(analytics.Event):
95112
base_artifact_id: str
96113

97114

98-
@analytics.eventclass("preprod_artifact.api.install_details")
99-
class PreprodArtifactApiInstallDetailsEvent(analytics.Event):
100-
organization_id: int
101-
project_id: int
102-
user_id: int | None = None
103-
artifact_id: str
104-
105-
106115
@analytics.eventclass("preprod_artifact.api.size_analysis_compare_download")
107116
class PreprodArtifactApiSizeAnalysisCompareDownloadEvent(analytics.Event):
108117
organization_id: int
@@ -146,6 +155,7 @@ class PreprodApiPrPageCommentsEvent(analytics.Event):
146155
analytics.register(PreprodArtifactApiAdminGetInfoEvent)
147156
analytics.register(PreprodArtifactApiAdminBatchDeleteEvent)
148157
analytics.register(PreprodArtifactApiDeleteEvent)
158+
analytics.register(PreprodArtifactApiImageEvent)
149159
# Size analysis
150160
analytics.register(PreprodArtifactApiSizeAnalysisDownloadEvent)
151161
analytics.register(PreprodArtifactApiSizeAnalysisCompareGetEvent)

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,22 @@
44

55
from django.http import HttpResponse
66
from rest_framework.request import Request
7+
from rest_framework.response import Response
78

9+
from sentry import analytics
810
from sentry.api.api_owners import ApiOwner
911
from sentry.api.api_publish_status import ApiPublishStatus
1012
from sentry.api.base import region_silo_endpoint
1113
from sentry.api.bases.project import ProjectEndpoint
1214
from sentry.models.project import Project
1315
from sentry.objectstore import preprod
1416
from sentry.objectstore.service import ClientError
17+
from sentry.preprod.analytics import PreprodArtifactApiImageEvent
1518

1619
logger = logging.getLogger(__name__)
1720

1821

1922
def detect_image_content_type(image_data: bytes) -> str:
20-
"""
21-
Detect the content type of an image from its magic bytes.
22-
Returns the appropriate MIME type or a default if unknown.
23-
"""
2423
if not image_data:
2524
return "application/octet-stream"
2625

@@ -67,7 +66,17 @@ def get(
6766
request: Request,
6867
project: Project,
6968
image_id: str,
70-
) -> HttpResponse:
69+
) -> Response:
70+
71+
analytics.record(
72+
PreprodArtifactApiImageEvent(
73+
organization_id=project.organization_id,
74+
project_id=project.id,
75+
user_id=request.user.id,
76+
image_id=image_id,
77+
)
78+
)
79+
7180
organization_id = project.organization_id
7281
project_id = project.id
7382

@@ -114,7 +123,7 @@ def get(
114123
)
115124

116125
# Upload failed, return appropriate error
117-
return HttpResponse({"error": "Not found"}, status=404)
126+
return Response({"error": "Not found"}, status=404)
118127

119128
logger.warning(
120129
"Failed to retrieve app icon from objectstore",
@@ -126,7 +135,7 @@ def get(
126135
"status": e.status,
127136
},
128137
)
129-
return HttpResponse({"error": "Failed to retrieve app icon"}, status=500)
138+
return Response({"error": "Failed to retrieve app icon"}, status=500)
130139

131140
except Exception:
132141
logger.exception(
@@ -137,4 +146,4 @@ def get(
137146
"image_id": image_id,
138147
},
139148
)
140-
return HttpResponse({"error": "Internal server error"}, status=500)
149+
return Response({"error": "Internal server error"}, status=500)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from django.urls import re_path
44

5-
from sentry.preprod.api.endpoints.project_preprod_artifact_icon import (
5+
from sentry.preprod.api.endpoints.project_preprod_artifact_image import (
66
ProjectPreprodArtifactImageEndpoint,
77
)
88
from sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare import (
@@ -87,7 +87,7 @@
8787
re_path(
8888
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/files/images/(?P<image_id>[^/]+)/$",
8989
ProjectPreprodArtifactImageEndpoint.as_view(),
90-
name="sentry-api-0-project-preprod-app-icon",
90+
name="sentry-api-0-project-preprod-artifact-image",
9191
),
9292
# Size analysis
9393
re_path(
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from io import BytesIO
2+
3+
from django.urls import reverse
4+
5+
from sentry.objectstore import preprod
6+
from sentry.testutils.cases import APITestCase
7+
from sentry.testutils.skips import requires_objectstore
8+
9+
10+
class ProjectPreprodArtifactImageTest(APITestCase):
11+
def setUp(self):
12+
super().setUp()
13+
self.login_as(user=self.user)
14+
self.org = self.create_organization(owner=self.user)
15+
self.project = self.create_project(organization=self.org)
16+
self.api_token = self.create_user_auth_token(
17+
user=self.user, scope_list=["org:admin", "project:admin"]
18+
)
19+
self.image_id = "test-image-123"
20+
self.base_path = f"/api/0/{self.org.slug}/{self.project.slug}/files/images/{self.image_id}/"
21+
22+
def _get_url(self, image_id=None):
23+
image_id = image_id or self.image_id
24+
return reverse(
25+
"sentry-api-0-project-preprod-artifact-image",
26+
args=[self.org.slug, self.project.slug, image_id],
27+
)
28+
29+
@requires_objectstore
30+
def test_successful_image_retrieval_png(self):
31+
png_data = b"\x89PNG\r\n\x1a\n" + b"fake png content" * 100
32+
33+
client = preprod.for_project(self.org.id, self.project.id)
34+
client.put(BytesIO(png_data), id=f"{self.org.id}/{self.project.id}/test-image-123")
35+
36+
url = self._get_url()
37+
response = self.client.get(
38+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
39+
)
40+
41+
assert response.status_code == 200
42+
assert response.content == png_data
43+
assert response["Content-Type"] == "image/png"
44+
45+
@requires_objectstore
46+
def test_successful_image_retrieval_jpeg(self):
47+
jpeg_data = b"\xff\xd8\xff" + b"fake jpeg content" * 100
48+
49+
client = preprod.for_project(self.org.id, self.project.id)
50+
client.put(BytesIO(jpeg_data), id=f"{self.org.id}/{self.project.id}/test-image-123")
51+
52+
url = self._get_url()
53+
response = self.client.get(
54+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
55+
)
56+
57+
assert response.status_code == 200
58+
assert response.content == jpeg_data
59+
assert response["Content-Type"] == "image/jpeg"
60+
61+
@requires_objectstore
62+
def test_successful_image_retrieval_webp(self):
63+
webp_data = b"RIFF" + b"1234" + b"WEBP" + b"fake webp content" * 100
64+
65+
client = preprod.for_project(self.org.id, self.project.id)
66+
client.put(BytesIO(webp_data), id=f"{self.org.id}/{self.project.id}/test-image-123")
67+
68+
url = self._get_url()
69+
response = self.client.get(
70+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
71+
)
72+
73+
assert response.status_code == 200
74+
assert response.content == webp_data
75+
assert response["Content-Type"] == "image/webp"
76+
77+
def test_successful_image_retrieval_heic(self):
78+
heic_data = b"RIFF" + b"ftypheic" + b"fake heic content" * 100
79+
80+
client = preprod.for_project(self.org.id, self.project.id)
81+
client.put(BytesIO(heic_data), id=f"{self.org.id}/{self.project.id}/test-image-123")
82+
83+
url = self._get_url()
84+
response = self.client.get(
85+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
86+
)
87+
88+
assert response.status_code == 200
89+
assert response.content == heic_data
90+
assert response["Content-Type"] == "image/heic"
91+
92+
@requires_objectstore
93+
def test_image_not_found(self):
94+
url = self._get_url()
95+
response = self.client.get(
96+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
97+
)
98+
99+
assert response.status_code == 404
100+
assert response.content == b'{"error":"Not found"}'
101+
102+
@requires_objectstore
103+
def test_unknown_image_format(self):
104+
unknown_data = b"unknown binary data" * 50
105+
106+
client = preprod.for_project(self.org.id, self.project.id)
107+
client.put(BytesIO(unknown_data), id=f"{self.org.id}/{self.project.id}/test-image-123")
108+
109+
url = self._get_url()
110+
response = self.client.get(
111+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
112+
)
113+
114+
assert response.status_code == 200
115+
assert response.content == unknown_data
116+
assert response["Content-Type"] == "application/octet-stream"
117+
118+
def test_endpoint_requires_project_access(self):
119+
other_user = self.create_user()
120+
self.login_as(user=other_user)
121+
self.api_token = self.create_user_auth_token(
122+
user=other_user, scope_list=["org:read", "project:read"]
123+
)
124+
125+
url = self._get_url()
126+
response = self.client.get(
127+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
128+
)
129+
assert response.status_code == 403

0 commit comments

Comments
 (0)