diff --git a/apps/api/src/api/v1/platform/events.py b/apps/api/src/api/v1/platform/events.py index bc4dccd6..2ea672dd 100644 --- a/apps/api/src/api/v1/platform/events.py +++ b/apps/api/src/api/v1/platform/events.py @@ -17,6 +17,7 @@ from src.core.awooop_operator_auth import ( AwoooPOperatorPrincipal, verify_awooop_operator, ) +from src.core.context import clear_project_context, get_current_project_context, set_project_context from src.services.channel_event_dossier_service import ( RecurrenceWorkItemHandoffKind, RecurrenceWorkItemMode, @@ -37,6 +38,28 @@ from src.services.platform_operator_service import list_recent_channel_events router = APIRouter() +class _BodyProjectContext: + """Temporarily promote body project_id into the request project context.""" + + def __init__(self, project_id: str | None) -> None: + self._project_id = project_id.strip() if project_id else None + self._tokens = None + + def __enter__(self) -> None: + if not self._project_id: + return + current = get_current_project_context() + self._tokens = set_project_context( + project_id=self._project_id, + source="request.body", + request_id=current.get("request_id"), + ) + + def __exit__(self, exc_type, exc, tb) -> None: + if self._tokens is not None: + clear_project_context(self._tokens) + + class ChannelEventItem(BaseModel): event_id: UUID project_id: str @@ -524,16 +547,17 @@ async def review_source_correlation_work_item( request: SourceCorrelationReviewDecisionRequest, ) -> dict[str, Any]: try: - return await fetch_source_correlation_review_decision( - project_id=request.project_id, - work_item_id=request.work_item_id, - decision=request.decision, - target_incident_id=request.target_incident_id, - reviewer_id=request.reviewer_id, - operator_note=request.operator_note, - provider=request.provider, - limit=request.limit, - ) + with _BodyProjectContext(request.project_id): + return await fetch_source_correlation_review_decision( + project_id=request.project_id, + work_item_id=request.work_item_id, + decision=request.decision, + target_incident_id=request.target_incident_id, + reviewer_id=request.reviewer_id, + operator_note=request.operator_note, + provider=request.provider, + limit=request.limit, + ) except RecurrenceWorkItemNotFoundError as exc: raise HTTPException( status_code=404, @@ -555,14 +579,15 @@ async def apply_source_correlation_work_item( request: SourceCorrelationApplyRequest, ) -> dict[str, Any]: try: - return await fetch_source_correlation_apply( - project_id=request.project_id, - work_item_id=request.work_item_id, - reviewer_id=request.reviewer_id, - operator_note=request.operator_note, - provider=request.provider, - limit=request.limit, - ) + with _BodyProjectContext(request.project_id): + return await fetch_source_correlation_apply( + project_id=request.project_id, + work_item_id=request.work_item_id, + reviewer_id=request.reviewer_id, + operator_note=request.operator_note, + provider=request.provider, + limit=request.limit, + ) except RecurrenceWorkItemNotFoundError as exc: raise HTTPException( status_code=404, diff --git a/apps/api/tests/test_platform_events_project_context.py b/apps/api/tests/test_platform_events_project_context.py new file mode 100644 index 00000000..7b3dc95a --- /dev/null +++ b/apps/api/tests/test_platform_events_project_context.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import pytest + +from src.api.v1.platform import events +from src.core.context import ( + clear_project_context, + get_current_project_context, + set_project_context, +) + + +@pytest.mark.asyncio +async def test_source_correlation_review_uses_body_project_context(monkeypatch): + observed: dict[str, object] = {} + + async def fake_fetch_source_correlation_review_decision(**kwargs): + observed["kwargs"] = kwargs + observed["context"] = get_current_project_context() + return {"review_record_status": "recorded"} + + monkeypatch.setattr( + events, + "fetch_source_correlation_review_decision", + fake_fetch_source_correlation_review_decision, + ) + + tokens = set_project_context( + project_id=None, + source="request.project_id.missing", + request_id="request-123", + ) + try: + response = await events.review_source_correlation_work_item( + events.SourceCorrelationReviewDecisionRequest( + project_id="awoooi", + work_item_id="source-evidence:sentry:canary", + decision="accepted", + target_incident_id="INC-20260505-25E744", + ) + ) + assert response == {"review_record_status": "recorded"} + assert observed["kwargs"]["project_id"] == "awoooi" + assert observed["context"] == { + "project_id": "awoooi", + "source": "request.body", + "request_id": "request-123", + } + assert get_current_project_context() == { + "project_id": None, + "source": "request.project_id.missing", + "request_id": "request-123", + } + finally: + clear_project_context(tokens) + + +@pytest.mark.asyncio +async def test_source_correlation_apply_uses_body_project_context(monkeypatch): + observed: dict[str, object] = {} + + async def fake_fetch_source_correlation_apply(**kwargs): + observed["kwargs"] = kwargs + observed["context"] = get_current_project_context() + return {"apply_status": "applied"} + + monkeypatch.setattr( + events, + "fetch_source_correlation_apply", + fake_fetch_source_correlation_apply, + ) + + tokens = set_project_context( + project_id=None, + source="request.project_id.missing", + request_id="request-456", + ) + try: + response = await events.apply_source_correlation_work_item( + events.SourceCorrelationApplyRequest( + project_id="awoooi", + work_item_id="source-evidence:sentry:canary", + ) + ) + assert response == {"apply_status": "applied"} + assert observed["kwargs"]["project_id"] == "awoooi" + assert observed["context"] == { + "project_id": "awoooi", + "source": "request.body", + "request_id": "request-456", + } + assert get_current_project_context() == { + "project_id": None, + "source": "request.project_id.missing", + "request_id": "request-456", + } + finally: + clear_project_context(tokens)