diff --git a/src/sentry/preprod/api/endpoints/project_preprod_distribution.py b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py new file mode 100644 index 00000000000000..cf10ada0e6db0b --- /dev/null +++ b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py @@ -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) + 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)}) diff --git a/src/sentry/preprod/api/endpoints/project_preprod_size.py b/src/sentry/preprod/api/endpoints/project_preprod_size.py index eeba8ba62b3565..15e0cb307cb5e2 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_size.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_size.py @@ -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 diff --git a/src/sentry/preprod/api/endpoints/urls.py b/src/sentry/preprod/api/endpoints/urls.py index 70d9d67164dc2a..d5cd9fdc5e1a28 100644 --- a/src/sentry/preprod/api/endpoints/urls.py +++ b/src/sentry/preprod/api/endpoints/urls.py @@ -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, @@ -242,6 +243,11 @@ PreprodSnapshotRecompareEndpoint.as_view(), name="sentry-api-0-organization-preprod-snapshots-recompare", ), + re_path( + r"^(?P[^/]+)/preprodartifacts/(?P[^/]+)/distribution/$", + ProjectPreprodDistributionEndpoint.as_view(), + name="sentry-api-0-organization-preprod-artifact-distribution", + ), ] preprod_internal_urlpatterns = [ diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index d64bfdfe5536e1..c33ec59d6b85c0 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -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/' diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py new file mode 100644 index 00000000000000..b2cb393c8534b6 --- /dev/null +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py @@ -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