"""Tests for webhook endpoint. Written FIRST (TDD RED phase).""" import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from release_agent.api.webhooks import ( _validate_webhook_secret, router as webhook_router, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- VALID_SECRET = "super-secret-webhook-key" _COMPLETED_PR_PAYLOAD = { "subscription_id": "sub-1", "event_type": "git.pullrequest.updated", "resource": { "repository": { "id": "repo-1", "name": "my-repo", "web_url": "https://dev.azure.com/org/project/_git/my-repo", }, "pull_request_id": 42, "title": "feat: add feature", "source_ref_name": "refs/heads/feature/BILL-123-add-feature", "target_ref_name": "refs/heads/main", "status": "completed", "closed_date": "2024-01-15T10:00:00Z", }, } _ACTIVE_PR_PAYLOAD = { "subscription_id": "sub-2", "event_type": "git.pullrequest.updated", "resource": { "repository": { "id": "repo-1", "name": "my-repo", "web_url": "https://dev.azure.com/org/project/_git/my-repo", }, "pull_request_id": 43, "title": "WIP: work in progress", "source_ref_name": "refs/heads/feature/BILL-456", "target_ref_name": "refs/heads/main", "status": "active", "closed_date": None, }, } def _make_test_app(webhook_secret: str = VALID_SECRET) -> FastAPI: """Return a FastAPI app with mocked state for webhook tests.""" app = FastAPI() app.include_router(webhook_router) mock_settings = MagicMock() mock_settings.webhook_secret.get_secret_value.return_value = webhook_secret mock_graphs = { "pr_completed": MagicMock(), "release": MagicMock(), } mock_clients = MagicMock() mock_pool = MagicMock() # background_tasks set tracked on state app.state.settings = mock_settings app.state.graphs = mock_graphs app.state.tool_clients = mock_clients app.state.db_pool = mock_pool app.state.background_tasks = set() return app # --------------------------------------------------------------------------- # _validate_webhook_secret (unit tests, pure function) # --------------------------------------------------------------------------- class TestValidateWebhookSecret: def test_valid_secret_returns_true(self) -> None: assert _validate_webhook_secret("mysecret", "mysecret") is True def test_wrong_secret_returns_false(self) -> None: assert _validate_webhook_secret("wrong", "mysecret") is False def test_empty_header_returns_false(self) -> None: assert _validate_webhook_secret("", "mysecret") is False def test_none_header_returns_false(self) -> None: assert _validate_webhook_secret(None, "mysecret") is False # type: ignore[arg-type] def test_uses_constant_time_comparison(self) -> None: # Should not raise even for very different lengths assert _validate_webhook_secret("a", "very-long-secret-value") is False def test_empty_expected_rejects_all(self) -> None: # Empty expected secret = auth misconfigured, reject everything assert _validate_webhook_secret("", "") is False assert _validate_webhook_secret("any-value", "") is False assert _validate_webhook_secret(None, "") is False # --------------------------------------------------------------------------- # POST /webhooks/azdo — integration tests via TestClient # --------------------------------------------------------------------------- class TestWebhookEndpoint: def test_valid_completed_pr_returns_202(self) -> None: app = _make_test_app() with patch( "release_agent.api.webhooks.asyncio.create_task", return_value=MagicMock() ): with TestClient(app, raise_server_exceptions=True) as client: response = client.post( "/webhooks/azdo", json=_COMPLETED_PR_PAYLOAD, headers={"X-Webhook-Secret": VALID_SECRET}, ) assert response.status_code == 202 data = response.json() assert "thread_id" in data assert "message" in data def test_missing_secret_header_returns_401(self) -> None: app = _make_test_app() with TestClient(app) as client: response = client.post( "/webhooks/azdo", json=_COMPLETED_PR_PAYLOAD, ) assert response.status_code == 401 def test_wrong_secret_header_returns_401(self) -> None: app = _make_test_app() with TestClient(app) as client: response = client.post( "/webhooks/azdo", json=_COMPLETED_PR_PAYLOAD, headers={"X-Webhook-Secret": "wrong-secret"}, ) assert response.status_code == 401 def test_invalid_payload_returns_422(self) -> None: app = _make_test_app() with TestClient(app) as client: response = client.post( "/webhooks/azdo", json={"invalid": "payload"}, headers={"X-Webhook-Secret": VALID_SECRET}, ) assert response.status_code == 422 def test_active_pr_event_returns_200_ignored(self) -> None: app = _make_test_app() with TestClient(app) as client: response = client.post( "/webhooks/azdo", json=_ACTIVE_PR_PAYLOAD, headers={"X-Webhook-Secret": VALID_SECRET}, ) assert response.status_code == 200 data = response.json() assert "ignored" in data.get("message", "").lower() or "ignored" in str(data).lower() def test_completed_pr_thread_id_is_string(self) -> None: app = _make_test_app() with patch( "release_agent.api.webhooks.asyncio.create_task", return_value=MagicMock() ): with TestClient(app) as client: response = client.post( "/webhooks/azdo", json=_COMPLETED_PR_PAYLOAD, headers={"X-Webhook-Secret": VALID_SECRET}, ) assert response.status_code == 202 assert isinstance(response.json()["thread_id"], str) def test_completed_pr_schedules_background_task(self) -> None: app = _make_test_app() task_mock = MagicMock() with patch( "release_agent.api.webhooks.asyncio.create_task", return_value=task_mock ) as mock_create: with TestClient(app) as client: client.post( "/webhooks/azdo", json=_COMPLETED_PR_PAYLOAD, headers={"X-Webhook-Secret": VALID_SECRET}, ) mock_create.assert_called_once() # --------------------------------------------------------------------------- # Error response shape # --------------------------------------------------------------------------- class TestWebhookErrorShape: def test_401_has_detail_field(self) -> None: app = _make_test_app() with TestClient(app) as client: response = client.post( "/webhooks/azdo", json=_COMPLETED_PR_PAYLOAD, ) assert response.status_code == 401 # FastAPI HTTPException returns {"detail": ...} assert "detail" in response.json()