Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from __future__ import annotations

import logging
from typing import Any, cast

from pydantic import BaseModel, Field
from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import internal_region_silo_endpoint
from sentry.models.project import Project
from sentry.preprod.api.bases.preprod_artifact_endpoint import PreprodArtifactEndpoint
from sentry.preprod.api.endpoints.project_preprod_size import parse_request_with_pydantic
from sentry.preprod.authentication import (
LaunchpadRpcPermission,
LaunchpadRpcSignatureAuthentication,
)
from sentry.preprod.models import PreprodArtifact

logger = logging.getLogger(__name__)


class PutDistribution(BaseModel):
error_code: int = Field(ge=0, le=3)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded magic number coupled to enum max value

Low Severity

The error_code field uses Field(ge=0, le=3) where 3 is a magic number implicitly coupled to the current max value of InstallableAppErrorCode (PROCESSING_ERROR=3). If a new enum member is added to InstallableAppErrorCode, this Pydantic validation will silently reject the new value with no obvious link back to the enum, making it easy to miss during future updates. Referencing the enum directly (e.g., using its max value or validating membership) would keep the validation in sync automatically.

Fix in Cursor Fix in Web

error_message: str


@internal_region_silo_endpoint
class ProjectPreprodDistributionEndpoint(PreprodArtifactEndpoint):
owner = ApiOwner.EMERGE_TOOLS
publish_status = {
"PUT": ApiPublishStatus.PRIVATE,
}
authentication_classes = (LaunchpadRpcSignatureAuthentication,)
permission_classes = (LaunchpadRpcPermission,)

def put(
self,
request: Request,
project: Project,
head_artifact_id: int,
head_artifact: PreprodArtifact,
) -> Response:
put: PutDistribution = parse_request_with_pydantic(request, cast(Any, PutDistribution))

head_artifact.installable_app_error_code = put.error_code
head_artifact.installable_app_error_message = put.error_message
head_artifact.save(
update_fields=[
"installable_app_error_code",
"installable_app_error_message",
"date_updated",
]
)

return Response({"artifactId": str(head_artifact.id)})
5 changes: 3 additions & 2 deletions src/sentry/preprod/api/endpoints/project_preprod_size.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ def parse_request_with_pydantic(request: Request, model: type[T]) -> T:
# can be used instead of parse_obj_as
return parse_obj_as(model, j)
except pydantic.ValidationError:
logger.exception("Could not parse PutSize")
raise serializers.ValidationError("Could not parse PutSize")
name = getattr(model, "__name__", str(model))
logger.exception("Could not parse %s", name)
raise serializers.ValidationError(f"Could not parse {name}")


@internal_region_silo_endpoint
Expand Down
6 changes: 6 additions & 0 deletions src/sentry/preprod/api/endpoints/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from .project_preprod_artifact_update import ProjectPreprodArtifactUpdateEndpoint
from .project_preprod_build_details import ProjectPreprodBuildDetailsEndpoint
from .project_preprod_check_for_updates import ProjectPreprodArtifactCheckForUpdatesEndpoint
from .project_preprod_distribution import ProjectPreprodDistributionEndpoint
from .project_preprod_size import (
ProjectPreprodSizeEndpoint,
ProjectPreprodSizeWithIdentifierEndpoint,
Expand Down Expand Up @@ -242,6 +243,11 @@
PreprodSnapshotRecompareEndpoint.as_view(),
name="sentry-api-0-organization-preprod-snapshots-recompare",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/preprodartifacts/(?P<head_artifact_id>[^/]+)/distribution/$",
ProjectPreprodDistributionEndpoint.as_view(),
name="sentry-api-0-organization-preprod-artifact-distribution",
),
]

preprod_internal_urlpatterns = [
Expand Down
1 change: 1 addition & 0 deletions static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ export type KnownSentryApiUrls =
| '/organizations/$organizationIdOrSlug/preprodartifacts/$artifactId/size-analysis/'
| '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/build-details/'
| '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/delete/'
| '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/distribution/'
| '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/private-install-details/'
| '/organizations/$organizationIdOrSlug/preprodartifacts/list-builds/'
| '/organizations/$organizationIdOrSlug/preprodartifacts/size-analysis/compare/$headArtifactId/$baseArtifactId/'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import orjson
from django.test import override_settings

from sentry.preprod.models import PreprodArtifact
from sentry.testutils.auth import generate_service_request_signature
from sentry.testutils.cases import TestCase

SHARED_SECRET_FOR_TESTS = "test-secret-key"


class ProjectPreprodDistributionEndpointTest(TestCase):
def setUp(self) -> None:
super().setUp()
self.file = self.create_file(name="test_artifact.apk", type="application/octet-stream")
self.artifact = self.create_preprod_artifact(
project=self.project,
file_id=self.file.id,
state=PreprodArtifact.ArtifactState.PROCESSED,
)

def _put(self, data, secret=SHARED_SECRET_FOR_TESTS):
url = f"/api/0/organizations/{self.organization.slug}/preprodartifacts/{self.artifact.id}/distribution/"
signature = generate_service_request_signature(url, data, [secret], "Launchpad")
return self.client.put(
url,
data=data,
content_type="application/json",
HTTP_AUTHORIZATION=f"rpcsignature {signature}",
)

@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
def test_bad_auth(self) -> None:
response = self._put(b"{}", secret="wrong secret")
assert response.status_code == 401

@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
def test_missing_fields(self) -> None:
response = self._put(b"{}")
assert response.status_code == 400

@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
def test_bad_json(self) -> None:
response = self._put(b"{")
assert response.status_code == 400

@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
def test_set_error(self) -> None:
response = self._put(
orjson.dumps({"error_code": 3, "error_message": "Unsupported artifact type"})
)

assert response.status_code == 200
self.artifact.refresh_from_db()
assert (
self.artifact.installable_app_error_code
== PreprodArtifact.InstallableAppErrorCode.PROCESSING_ERROR
)
assert self.artifact.installable_app_error_message == "Unsupported artifact type"

@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
def test_invalid_error_code(self) -> None:
response = self._put(orjson.dumps({"error_code": 99, "error_message": "bad"}))
assert response.status_code == 400

@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
def test_non_dict_json_body(self) -> None:
response = self._put(orjson.dumps([1, 2, 3]))
assert response.status_code == 400

@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
def test_requires_launchpad_rpc_authentication(self) -> None:
self.login_as(self.user)

url = f"/api/0/organizations/{self.organization.slug}/preprodartifacts/{self.artifact.id}/distribution/"
response = self.client.put(
url,
data=orjson.dumps({"error_code": 3, "error_message": "some error"}),
content_type="application/json",
)

assert response.status_code == 401
Loading