Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/sentry/preprod/api/endpoints/builds.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from sentry.preprod.artifact_search import queryset_for_query
from sentry.preprod.models import PreprodArtifact
from sentry.preprod.quotas import get_size_retention_cutoff

ERR_FEATURE_REQUIRED = "Feature {} is not enabled for the organization."

Expand Down Expand Up @@ -56,8 +57,11 @@ def get(self, request: Request, organization: Organization) -> Response:
# params on purpose.

query = request.GET.get("query", "").strip()
cutoff = get_size_retention_cutoff(organization)

try:
queryset = queryset_for_query(query, organization)
queryset = queryset.filter(date_added__gte=cutoff)
if start:
queryset = queryset.filter(date_added__gte=start)
if end:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
)
from sentry.preprod.api.validators import PreprodListBuildsValidator
from sentry.preprod.models import PreprodArtifact
from sentry.preprod.quotas import get_size_retention_cutoff
from sentry.preprod.utils import parse_release_version

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -85,12 +86,14 @@ def get(self, request: Request, organization: Organization) -> Response:
except InvalidParams:
raise ParseError(detail="Invalid date range")

cutoff = get_size_retention_cutoff(organization)

queryset = (
PreprodArtifact.objects.select_related(
"project", "build_configuration", "commit_comparison", "mobile_app_info"
)
.prefetch_related("preprodartifactsizemetrics_set")
.filter(project_id__in=project_ids)
.filter(project_id__in=project_ids, date_added__gte=cutoff)
)

if start and end:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
transform_preprod_artifact_to_build_details,
)
from sentry.preprod.models import PreprodArtifact
from sentry.preprod.quotas import get_size_retention_cutoff

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -62,6 +63,10 @@ def get(
):
return Response({"error": "Feature not enabled"}, status=403)

cutoff = get_size_retention_cutoff(project.organization)
if head_artifact.date_added < cutoff:
return Response({"detail": "This build's size data has expired."}, status=404)

if head_artifact.state == PreprodArtifact.ArtifactState.FAILED:
return Response({"error": head_artifact.error_message}, status=400)
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from sentry.preprod.analytics import PreprodApiPrPageSizeAnalysisDownloadEvent
from sentry.preprod.api.bases.preprod_artifact_endpoint import PreprodArtifactResourceDoesNotExist
from sentry.preprod.models import PreprodArtifact
from sentry.preprod.quotas import get_size_retention_cutoff
from sentry.preprod.size_analysis.download import (
SizeAnalysisError,
get_size_analysis_error_response,
Expand Down Expand Up @@ -62,6 +63,10 @@ def get(
except (PreprodArtifact.DoesNotExist, ValueError):
raise PreprodArtifactResourceDoesNotExist

cutoff = get_size_retention_cutoff(organization)
if artifact.date_added < cutoff:
return Response({"detail": "This build's size data has expired."}, status=404)

all_size_metrics = list(artifact.get_size_metrics())

try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
PreprodArtifactSizeComparison,
PreprodArtifactSizeMetrics,
)
from sentry.preprod.quotas import get_size_retention_cutoff
from sentry.preprod.size_analysis.tasks import manual_size_analysis_comparison
from sentry.preprod.size_analysis.utils import build_size_metrics_map, can_compare_size_metrics

Expand Down Expand Up @@ -87,6 +88,10 @@ def get(
):
return Response({"detail": "Feature not enabled"}, status=403)

cutoff = get_size_retention_cutoff(project.organization)
if head_artifact.date_added < cutoff or base_artifact.date_added < cutoff:
return Response({"detail": "This build's size data has expired."}, status=404)

logger.info(
"preprod.size_analysis.compare.api.get",
extra={"head_artifact_id": head_artifact_id, "base_artifact_id": base_artifact_id},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from sentry.models.project import Project
from sentry.preprod.analytics import PreprodArtifactApiSizeAnalysisCompareDownloadEvent
from sentry.preprod.models import PreprodArtifactSizeComparison
from sentry.preprod.quotas import get_size_retention_cutoff

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -68,7 +69,9 @@ def get(
)

try:
comparison_obj = PreprodArtifactSizeComparison.objects.get(
comparison_obj = PreprodArtifactSizeComparison.objects.select_related(
"head_size_analysis__preprod_artifact"
).get(
head_size_analysis_id=head_size_metric_id,
base_size_analysis_id=base_size_metric_id,
organization_id=project.organization_id,
Expand All @@ -83,6 +86,11 @@ def get(
)
return Response({"detail": "Comparison not found."}, status=404)

cutoff = get_size_retention_cutoff(project.organization)
artifact = comparison_obj.head_size_analysis.preprod_artifact
if artifact.date_added < cutoff:
return Response({"detail": "This build's size data has expired."}, status=404)
Copy link
Contributor

Choose a reason for hiding this comment

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

Compare download only checks head artifact retention, not base

Medium Severity

The retention check in the compare download endpoint only validates the head artifact's date_added against the cutoff, while the sibling compare endpoint at project_preprod_size_analysis_compare.py correctly checks both head_artifact.date_added < cutoff or base_artifact.date_added < cutoff. If the base artifact's data has expired but the head hasn't, this endpoint will still serve the comparison data, bypassing the intended data retention policy.

Additional Locations (1)

Fix in Cursor Fix in Web


if comparison_obj.file_id is None:
logger.info(
"preprod.size_analysis.compare.api.download.no_file_id",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from sentry.preprod.analytics import PreprodArtifactApiSizeAnalysisDownloadEvent
from sentry.preprod.api.bases.preprod_artifact_endpoint import PreprodArtifactEndpoint
from sentry.preprod.models import PreprodArtifact
from sentry.preprod.quotas import get_size_retention_cutoff
from sentry.preprod.size_analysis.download import (
SizeAnalysisError,
get_size_analysis_error_response,
Expand Down Expand Up @@ -62,6 +63,10 @@ def get(
):
return Response({"detail": "Feature not enabled"}, status=403)

cutoff = get_size_retention_cutoff(project.organization)
if head_artifact.date_added < cutoff:
return Response({"detail": "This build's size data has expired."}, status=404)

all_size_metrics = list(head_artifact.get_size_metrics())

try:
Expand Down
15 changes: 15 additions & 0 deletions src/sentry/preprod/quotas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import logging
from collections.abc import Callable
from datetime import datetime, timedelta
from typing import Any

import sentry_sdk
from django.contrib.auth.models import AnonymousUser
from django.utils import timezone

from sentry import features, quotas
from sentry.constants import DataCategory
Expand All @@ -18,6 +20,19 @@

logger = logging.getLogger(__name__)

DEFAULT_SIZE_RETENTION_DAYS = 90


def get_size_retention_cutoff(organization: Organization) -> datetime:
retention_days = (
quotas.backend.get_event_retention(
organization=organization, category=DataCategory.SIZE_ANALYSIS
)
or DEFAULT_SIZE_RETENTION_DAYS
)
return timezone.now() - timedelta(days=retention_days)


SIZE_ENABLED_KEY = "sentry:preprod_size_enabled_by_customer"
SIZE_ENABLED_QUERY_KEY = "sentry:preprod_size_enabled_query"
DISTRIBUTION_ENABLED_KEY = "sentry:preprod_distribution_enabled_by_customer"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from datetime import timedelta
from unittest.mock import patch

from django.urls import reverse
from django.utils import timezone

from sentry.preprod.models import PreprodArtifact, PreprodArtifactSizeMetrics
from sentry.testutils.cases import TestCase
Expand Down Expand Up @@ -172,3 +176,19 @@ def test_size_analysis_download_no_analysis_file(self) -> None:
assert (
response.json()["detail"] == "Size analysis completed but results are unavailable"
)

def test_returns_404_for_expired_artifact(self) -> None:
with (
self.feature("organizations:pr-page"),
patch(
"sentry.preprod.api.endpoints.pull_request.organization_pullrequest_size_analysis_download.get_size_retention_cutoff"
) as mock_cutoff,
):
mock_cutoff.return_value = timezone.now() - timedelta(days=30)
self.preprod_artifact.date_added = timezone.now() - timedelta(days=60)
self.preprod_artifact.save()

url = self._get_url()
response = self.client.get(url)
assert response.status_code == 404
assert response.json()["detail"] == "This build's size data has expired."
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from datetime import timedelta
from unittest.mock import patch

from django.test import override_settings
from django.urls import reverse
from django.utils import timezone

from sentry.preprod.models import (
PreprodArtifact,
Expand Down Expand Up @@ -771,3 +773,39 @@ def test_post_comparison_different_build_configurations(self):
status_code=400,
)
assert response.data["detail"] == "Head and base build configurations must be the same."

@override_settings(SENTRY_FEATURES={"organizations:preprod-frontend-routes": True})
@patch(
"sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare.get_size_retention_cutoff"
)
def test_get_returns_404_for_expired_head_artifact(self, mock_cutoff):
mock_cutoff.return_value = timezone.now() - timedelta(days=30)
self.head_artifact.date_added = timezone.now() - timedelta(days=60)
self.head_artifact.save()

response = self.get_response(
self.organization.slug,
self.project.slug,
self.head_artifact.id,
self.base_artifact.id,
)
assert response.status_code == 404
assert response.data["detail"] == "This build's size data has expired."

@override_settings(SENTRY_FEATURES={"organizations:preprod-frontend-routes": True})
@patch(
"sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare.get_size_retention_cutoff"
)
def test_get_returns_404_for_expired_base_artifact(self, mock_cutoff):
mock_cutoff.return_value = timezone.now() - timedelta(days=30)
self.base_artifact.date_added = timezone.now() - timedelta(days=60)
self.base_artifact.save()

response = self.get_response(
self.organization.slug,
self.project.slug,
self.head_artifact.id,
self.base_artifact.id,
)
assert response.status_code == 404
assert response.data["detail"] == "This build's size data has expired."
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from datetime import timedelta
from unittest.mock import patch

from django.test import override_settings
from django.urls import reverse
from django.utils import timezone

from sentry.preprod.models import (
PreprodArtifact,
Expand Down Expand Up @@ -150,6 +154,27 @@ def test_download_size_analysis_comparison_file_retrieval_failure(self) -> None:
assert response.status_code == 404
assert response.json()["detail"] == "Comparison not found."

@patch(
"sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare_download.get_size_retention_cutoff"
)
def test_returns_404_for_expired_artifact(self, mock_cutoff) -> None:
mock_cutoff.return_value = timezone.now() - timedelta(days=30)
self.head_artifact.date_added = timezone.now() - timedelta(days=60)
self.head_artifact.save()

self.create_preprod_artifact_size_comparison(
organization=self.organization,
head_size_analysis=self.head_size_metrics,
base_size_analysis=self.base_size_metrics,
state=PreprodArtifactSizeComparison.State.SUCCESS,
file_id=self.comparison_file.id,
)

url = self._get_url()
response = self.client.get(url)
assert response.status_code == 404
assert response.json()["detail"] == "This build's size data has expired."

def test_download_size_analysis_comparison_different_organization(self) -> None:
other_user = self.create_user()
other_org = self.create_organization(owner=other_user)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from datetime import timedelta
from io import BytesIO
from unittest.mock import patch

from django.test import override_settings
from django.utils import timezone

from sentry.preprod.models import PreprodArtifact, PreprodArtifactSizeMetrics
from sentry.testutils.cases import APITestCase
Expand Down Expand Up @@ -125,3 +128,15 @@ def test_completed_with_file_returns_200(self):
if hasattr(response, "streaming_content")
else response.content
)

@patch(
"sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_download.get_size_retention_cutoff"
)
def test_returns_404_for_expired_artifact(self, mock_cutoff):
mock_cutoff.return_value = timezone.now() - timedelta(days=30)
self.artifact.date_added = timezone.now() - timedelta(days=60)
self.artifact.save()

response = self.get_response(self.organization.slug, self.project.slug, self.artifact.id)
assert response.status_code == 404
assert response.data["detail"] == "This build's size data has expired."
15 changes: 14 additions & 1 deletion tests/sentry/preprod/api/endpoints/test_builds.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from unittest.mock import ANY
from unittest.mock import ANY, patch

import pytest
from django.urls import reverse
Expand Down Expand Up @@ -845,6 +845,19 @@ def test_free_text_search_with_structured_filter(self) -> None:
assert len(data) == 1
assert data[0]["app_info"]["app_id"] == "com.example.android"

@with_feature("organizations:preprod-frontend-routes")
@patch("sentry.preprod.api.endpoints.builds.get_size_retention_cutoff")
def test_excludes_expired_artifacts(self, mock_cutoff) -> None:
mock_cutoff.return_value = before_now(days=30)
self.create_preprod_artifact(app_id="recent.app", date_added=before_now(days=10))
self.create_preprod_artifact(app_id="expired.app", date_added=before_now(days=60))

response = self._request({})
self._assert_is_successful(response)
data = response.json()
assert len(data) == 1
assert data[0]["app_info"]["app_id"] == "recent.app"


class QuerysetForQueryTest(APITestCase):
"""Tests for the queryset_for_query, artifact_in_queryset, and artifact_matches_query functions."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -742,3 +742,33 @@ def test_list_builds_filter_project_and_other_criteria(self) -> None:
builds = response.json()["builds"]
assert len(builds) == 1
assert builds[0]["app_info"]["app_id"] == "com.example.app4"

@patch(
"sentry.preprod.api.endpoints.organization_preprod_list_builds.get_size_retention_cutoff"
)
def test_list_builds_excludes_expired_artifacts(self, mock_cutoff) -> None:
now = timezone.now()
mock_cutoff.return_value = now - timedelta(days=30)

self.artifact1.date_added = now - timedelta(days=10)
self.artifact1.save()
self.artifact2.date_added = now - timedelta(days=60)
self.artifact2.save()
self.artifact3.date_added = now - timedelta(days=5)
self.artifact3.save()
self.artifact4.date_added = now - timedelta(days=40)
self.artifact4.save()

response = self.client.get(
f"{self._get_url()}?project={self.project.id}&project={self.project2.id}",
format="json",
HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}",
)

assert response.status_code == 200
builds = response.json()["builds"]
returned_ids = {b["id"] for b in builds}
assert str(self.artifact1.id) in returned_ids
assert str(self.artifact3.id) in returned_ids
assert str(self.artifact2.id) not in returned_ids
assert str(self.artifact4.id) not in returned_ids
Loading
Loading