feat(#157): Set up webhook receiver endpoint
Implement FastAPI webhook receiver for Gitea issue assignment events with HMAC SHA256 signature verification and event routing. Implementation details: - FastAPI application with /webhook/gitea POST endpoint - HMAC SHA256 signature verification in security.py - Event routing for assigned, unassigned, closed actions - Comprehensive logging for all webhook events - Health check endpoint at /health - Docker containerization with health checks - 91% test coverage (exceeds 85% requirement) TDD workflow followed: - Wrote 16 tests first (RED phase) - Implemented features to pass tests (GREEN phase) - All tests passing with 91% coverage - Type checking with mypy: success - Linting with ruff: success Files created: - apps/coordinator/src/main.py - FastAPI application - apps/coordinator/src/webhook.py - Webhook handlers - apps/coordinator/src/security.py - HMAC verification - apps/coordinator/src/config.py - Configuration management - apps/coordinator/tests/ - Comprehensive test suite - apps/coordinator/Dockerfile - Production container - apps/coordinator/pyproject.toml - Python project config Configuration: - Updated .env.example with GITEA_WEBHOOK_SECRET - Updated docker-compose.yml with coordinator service Testing: - 16 unit and integration tests - Security tests for signature verification - Event handler tests for all supported actions - Health check endpoint tests - All tests passing with 91% coverage This unblocks issue #158 (issue parser). Fixes #157 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
1
apps/coordinator/tests/__init__.py
Normal file
1
apps/coordinator/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for mosaic-coordinator."""
|
||||
120
apps/coordinator/tests/conftest.py
Normal file
120
apps/coordinator/tests/conftest.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Pytest fixtures for coordinator tests."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def webhook_secret() -> str:
|
||||
"""Return a test webhook secret."""
|
||||
return "test-webhook-secret-12345"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitea_url() -> str:
|
||||
"""Return a test Gitea URL."""
|
||||
return "https://git.mosaicstack.dev"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_assigned_payload() -> dict[str, object]:
|
||||
"""Return a sample Gitea 'assigned' issue webhook payload."""
|
||||
return {
|
||||
"action": "assigned",
|
||||
"number": 157,
|
||||
"issue": {
|
||||
"id": 157,
|
||||
"number": 157,
|
||||
"title": "[COORD-001] Set up webhook receiver endpoint",
|
||||
"state": "open",
|
||||
"assignee": {
|
||||
"id": 1,
|
||||
"login": "mosaic",
|
||||
"full_name": "Mosaic Bot",
|
||||
},
|
||||
},
|
||||
"repository": {
|
||||
"name": "stack",
|
||||
"full_name": "mosaic/stack",
|
||||
"owner": {"login": "mosaic"},
|
||||
},
|
||||
"sender": {
|
||||
"id": 2,
|
||||
"login": "admin",
|
||||
"full_name": "Admin User",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_unassigned_payload() -> dict[str, object]:
|
||||
"""Return a sample Gitea 'unassigned' issue webhook payload."""
|
||||
return {
|
||||
"action": "unassigned",
|
||||
"number": 157,
|
||||
"issue": {
|
||||
"id": 157,
|
||||
"number": 157,
|
||||
"title": "[COORD-001] Set up webhook receiver endpoint",
|
||||
"state": "open",
|
||||
"assignee": None,
|
||||
},
|
||||
"repository": {
|
||||
"name": "stack",
|
||||
"full_name": "mosaic/stack",
|
||||
"owner": {"login": "mosaic"},
|
||||
},
|
||||
"sender": {
|
||||
"id": 2,
|
||||
"login": "admin",
|
||||
"full_name": "Admin User",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_closed_payload() -> dict[str, object]:
|
||||
"""Return a sample Gitea 'closed' issue webhook payload."""
|
||||
return {
|
||||
"action": "closed",
|
||||
"number": 157,
|
||||
"issue": {
|
||||
"id": 157,
|
||||
"number": 157,
|
||||
"title": "[COORD-001] Set up webhook receiver endpoint",
|
||||
"state": "closed",
|
||||
"assignee": {
|
||||
"id": 1,
|
||||
"login": "mosaic",
|
||||
"full_name": "Mosaic Bot",
|
||||
},
|
||||
},
|
||||
"repository": {
|
||||
"name": "stack",
|
||||
"full_name": "mosaic/stack",
|
||||
"owner": {"login": "mosaic"},
|
||||
},
|
||||
"sender": {
|
||||
"id": 2,
|
||||
"login": "admin",
|
||||
"full_name": "Admin User",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(webhook_secret: str, gitea_url: str, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
"""Create a FastAPI test client with test configuration."""
|
||||
# Set test environment variables
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", webhook_secret)
|
||||
monkeypatch.setenv("GITEA_URL", gitea_url)
|
||||
monkeypatch.setenv("LOG_LEVEL", "debug")
|
||||
|
||||
# Force reload of settings
|
||||
from src import config
|
||||
import importlib
|
||||
importlib.reload(config)
|
||||
|
||||
# Import app after settings are configured
|
||||
from src.main import app
|
||||
return TestClient(app)
|
||||
84
apps/coordinator/tests/test_security.py
Normal file
84
apps/coordinator/tests/test_security.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Tests for HMAC signature verification."""
|
||||
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSignatureVerification:
|
||||
"""Test suite for HMAC SHA256 signature verification."""
|
||||
|
||||
def test_verify_signature_valid(self, webhook_secret: str) -> None:
|
||||
"""Test that valid signature is accepted."""
|
||||
from src.security import verify_signature
|
||||
|
||||
payload = json.dumps({"action": "assigned", "number": 157}).encode("utf-8")
|
||||
signature = hmac.new(
|
||||
webhook_secret.encode("utf-8"), payload, "sha256"
|
||||
).hexdigest()
|
||||
|
||||
assert verify_signature(payload, signature, webhook_secret) is True
|
||||
|
||||
def test_verify_signature_invalid(self, webhook_secret: str) -> None:
|
||||
"""Test that invalid signature is rejected."""
|
||||
from src.security import verify_signature
|
||||
|
||||
payload = json.dumps({"action": "assigned", "number": 157}).encode("utf-8")
|
||||
invalid_signature = "invalid_signature_12345"
|
||||
|
||||
assert verify_signature(payload, invalid_signature, webhook_secret) is False
|
||||
|
||||
def test_verify_signature_empty_signature(self, webhook_secret: str) -> None:
|
||||
"""Test that empty signature is rejected."""
|
||||
from src.security import verify_signature
|
||||
|
||||
payload = json.dumps({"action": "assigned", "number": 157}).encode("utf-8")
|
||||
|
||||
assert verify_signature(payload, "", webhook_secret) is False
|
||||
|
||||
def test_verify_signature_wrong_secret(self, webhook_secret: str) -> None:
|
||||
"""Test that signature with wrong secret is rejected."""
|
||||
from src.security import verify_signature
|
||||
|
||||
payload = json.dumps({"action": "assigned", "number": 157}).encode("utf-8")
|
||||
wrong_secret = "wrong-secret-67890"
|
||||
signature = hmac.new(
|
||||
wrong_secret.encode("utf-8"), payload, "sha256"
|
||||
).hexdigest()
|
||||
|
||||
assert verify_signature(payload, signature, webhook_secret) is False
|
||||
|
||||
def test_verify_signature_modified_payload(self, webhook_secret: str) -> None:
|
||||
"""Test that signature fails when payload is modified."""
|
||||
from src.security import verify_signature
|
||||
|
||||
original_payload = json.dumps({"action": "assigned", "number": 157}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
signature = hmac.new(
|
||||
webhook_secret.encode("utf-8"), original_payload, "sha256"
|
||||
).hexdigest()
|
||||
|
||||
# Modify the payload
|
||||
modified_payload = json.dumps({"action": "assigned", "number": 999}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
assert verify_signature(modified_payload, signature, webhook_secret) is False
|
||||
|
||||
def test_verify_signature_timing_safe(self, webhook_secret: str) -> None:
|
||||
"""Test that signature comparison is timing-attack safe."""
|
||||
from src.security import verify_signature
|
||||
|
||||
payload = json.dumps({"action": "assigned", "number": 157}).encode("utf-8")
|
||||
signature = hmac.new(
|
||||
webhook_secret.encode("utf-8"), payload, "sha256"
|
||||
).hexdigest()
|
||||
|
||||
# Valid signature should work
|
||||
assert verify_signature(payload, signature, webhook_secret) is True
|
||||
|
||||
# Similar but wrong signature should fail (timing-safe comparison)
|
||||
wrong_signature = signature[:-1] + ("0" if signature[-1] != "0" else "1")
|
||||
assert verify_signature(payload, wrong_signature, webhook_secret) is False
|
||||
162
apps/coordinator/tests/test_webhook.py
Normal file
162
apps/coordinator/tests/test_webhook.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Tests for webhook endpoint handlers."""
|
||||
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestWebhookEndpoint:
|
||||
"""Test suite for /webhook/gitea endpoint."""
|
||||
|
||||
def _create_signature(self, payload: dict[str, object], secret: str) -> str:
|
||||
"""Create HMAC SHA256 signature for payload."""
|
||||
# Use separators to match FastAPI's JSON encoding (no spaces)
|
||||
payload_bytes = json.dumps(payload, separators=(',', ':')).encode("utf-8")
|
||||
return hmac.new(secret.encode("utf-8"), payload_bytes, "sha256").hexdigest()
|
||||
|
||||
def test_webhook_missing_signature(
|
||||
self, client: TestClient, sample_assigned_payload: dict[str, object]
|
||||
) -> None:
|
||||
"""Test that webhook without signature returns 401."""
|
||||
response = client.post("/webhook/gitea", json=sample_assigned_payload)
|
||||
assert response.status_code == 401
|
||||
assert "Invalid or missing signature" in response.json()["detail"]
|
||||
|
||||
def test_webhook_invalid_signature(
|
||||
self, client: TestClient, sample_assigned_payload: dict[str, object]
|
||||
) -> None:
|
||||
"""Test that webhook with invalid signature returns 401."""
|
||||
headers = {"X-Gitea-Signature": "invalid_signature"}
|
||||
response = client.post(
|
||||
"/webhook/gitea", json=sample_assigned_payload, headers=headers
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert "Invalid or missing signature" in response.json()["detail"]
|
||||
|
||||
def test_webhook_assigned_event(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_assigned_payload: dict[str, object],
|
||||
webhook_secret: str,
|
||||
) -> None:
|
||||
"""Test that assigned event is processed successfully."""
|
||||
signature = self._create_signature(sample_assigned_payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
response = client.post(
|
||||
"/webhook/gitea", json=sample_assigned_payload, headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
assert response.json()["action"] == "assigned"
|
||||
assert response.json()["issue_number"] == 157
|
||||
|
||||
def test_webhook_unassigned_event(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_unassigned_payload: dict[str, object],
|
||||
webhook_secret: str,
|
||||
) -> None:
|
||||
"""Test that unassigned event is processed successfully."""
|
||||
signature = self._create_signature(sample_unassigned_payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
response = client.post(
|
||||
"/webhook/gitea", json=sample_unassigned_payload, headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
assert response.json()["action"] == "unassigned"
|
||||
assert response.json()["issue_number"] == 157
|
||||
|
||||
def test_webhook_closed_event(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_closed_payload: dict[str, object],
|
||||
webhook_secret: str,
|
||||
) -> None:
|
||||
"""Test that closed event is processed successfully."""
|
||||
signature = self._create_signature(sample_closed_payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
response = client.post(
|
||||
"/webhook/gitea", json=sample_closed_payload, headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
assert response.json()["action"] == "closed"
|
||||
assert response.json()["issue_number"] == 157
|
||||
|
||||
def test_webhook_unsupported_action(
|
||||
self, client: TestClient, webhook_secret: str
|
||||
) -> None:
|
||||
"""Test that unsupported actions are handled gracefully."""
|
||||
payload = {
|
||||
"action": "opened", # Not a supported action
|
||||
"number": 157,
|
||||
"issue": {"id": 157, "number": 157, "title": "Test"},
|
||||
}
|
||||
signature = self._create_signature(payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
response = client.post("/webhook/gitea", json=payload, headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ignored"
|
||||
assert response.json()["action"] == "opened"
|
||||
|
||||
def test_webhook_malformed_payload(
|
||||
self, client: TestClient, webhook_secret: str
|
||||
) -> None:
|
||||
"""Test that malformed payload returns 422."""
|
||||
payload = {"invalid": "payload"} # Missing required fields
|
||||
signature = self._create_signature(payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
response = client.post("/webhook/gitea", json=payload, headers=headers)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_webhook_logs_events(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_assigned_payload: dict[str, object],
|
||||
webhook_secret: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that webhook events are logged."""
|
||||
signature = self._create_signature(sample_assigned_payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
response = client.post(
|
||||
"/webhook/gitea", json=sample_assigned_payload, headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Check that event was logged
|
||||
assert any("Webhook event received" in record.message for record in caplog.records)
|
||||
assert any("action=assigned" in record.message for record in caplog.records)
|
||||
assert any("issue_number=157" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Test suite for /health endpoint."""
|
||||
|
||||
def test_health_check_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that health check endpoint returns 200 OK."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
|
||||
def test_health_check_includes_service_name(self, client: TestClient) -> None:
|
||||
"""Test that health check includes service name."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert "service" in response.json()
|
||||
assert response.json()["service"] == "mosaic-coordinator"
|
||||
Reference in New Issue
Block a user