From d3fbd9bdf896af8220b4c0f2d9af544d049a5160 Mon Sep 17 00:00:00 2001 From: Zach Collins Date: Tue, 18 Jun 2024 11:24:08 -0700 Subject: [PATCH] First pass converting seer api calls to using signature (#72486) Gotta adjust some tests. This addresses the need to sign not just to sentry, but from sentry. Unfortunately, given the way we did signing originally, we can't reuse the existing secret due to asymmetry. However, good news is that all of this may be replaceable with grpc in the near future. This addresses the immediate security issues and puts us on track to remove most of this later. See https://github.com/getsentry/seer/pull/774/files --- src/sentry/api/endpoints/group_ai_autofix.py | 85 +++++++++++-------- .../endpoints/group_autofix_setup_check.py | 23 +++-- .../api/endpoints/group_autofix_update.py | 3 +- .../organization_profiling_functions.py | 5 +- .../project_autofix_create_codebase_index.py | 27 ++++-- src/sentry/api/helpers/autofix.py | 29 ++++--- src/sentry/autofix/utils.py | 25 ++++-- src/sentry/conf/server.py | 5 +- src/sentry/options/defaults.py | 15 ++++ src/sentry/seer/breakpoints.py | 38 +++++++-- src/sentry/seer/signed_seer_api.py | 41 +++++++++ src/sentry/seer/similarity/backfill.py | 9 +- src/sentry/seer/similarity/similar_issues.py | 11 ++- src/sentry/statistical_detectors/detector.py | 20 +++-- .../test_group_similar_issues_embeddings.py | 16 ++-- tests/sentry/autofix/test_utils.py | 2 +- tests/sentry/seer/similarity/test_backfill.py | 5 ++ tests/sentry/seer/test_breakpoints.py | 6 +- tests/sentry/seer/test_signed_seer_api.py | 53 ++++++++++++ 19 files changed, 314 insertions(+), 104 deletions(-) create mode 100644 src/sentry/seer/signed_seer_api.py create mode 100644 tests/sentry/seer/test_signed_seer_api.py diff --git a/src/sentry/api/endpoints/group_ai_autofix.py b/src/sentry/api/endpoints/group_ai_autofix.py index 9146861349be3f..955fe429f122f0 100644 --- a/src/sentry/api/endpoints/group_ai_autofix.py +++ b/src/sentry/api/endpoints/group_ai_autofix.py @@ -19,6 +19,7 @@ from sentry.autofix.utils import get_autofix_repos_from_project_code_mappings from sentry.models.group import Group from sentry.models.user import User +from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.services.hybrid_cloud.user.service import user_service from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -85,47 +86,63 @@ def _call_autofix( instruction: str, timeout_secs: int, ): - response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/start", - data=orjson.dumps( - { - "organization_id": group.organization.id, - "project_id": group.project.id, - "repos": repos, - "issue": { - "id": group.id, - "title": group.title, - "short_id": group.qualified_short_id, - "events": [serialized_event], - }, - "instruction": instruction, - "timeout_secs": timeout_secs, - "last_updated": datetime.now().isoformat(), - "invoking_user": ( - { - "id": user.id, - "display_name": user.get_display_name(), - } - if not isinstance(user, AnonymousUser) - else None - ), + path = "/v1/automation/autofix/start" + body = orjson.dumps( + { + "organization_id": group.organization.id, + "project_id": group.project.id, + "repos": repos, + "issue": { + "id": group.id, + "title": group.title, + "short_id": group.qualified_short_id, + "events": [serialized_event], }, - option=orjson.OPT_NON_STR_KEYS, - ), - headers={"content-type": "application/json;charset=utf-8"}, + "instruction": instruction, + "timeout_secs": timeout_secs, + "last_updated": datetime.now().isoformat(), + "invoking_user": ( + { + "id": user.id, + "display_name": user.get_display_name(), + } + if not isinstance(user, AnonymousUser) + else None + ), + }, + option=orjson.OPT_NON_STR_KEYS, + ) + response = requests.post( + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() def _call_get_autofix_state(self, group_id: int) -> dict[str, Any] | None: + path = "/v1/automation/autofix/state" + body = orjson.dumps( + { + "group_id": group_id, + } + ) response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/state", - data=orjson.dumps( - { - "group_id": group_id, - } - ), - headers={"content-type": "application/json;charset=utf-8"}, + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() diff --git a/src/sentry/api/endpoints/group_autofix_setup_check.py b/src/sentry/api/endpoints/group_autofix_setup_check.py index cc1e30e64c056b..b092ccd4dc9048 100644 --- a/src/sentry/api/endpoints/group_autofix_setup_check.py +++ b/src/sentry/api/endpoints/group_autofix_setup_check.py @@ -21,6 +21,7 @@ from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.project import Project +from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.services.hybrid_cloud.integration import integration_service logger = logging.getLogger(__name__) @@ -67,15 +68,23 @@ def get_repos_and_access(project: Project) -> list[dict]: repos = get_autofix_repos_from_project_code_mappings(project) repos_and_access: list[dict] = [] + path = "/v1/automation/codebase/repo/check-access" for repo in repos: + body = orjson.dumps( + { + "repo": repo, + } + ) response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codebase/repo/check-access", - data=orjson.dumps( - { - "repo": repo, - } - ), - headers={"content-type": "application/json;charset=utf-8"}, + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() diff --git a/src/sentry/api/endpoints/group_autofix_update.py b/src/sentry/api/endpoints/group_autofix_update.py index 572682eb62eced..638f8668612d65 100644 --- a/src/sentry/api/endpoints/group_autofix_update.py +++ b/src/sentry/api/endpoints/group_autofix_update.py @@ -41,8 +41,9 @@ def post(self, request: Request, group: Group) -> Response: data={"error": "You must be authenticated to use this endpoint"}, ) + path = "/v1/automation/autofix/update" response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/update", + f"{settings.SEER_AUTOFIX_URL}{path}", data=orjson.dumps( { **request.data, diff --git a/src/sentry/api/endpoints/organization_profiling_functions.py b/src/sentry/api/endpoints/organization_profiling_functions.py index c6605fc817621c..7b236187691976 100644 --- a/src/sentry/api/endpoints/organization_profiling_functions.py +++ b/src/sentry/api/endpoints/organization_profiling_functions.py @@ -19,7 +19,7 @@ from sentry.models.organization import Organization from sentry.search.events.builder import ProfileTopFunctionsTimeseriesQueryBuilder from sentry.search.events.types import QueryBuilderConfig -from sentry.seer.breakpoints import BreakpointData, detect_breakpoints +from sentry.seer.breakpoints import BreakpointData, BreakpointRequest, detect_breakpoints from sentry.snuba import functions from sentry.snuba.dataset import Dataset from sentry.snuba.referrer import Referrer @@ -163,7 +163,7 @@ def get_trends_data(stats_data) -> list[BreakpointData]: if not stats_data: return [] - trends_request = { + trends_request: BreakpointRequest = { "data": { k: { "data": v[data["function"]]["data"], @@ -182,7 +182,6 @@ def get_trends_data(stats_data) -> list[BreakpointData]: if v[data["function"]]["data"] }, "sort": data["trend"].as_sort(), - "trendFunction": data["function"], } return detect_breakpoints(trends_request)["data"] diff --git a/src/sentry/api/endpoints/project_autofix_create_codebase_index.py b/src/sentry/api/endpoints/project_autofix_create_codebase_index.py index 4aaa057a3beea8..4d652878acc7ed 100644 --- a/src/sentry/api/endpoints/project_autofix_create_codebase_index.py +++ b/src/sentry/api/endpoints/project_autofix_create_codebase_index.py @@ -13,6 +13,7 @@ from sentry.api.bases.project import ProjectEndpoint, ProjectPermission from sentry.autofix.utils import get_autofix_repos_from_project_code_mappings from sentry.models.project import Project +from sentry.seer.signed_seer_api import sign_with_seer_secret logger = logging.getLogger(__name__) @@ -41,18 +42,26 @@ def post(self, request: Request, project: Project) -> Response: Create a codebase index for for a project's repositories, uses the code mapping to determine which repositories to index """ repos = get_autofix_repos_from_project_code_mappings(project) + path = "/v1/automation/codebase/index/create" for repo in repos: + body = orjson.dumps( + { + "organization_id": project.organization.id, + "project_id": project.id, + "repo": repo, + } + ) response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codebase/index/create", - data=orjson.dumps( - { - "organization_id": project.organization.id, - "project_id": project.id, - "repo": repo, - } - ), - headers={"content-type": "application/json;charset=utf-8"}, + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() diff --git a/src/sentry/api/helpers/autofix.py b/src/sentry/api/helpers/autofix.py index 61758f5b171df2..ae9ca2c03adc43 100644 --- a/src/sentry/api/helpers/autofix.py +++ b/src/sentry/api/helpers/autofix.py @@ -5,6 +5,7 @@ from django.conf import settings from sentry.autofix.utils import get_autofix_repos_from_project_code_mappings +from sentry.seer.signed_seer_api import sign_with_seer_secret class AutofixCodebaseIndexingStatus(str, enum.Enum): @@ -20,18 +21,26 @@ def get_project_codebase_indexing_status(project): return None statuses = [] + path = "/v1/automation/codebase/index/status" for repo in repos: + body = orjson.dumps( + { + "organization_id": project.organization.id, + "project_id": project.id, + "repo": repo, + }, + option=orjson.OPT_UTC_Z, + ) response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codebase/index/status", - data=orjson.dumps( - { - "organization_id": project.organization.id, - "project_id": project.id, - "repo": repo, - }, - option=orjson.OPT_UTC_Z, - ), - headers={"content-type": "application/json;charset=utf-8"}, + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() diff --git a/src/sentry/autofix/utils.py b/src/sentry/autofix/utils.py index 36816eaa637427..27dc946819654c 100644 --- a/src/sentry/autofix/utils.py +++ b/src/sentry/autofix/utils.py @@ -6,6 +6,7 @@ from sentry.integrations.utils.code_mapping import get_sorted_code_mapping_configs from sentry.models.project import Project from sentry.models.repository import Repository +from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.utils import json @@ -51,15 +52,23 @@ def get_autofix_repos_from_project_code_mappings(project: Project) -> list[dict] def get_autofix_state_from_pr_id(provider: str, pr_id: int) -> AutofixState | None: + path = "/v1/automation/autofix/state/pr" + body = json.dumps( + { + "provider": provider, + "pr_id": pr_id, + } + ).encode("utf-8") response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/state/pr", - data=json.dumps( - { - "provider": provider, - "pr_id": pr_id, - } - ), - headers={"content-type": "application/json;charset=utf-8"}, + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index b1545d48ec3e57..d5099c9f00ca73 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -673,8 +673,11 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: # Timeout for RPC requests between regions RPC_TIMEOUT = 5.0 -# Shared secret used to sign cross-region RPC requests with the seer microservice. +# TODO: Replace both of these secrets with mutual TLS and simplify our rpc channels. +# Shared secret used to sign cross-region RPC requests from the seer microservice. SEER_RPC_SHARED_SECRET: list[str] | None = None +# Shared secret used to sign cross-region RPC requests to the seer microservice. +SEER_API_SHARED_SECRET: str = "" # The protocol, host and port for control silo # Usecases include sending requests to the Integration Proxy Endpoint and RPC requests. diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 189d3bed8d3396..c72cc62ef36840 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2550,3 +2550,18 @@ default=50, flags=FLAG_AUTOMATOR_MODIFIABLE, ) + +# Enable orjson in the occurrence_consumer.process_[message|batch] +register( + "issues.occurrence_consumer.use_orjson", + type=Bool, + default=False, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) + +# Controls the rate of using the sentry api shared secret for communicating to sentry. +register( + "seer.api.use-shared-secret", + default=0.0, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) diff --git a/src/sentry/seer/breakpoints.py b/src/sentry/seer/breakpoints.py index 252e4a4c8b656c..576a63748b5583 100644 --- a/src/sentry/seer/breakpoints.py +++ b/src/sentry/seer/breakpoints.py @@ -1,11 +1,13 @@ import logging -from typing import TypedDict +from collections.abc import Mapping +from typing import NotRequired, TypedDict import sentry_sdk from django.conf import settings from urllib3 import Retry from sentry.net.http import connection_from_url +from sentry.seer.signed_seer_api import make_signed_seer_api_request from sentry.utils import json logger = logging.getLogger(__name__) @@ -44,12 +46,36 @@ class BreakpointResponse(TypedDict): ) -def detect_breakpoints(breakpoint_request) -> BreakpointResponse: - response = seer_breakpoint_connection_pool.urlopen( - "POST", +# TODO: Source these from shared schema repository +class BreakpointRequest(TypedDict): + data: "Mapping[str, BreakpointTransaction]" + sort: NotRequired[str] + allow_midpoint: NotRequired[str] + validate_tail_hours: NotRequired[int] + trend_percentage: NotRequired[float] + min_change: NotRequired[float] + + +class BreakpointTransaction(TypedDict): + data: "list[SnubaTSEntry]" + request_start: int + request_end: int + data_start: int + data_end: int + + +class SnubaMetadata(TypedDict): + count: float + + +SnubaTSEntry = tuple[int, tuple[SnubaMetadata]] + + +def detect_breakpoints(breakpoint_request: BreakpointRequest) -> BreakpointResponse: + response = make_signed_seer_api_request( + seer_breakpoint_connection_pool, "/trends/breakpoint-detector", - body=json.dumps(breakpoint_request), - headers={"content-type": "application/json;charset=utf-8"}, + json.dumps(breakpoint_request).encode("utf-8"), ) if response.status >= 200 and response.status < 300: diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py new file mode 100644 index 00000000000000..cdec9c2a94646c --- /dev/null +++ b/src/sentry/seer/signed_seer_api.py @@ -0,0 +1,41 @@ +import hashlib +import hmac +from random import random +from typing import Any + +from django.conf import settings +from urllib3 import BaseHTTPResponse, HTTPConnectionPool + +from sentry import options + + +def make_signed_seer_api_request( + connection_pool: HTTPConnectionPool, path: str, body: bytes, timeout: int | None = None +) -> BaseHTTPResponse: + host = connection_pool.host + if connection_pool.port: + host += ":" + str(connection_pool.port) + auth_headers = sign_with_seer_secret(f"{connection_pool.scheme}://{host}{path}", body) + + timeout_options: dict[str, Any] = {} + if timeout: + timeout_options["timeout"] = timeout + + return connection_pool.urlopen( + "POST", + path, + body=body, + headers={"content-type": "application/json;charset=utf-8", **auth_headers}, + **timeout_options, + ) + + +def sign_with_seer_secret(url: str, body: bytes): + auth_headers: dict[str, str] = {} + if random() < options.get("seer.api.use-shared-secret") and settings.SEER_API_SHARED_SECRET: + signature_input = b"%s:%s" % (url.encode("utf8"), body) + signature = hmac.new( + settings.SEER_API_SHARED_SECRET.encode("utf-8"), signature_input, hashlib.sha256 + ).hexdigest() + auth_headers["Authorization"] = f"Rpcsignature rpc0:{signature}" + return auth_headers diff --git a/src/sentry/seer/similarity/backfill.py b/src/sentry/seer/similarity/backfill.py index f83b831ff7bba0..909263fe93db45 100644 --- a/src/sentry/seer/similarity/backfill.py +++ b/src/sentry/seer/similarity/backfill.py @@ -6,6 +6,7 @@ from sentry.conf.server import SEER_GROUPING_RECORDS_DELETE_URL, SEER_GROUPING_RECORDS_URL from sentry.net.http import connection_from_url +from sentry.seer.signed_seer_api import make_signed_seer_api_request from sentry.seer.similarity.types import RawSeerSimilarIssueData from sentry.utils import json @@ -55,11 +56,10 @@ def post_bulk_grouping_records( } try: - response = seer_grouping_connection_pool.urlopen( - "POST", + response = make_signed_seer_api_request( + seer_grouping_connection_pool, SEER_GROUPING_RECORDS_URL, - body=json.dumps(grouping_records_request), - headers={"Content-Type": "application/json;charset=utf-8"}, + body=json.dumps(grouping_records_request).encode("utf-8"), timeout=POST_BULK_GROUPING_RECORDS_TIMEOUT, ) except ReadTimeoutError: @@ -80,6 +80,7 @@ def delete_grouping_records( project_id: int, ) -> bool: try: + # TODO: Move this over to POST json_api implementation response = seer_grouping_connection_pool.urlopen( "GET", f"{SEER_GROUPING_RECORDS_DELETE_URL}/{project_id}", diff --git a/src/sentry/seer/similarity/similar_issues.py b/src/sentry/seer/similarity/similar_issues.py index c20d93f2d9a256..9765fe97aa8926 100644 --- a/src/sentry/seer/similarity/similar_issues.py +++ b/src/sentry/seer/similarity/similar_issues.py @@ -4,6 +4,7 @@ from sentry.conf.server import SEER_MAX_GROUPING_DISTANCE, SEER_SIMILAR_ISSUES_URL from sentry.net.http import connection_from_url +from sentry.seer.signed_seer_api import make_signed_seer_api_request from sentry.seer.similarity.types import ( IncompleteSeerDataError, SeerSimilarIssueData, @@ -31,11 +32,12 @@ def get_similarity_data_from_seer( sorted in order of descending similarity. """ - response = seer_grouping_connection_pool.urlopen( - "POST", + response = make_signed_seer_api_request( + seer_grouping_connection_pool, SEER_SIMILAR_ISSUES_URL, - body=json.dumps({"threshold": SEER_MAX_GROUPING_DISTANCE, **similar_issues_request}), - headers={"Content-Type": "application/json;charset=utf-8"}, + json.dumps({"threshold": SEER_MAX_GROUPING_DISTANCE, **similar_issues_request}).encode( + "utf8" + ), ) try: @@ -50,6 +52,7 @@ def get_similarity_data_from_seer( extra={ "request_params": similar_issues_request, "response_data": response.data, + "response_code": response.status, }, ) return [] diff --git a/src/sentry/statistical_detectors/detector.py b/src/sentry/statistical_detectors/detector.py index 6a0f72660399bb..032d0fb7df7fbf 100644 --- a/src/sentry/statistical_detectors/detector.py +++ b/src/sentry/statistical_detectors/detector.py @@ -25,7 +25,12 @@ get_regression_groups, ) from sentry.search.events.fields import get_function_alias -from sentry.seer.breakpoints import BreakpointData, detect_breakpoints +from sentry.seer.breakpoints import ( + BreakpointData, + BreakpointRequest, + BreakpointTransaction, + detect_breakpoints, +) from sentry.statistical_detectors.algorithm import DetectorAlgorithm from sentry.statistical_detectors.base import DetectorPayload, DetectorState, TrendType from sentry.statistical_detectors.issue_platform_adapter import fingerprint_regression @@ -199,10 +204,11 @@ def detect_regressions( serializer = SnubaTSResultSerializer(None, None, None) for chunk in chunked(cls.all_timeseries(objects, start, function), timeseries_per_batch): - data = {} + data: dict[str, BreakpointTransaction] = {} for project_id, object_name, result in chunk: serialized = serializer.serialize(result, get_function_alias(function)) - data[f"{project_id},{object_name}"] = { + + transaction: BreakpointTransaction = { "data": serialized["data"], "data_start": serialized["start"], "data_end": serialized["end"], @@ -211,11 +217,13 @@ def detect_regressions( "request_end": serialized["end"], } - request = { + data[f"{project_id},{object_name}"] = transaction + + request: BreakpointRequest = { "data": data, "sort": "-trend_percentage()", - "min_change()": cls.min_change, - # "trend_percentage()": 0.5, # require a minimum 50% increase + "min_change": cls.min_change, + # "trend_percentage": 0.5, # require a minimum 50% increase # "validate_tail_hours": 6, # Disable the fall back to use the midpoint as the breakpoint # which was originally intended to detect a gradual regression diff --git a/tests/sentry/api/endpoints/test_group_similar_issues_embeddings.py b/tests/sentry/api/endpoints/test_group_similar_issues_embeddings.py index 2f2bf9e5e26068..0defb49cf843a7 100644 --- a/tests/sentry/api/endpoints/test_group_similar_issues_embeddings.py +++ b/tests/sentry/api/endpoints/test_group_similar_issues_embeddings.py @@ -230,8 +230,8 @@ def test_simple(self, mock_logger, mock_seer_request, mock_metrics): mock_seer_request.assert_called_with( "POST", SEER_SIMILAR_ISSUES_URL, - body=orjson.dumps(expected_seer_request_params).decode(), - headers={"Content-Type": "application/json;charset=utf-8"}, + body=orjson.dumps(expected_seer_request_params), + headers={"content-type": "application/json;charset=utf-8"}, ) expected_seer_request_params["group_message"] = expected_seer_request_params.pop("message") @@ -514,8 +514,8 @@ def test_no_optional_params(self, mock_seer_request): "exception_type": "ZeroDivisionError", "read_only": True, }, - ).decode(), - headers={"Content-Type": "application/json;charset=utf-8"}, + ), + headers={"content-type": "application/json;charset=utf-8"}, ) # Include k @@ -541,8 +541,8 @@ def test_no_optional_params(self, mock_seer_request): "read_only": True, "k": 1, }, - ).decode(), - headers={"Content-Type": "application/json;charset=utf-8"}, + ), + headers={"content-type": "application/json;charset=utf-8"}, ) # Include threshold @@ -567,6 +567,6 @@ def test_no_optional_params(self, mock_seer_request): "exception_type": "ZeroDivisionError", "read_only": True, }, - ).decode(), - headers={"Content-Type": "application/json;charset=utf-8"}, + ), + headers={"content-type": "application/json;charset=utf-8"}, ) diff --git a/tests/sentry/autofix/test_utils.py b/tests/sentry/autofix/test_utils.py index 884741a3dcaa5b..04d8f8f990499d 100644 --- a/tests/sentry/autofix/test_utils.py +++ b/tests/sentry/autofix/test_utils.py @@ -52,7 +52,7 @@ def test_get_autofix_state_from_pr_id_success(self, mock_post): mock_post.assert_called_once_with( f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/state/pr", - data=json.dumps({"provider": "github", "pr_id": 1}), + data=json.dumps({"provider": "github", "pr_id": 1}).encode("utf-8"), headers={"content-type": "application/json;charset=utf-8"}, ) diff --git a/tests/sentry/seer/similarity/test_backfill.py b/tests/sentry/seer/similarity/test_backfill.py index 234a02b01cf8b6..87b2e2c81fb958 100644 --- a/tests/sentry/seer/similarity/test_backfill.py +++ b/tests/sentry/seer/similarity/test_backfill.py @@ -2,6 +2,7 @@ from unittest import mock from unittest.mock import MagicMock +import pytest from django.conf import settings from urllib3.connectionpool import ConnectionPool from urllib3.exceptions import ReadTimeoutError @@ -37,6 +38,7 @@ } +@pytest.mark.django_db @mock.patch("sentry.seer.similarity.backfill.logger") @mock.patch("sentry.seer.similarity.backfill.seer_grouping_connection_pool.urlopen") def test_post_bulk_grouping_records_success(mock_seer_request: MagicMock, mock_logger: MagicMock): @@ -60,6 +62,7 @@ def test_post_bulk_grouping_records_success(mock_seer_request: MagicMock, mock_l ) +@pytest.mark.django_db @mock.patch("sentry.seer.similarity.backfill.logger") @mock.patch("sentry.seer.similarity.backfill.seer_grouping_connection_pool.urlopen") def test_post_bulk_grouping_records_timeout(mock_seer_request: MagicMock, mock_logger: MagicMock): @@ -82,6 +85,7 @@ def test_post_bulk_grouping_records_timeout(mock_seer_request: MagicMock, mock_l ) +@pytest.mark.django_db @mock.patch("sentry.seer.similarity.backfill.logger") @mock.patch("sentry.seer.similarity.backfill.seer_grouping_connection_pool.urlopen") def test_post_bulk_grouping_records_failure(mock_seer_request: MagicMock, mock_logger: MagicMock): @@ -105,6 +109,7 @@ def test_post_bulk_grouping_records_failure(mock_seer_request: MagicMock, mock_l ) +@pytest.mark.django_db @mock.patch("sentry.seer.similarity.backfill.seer_grouping_connection_pool.urlopen") def test_post_bulk_grouping_records_empty_data(mock_seer_request: MagicMock): """Test that function handles empty data. This should not happen, but we do not want to error if it does.""" diff --git a/tests/sentry/seer/test_breakpoints.py b/tests/sentry/seer/test_breakpoints.py index 70d1aae21bc4fd..9dc5d6c9c2f689 100644 --- a/tests/sentry/seer/test_breakpoints.py +++ b/tests/sentry/seer/test_breakpoints.py @@ -7,6 +7,7 @@ from sentry.utils import json +@pytest.mark.django_db @mock.patch("sentry.seer.breakpoints.seer_breakpoint_connection_pool.urlopen") def test_detect_breakpoints(mock_urlopen): data = { @@ -27,9 +28,10 @@ def test_detect_breakpoints(mock_urlopen): } mock_urlopen.return_value = HTTPResponse(json.dumps(data), status=200) - assert detect_breakpoints({}) == data + assert detect_breakpoints({"data": {}}) == data +@pytest.mark.django_db @pytest.mark.parametrize( ["body", "status"], [ @@ -43,5 +45,5 @@ def test_detect_breakpoints(mock_urlopen): def test_detect_breakpoints_errors(mock_urlopen, mock_capture_exception, body, status): mock_urlopen.return_value = HTTPResponse(body, status=status) - assert detect_breakpoints({}) == {"data": []} + assert detect_breakpoints({"data": {}}) == {"data": []} assert mock_capture_exception.called diff --git a/tests/sentry/seer/test_signed_seer_api.py b/tests/sentry/seer/test_signed_seer_api.py new file mode 100644 index 00000000000000..fab6b29781d74c --- /dev/null +++ b/tests/sentry/seer/test_signed_seer_api.py @@ -0,0 +1,53 @@ +from unittest.mock import Mock + +import pytest +from django.test import override_settings + +from sentry.seer.signed_seer_api import make_signed_seer_api_request +from sentry.testutils.helpers import override_options + + +@pytest.mark.django_db +def test_make_signed_seer_api_request(): + body = b'{"b": 12, "thing": "thing"}' + + def url_request(timeout: int | None = None): + mock = Mock() + mock.host = "localhost" + mock.port = None + mock.scheme = "http" + with override_settings(SEER_API_SHARED_SECRET="secret-one"): + make_signed_seer_api_request( + mock, + path="/v0/some/url", + body=body, + timeout=timeout, + ) + + return mock.urlopen + + url_request().assert_called_once_with( + "POST", + "/v0/some/url", + body=body, + headers={"content-type": "application/json;charset=utf-8"}, + ) + + url_request(timeout=5).assert_called_once_with( + "POST", + "/v0/some/url", + body=body, + headers={"content-type": "application/json;charset=utf-8"}, + timeout=5, + ) + + with override_options({"seer.api.use-shared-secret": 1.0}): + url_request().assert_called_once_with( + "POST", + "/v0/some/url", + body=body, + headers={ + "content-type": "application/json;charset=utf-8", + "Authorization": "Rpcsignature rpc0:96f23d5b3df807a9dc91f090078a46c00e17fe8b0bc7ef08c9391fa8b37a66b5", + }, + )