LangGraph-based release automation agent with: - PR discovery (webhook + polling) - AI code review via Claude Code CLI (subscription-based) - Auto-create Jira tickets for PRs without ticket ID - Jira ticket lifecycle management (code review -> staging -> done) - CI/CD pipeline trigger, polling, and approval gates - Slack interactive messages with approval buttons - Per-repo semantic versioning - PostgreSQL persistence (threads, staging, releases) - FastAPI API (webhooks, approvals, status, manual triggers) - Docker Compose deployment 1069 tests, 95%+ coverage.
219 lines
7.5 KiB
Python
219 lines
7.5 KiB
Python
"""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()
|