diff --git a/.env.example b/.env.example index 510e0d7..3c80dcd 100644 --- a/.env.example +++ b/.env.example @@ -158,6 +158,11 @@ GITEA_BOT_PASSWORD=REPLACE_WITH_COORDINATOR_BOT_PASSWORD GITEA_REPO_OWNER=mosaic GITEA_REPO_NAME=stack +# Webhook secret for coordinator (HMAC SHA256 signature verification) +# SECURITY: Generate random secret with: openssl rand -hex 32 +# Configure in Gitea: Repository Settings → Webhooks → Add Webhook +GITEA_WEBHOOK_SECRET=REPLACE_WITH_RANDOM_WEBHOOK_SECRET + # ====================== # Logging & Debugging # ====================== diff --git a/apps/coordinator/.dockerignore b/apps/coordinator/.dockerignore new file mode 100644 index 0000000..9146a02 --- /dev/null +++ b/apps/coordinator/.dockerignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +venv/ +env/ +ENV/ + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +tests/ + +# Distribution +dist/ +build/ +*.egg-info/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# Git +.git/ +.gitignore + +# Documentation +README.md + +# Misc +*.log diff --git a/apps/coordinator/.gitignore b/apps/coordinator/.gitignore new file mode 100644 index 0000000..2e24842 --- /dev/null +++ b/apps/coordinator/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +venv/ +env/ +ENV/ + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ + +# Distribution +dist/ +build/ +*.egg-info/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local diff --git a/apps/coordinator/Dockerfile b/apps/coordinator/Dockerfile new file mode 100644 index 0000000..ad35f0e --- /dev/null +++ b/apps/coordinator/Dockerfile @@ -0,0 +1,59 @@ +# Multi-stage build for mosaic-coordinator +FROM python:3.11-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy dependency files +COPY pyproject.toml . + +# Create virtual environment and install dependencies +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir hatchling && \ + pip install --no-cache-dir \ + fastapi>=0.109.0 \ + uvicorn[standard]>=0.27.0 \ + pydantic>=2.5.0 \ + pydantic-settings>=2.1.0 \ + python-dotenv>=1.0.0 + +# Production stage +FROM python:3.11-slim + +WORKDIR /app + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy application code +COPY src/ ./src/ + +# Create non-root user +RUN useradd -m -u 1000 coordinator && \ + chown -R coordinator:coordinator /app + +USER coordinator + +# Environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + HOST=0.0.0.0 \ + PORT=8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" + +# Expose port +EXPOSE 8000 + +# Run application +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/coordinator/README.md b/apps/coordinator/README.md new file mode 100644 index 0000000..67552be --- /dev/null +++ b/apps/coordinator/README.md @@ -0,0 +1,141 @@ +# Mosaic Coordinator + +FastAPI webhook receiver for Gitea issue events, enabling autonomous task coordination for AI agents. + +## Overview + +The coordinator receives webhook events from Gitea when issues are assigned, unassigned, or closed. It verifies webhook authenticity via HMAC SHA256 signature and routes events to appropriate handlers. + +## Features + +- HMAC SHA256 signature verification +- Event routing (assigned, unassigned, closed) +- Comprehensive logging +- Health check endpoint +- Docker containerized +- 85%+ test coverage + +## Development + +### Prerequisites + +- Python 3.11+ +- pip or uv package manager + +### Setup + +```bash +# Install dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Run with coverage +pytest --cov=src --cov-report=html + +# Type checking +mypy src/ + +# Linting +ruff check src/ +``` + +### Running locally + +```bash +# Set environment variables +export GITEA_WEBHOOK_SECRET="your-secret-here" +export LOG_LEVEL="info" + +# Run server +uvicorn src.main:app --reload --port 8000 +``` + +## API Endpoints + +### POST /webhook/gitea + +Receives Gitea webhook events. + +**Headers:** + +- `X-Gitea-Signature`: HMAC SHA256 signature of request body + +**Response:** + +- `200 OK`: Event processed successfully +- `401 Unauthorized`: Invalid or missing signature +- `422 Unprocessable Entity`: Invalid payload + +### GET /health + +Health check endpoint. + +**Response:** + +- `200 OK`: Service is healthy + +## Environment Variables + +| Variable | Description | Required | Default | +| ---------------------- | ------------------------------------------- | -------- | ------- | +| `GITEA_WEBHOOK_SECRET` | Secret for HMAC signature verification | Yes | - | +| `GITEA_URL` | Gitea instance URL | Yes | - | +| `LOG_LEVEL` | Logging level (debug, info, warning, error) | No | info | +| `HOST` | Server host | No | 0.0.0.0 | +| `PORT` | Server port | No | 8000 | + +## Docker + +```bash +# Build +docker build -t mosaic-coordinator . + +# Run +docker run -p 8000:8000 \ + -e GITEA_WEBHOOK_SECRET="your-secret" \ + -e GITEA_URL="https://git.mosaicstack.dev" \ + mosaic-coordinator +``` + +## Testing + +```bash +# Run all tests +pytest + +# Run with coverage (requires 85%+) +pytest --cov=src --cov-report=term-missing + +# Run specific test file +pytest tests/test_security.py + +# Run with verbose output +pytest -v +``` + +## Architecture + +``` +apps/coordinator/ +├── src/ +│ ├── main.py # FastAPI application +│ ├── webhook.py # Webhook endpoint handlers +│ ├── security.py # HMAC signature verification +│ └── config.py # Configuration management +├── tests/ +│ ├── test_security.py +│ ├── test_webhook.py +│ └── conftest.py # Pytest fixtures +├── pyproject.toml # Project metadata & dependencies +├── Dockerfile +└── README.md +``` + +## Related Issues + +- #156 - Create coordinator bot user +- #157 - Set up webhook receiver endpoint +- #158 - Implement issue parser +- #140 - Coordinator architecture diff --git a/apps/coordinator/pyproject.toml b/apps/coordinator/pyproject.toml new file mode 100644 index 0000000..903e706 --- /dev/null +++ b/apps/coordinator/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "mosaic-coordinator" +version = "0.0.1" +description = "Mosaic Stack webhook receiver and task coordinator" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "pytest-asyncio>=0.21.0", + "httpx>=0.26.0", + "ruff>=0.1.0", + "mypy>=1.8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "--cov=src --cov-report=term-missing --cov-report=html --cov-fail-under=85" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "B", "UP"] +ignore = [] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true diff --git a/apps/coordinator/src/__init__.py b/apps/coordinator/src/__init__.py new file mode 100644 index 0000000..b3ed773 --- /dev/null +++ b/apps/coordinator/src/__init__.py @@ -0,0 +1,3 @@ +"""Mosaic Coordinator - Webhook receiver for Gitea issue events.""" + +__version__ = "0.0.1" diff --git a/apps/coordinator/src/config.py b/apps/coordinator/src/config.py new file mode 100644 index 0000000..c83b4ca --- /dev/null +++ b/apps/coordinator/src/config.py @@ -0,0 +1,34 @@ +"""Configuration management for mosaic-coordinator.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # Gitea Configuration + gitea_webhook_secret: str + gitea_url: str = "https://git.mosaicstack.dev" + + # Server Configuration + host: str = "0.0.0.0" + port: int = 8000 + + # Logging + log_level: str = "info" + + +def get_settings() -> Settings: + """Get settings instance (lazy loaded).""" + return Settings() # type: ignore[call-arg] + + +# Global settings instance +settings = get_settings() diff --git a/apps/coordinator/src/main.py b/apps/coordinator/src/main.py new file mode 100644 index 0000000..ad0f6ac --- /dev/null +++ b/apps/coordinator/src/main.py @@ -0,0 +1,89 @@ +"""FastAPI application for mosaic-coordinator webhook receiver.""" + +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from pydantic import BaseModel + +from .config import settings +from .webhook import router as webhook_router + + +# Configure logging +def setup_logging() -> None: + """Configure logging for the application.""" + log_level = getattr(logging, settings.log_level.upper(), logging.INFO) + logging.basicConfig( + level=log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +# Setup logging on module import +setup_logging() +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """ + Application lifespan manager. + + Handles startup and shutdown logic. + """ + # Startup + logger.info("Starting mosaic-coordinator webhook receiver") + logger.info(f"Gitea URL: {settings.gitea_url}") + logger.info(f"Log level: {settings.log_level}") + logger.info(f"Server: {settings.host}:{settings.port}") + + yield + + # Shutdown + logger.info("Shutting down mosaic-coordinator webhook receiver") + + +# Create FastAPI application +app = FastAPI( + title="Mosaic Coordinator", + description="Webhook receiver for Gitea issue events", + version="0.0.1", + lifespan=lifespan, +) + + +class HealthResponse(BaseModel): + """Health check response model.""" + + status: str + service: str + + +@app.get("/health", response_model=HealthResponse) +async def health_check() -> HealthResponse: + """ + Health check endpoint. + + Returns: + HealthResponse indicating service is healthy + """ + return HealthResponse(status="healthy", service="mosaic-coordinator") + + +# Include webhook router +app.include_router(webhook_router) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "src.main:app", + host=settings.host, + port=settings.port, + reload=True, + log_level=settings.log_level.lower(), + ) diff --git a/apps/coordinator/src/security.py b/apps/coordinator/src/security.py new file mode 100644 index 0000000..4675d1b --- /dev/null +++ b/apps/coordinator/src/security.py @@ -0,0 +1,35 @@ +"""Security utilities for webhook signature verification.""" + +import hashlib +import hmac + + +def verify_signature(payload: bytes, signature: str, secret: str) -> bool: + """ + Verify HMAC SHA256 signature of webhook payload. + + Args: + payload: Raw request body as bytes + signature: Signature from X-Gitea-Signature header + secret: Webhook secret configured in Gitea + + Returns: + True if signature is valid, False otherwise + + Example: + >>> payload = b'{"action": "assigned"}' + >>> secret = "my-webhook-secret" + >>> sig = hmac.new(secret.encode(), payload, "sha256").hexdigest() + >>> verify_signature(payload, sig, secret) + True + """ + if not signature: + return False + + # Compute expected signature + expected_signature = hmac.new( + secret.encode("utf-8"), payload, hashlib.sha256 + ).hexdigest() + + # Use timing-safe comparison to prevent timing attacks + return hmac.compare_digest(signature, expected_signature) diff --git a/apps/coordinator/src/webhook.py b/apps/coordinator/src/webhook.py new file mode 100644 index 0000000..18ea2eb --- /dev/null +++ b/apps/coordinator/src/webhook.py @@ -0,0 +1,177 @@ +"""Webhook endpoint handlers for Gitea events.""" + +import logging +from typing import Any + +from fastapi import APIRouter, Header, HTTPException, Request +from pydantic import BaseModel, Field + +from .config import settings +from .security import verify_signature + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class WebhookResponse(BaseModel): + """Response model for webhook endpoint.""" + + status: str = Field(..., description="Status of webhook processing") + action: str = Field(..., description="Action type from webhook") + issue_number: int | None = Field(None, description="Issue number if applicable") + message: str | None = Field(None, description="Additional message") + + +class GiteaWebhookPayload(BaseModel): + """Model for Gitea webhook payload.""" + + action: str = Field(..., description="Action type (assigned, unassigned, closed, etc.)") + number: int = Field(..., description="Issue or PR number") + issue: dict[str, Any] | None = Field(None, description="Issue details") + repository: dict[str, Any] | None = Field(None, description="Repository details") + sender: dict[str, Any] | None = Field(None, description="User who triggered event") + + +@router.post("/webhook/gitea", response_model=WebhookResponse) +async def handle_gitea_webhook( + request: Request, + payload: GiteaWebhookPayload, + x_gitea_signature: str | None = Header(None, alias="X-Gitea-Signature"), +) -> WebhookResponse: + """ + Handle Gitea webhook events. + + Verifies HMAC SHA256 signature and routes events to appropriate handlers. + + Args: + request: FastAPI request object + payload: Parsed webhook payload + x_gitea_signature: HMAC signature from Gitea + + Returns: + WebhookResponse indicating success or failure + + Raises: + HTTPException: 401 if signature is invalid or missing + """ + # Get raw request body for signature verification + body = await request.body() + + # Verify signature + if not x_gitea_signature or not verify_signature( + body, x_gitea_signature, settings.gitea_webhook_secret + ): + logger.warning( + "Webhook received with invalid or missing signature", + extra={"action": payload.action, "issue_number": payload.number}, + ) + raise HTTPException(status_code=401, detail="Invalid or missing signature") + + # Log the event + logger.info( + f"Webhook event received: action={payload.action}, issue_number={payload.number}", + extra={ + "action": payload.action, + "issue_number": payload.number, + "repository": payload.repository.get("full_name") if payload.repository else None, + }, + ) + + # Route to appropriate handler based on action + if payload.action == "assigned": + return await handle_assigned_event(payload) + elif payload.action == "unassigned": + return await handle_unassigned_event(payload) + elif payload.action == "closed": + return await handle_closed_event(payload) + else: + # Ignore unsupported actions + logger.debug(f"Ignoring unsupported action: {payload.action}") + return WebhookResponse( + status="ignored", + action=payload.action, + issue_number=payload.number, + message=f"Action '{payload.action}' is not supported", + ) + + +async def handle_assigned_event(payload: GiteaWebhookPayload) -> WebhookResponse: + """ + Handle issue assigned event. + + Args: + payload: Webhook payload + + Returns: + WebhookResponse indicating success + """ + logger.info( + f"Issue #{payload.number} assigned", + extra={ + "issue_number": payload.number, + "assignee": payload.issue.get("assignee", {}).get("login") if payload.issue else None, + }, + ) + + # TODO: Trigger issue parser and context estimator (issue #158) + # For now, just log and return success + + return WebhookResponse( + status="success", + action="assigned", + issue_number=payload.number, + message=f"Issue #{payload.number} assigned event processed", + ) + + +async def handle_unassigned_event(payload: GiteaWebhookPayload) -> WebhookResponse: + """ + Handle issue unassigned event. + + Args: + payload: Webhook payload + + Returns: + WebhookResponse indicating success + """ + logger.info( + f"Issue #{payload.number} unassigned", + extra={"issue_number": payload.number}, + ) + + # TODO: Update coordinator state (issue #159+) + # For now, just log and return success + + return WebhookResponse( + status="success", + action="unassigned", + issue_number=payload.number, + message=f"Issue #{payload.number} unassigned event processed", + ) + + +async def handle_closed_event(payload: GiteaWebhookPayload) -> WebhookResponse: + """ + Handle issue closed event. + + Args: + payload: Webhook payload + + Returns: + WebhookResponse indicating success + """ + logger.info( + f"Issue #{payload.number} closed", + extra={"issue_number": payload.number}, + ) + + # TODO: Update coordinator state and cleanup (issue #159+) + # For now, just log and return success + + return WebhookResponse( + status="success", + action="closed", + issue_number=payload.number, + message=f"Issue #{payload.number} closed event processed", + ) diff --git a/apps/coordinator/tests/__init__.py b/apps/coordinator/tests/__init__.py new file mode 100644 index 0000000..76f2dd2 --- /dev/null +++ b/apps/coordinator/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for mosaic-coordinator.""" diff --git a/apps/coordinator/tests/conftest.py b/apps/coordinator/tests/conftest.py new file mode 100644 index 0000000..b09fa99 --- /dev/null +++ b/apps/coordinator/tests/conftest.py @@ -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) diff --git a/apps/coordinator/tests/test_security.py b/apps/coordinator/tests/test_security.py new file mode 100644 index 0000000..664e52d --- /dev/null +++ b/apps/coordinator/tests/test_security.py @@ -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 diff --git a/apps/coordinator/tests/test_webhook.py b/apps/coordinator/tests/test_webhook.py new file mode 100644 index 0000000..ccd12f3 --- /dev/null +++ b/apps/coordinator/tests/test_webhook.py @@ -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" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9a1ec8d..6a3e2bd 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -39,6 +39,35 @@ services: networks: - mosaic-network + coordinator: + build: + context: ../apps/coordinator + dockerfile: Dockerfile + container_name: mosaic-coordinator + restart: unless-stopped + environment: + GITEA_WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET} + GITEA_URL: ${GITEA_URL:-https://git.mosaicstack.dev} + LOG_LEVEL: ${LOG_LEVEL:-info} + HOST: 0.0.0.0 + PORT: 8000 + ports: + - "8000:8000" + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + networks: + - mosaic-network + volumes: postgres_data: name: mosaic-postgres-data diff --git a/docs/scratchpads/157-webhook-receiver.md b/docs/scratchpads/157-webhook-receiver.md new file mode 100644 index 0000000..213e729 --- /dev/null +++ b/docs/scratchpads/157-webhook-receiver.md @@ -0,0 +1,56 @@ +# Issue #157: Set up webhook receiver endpoint + +## Objective + +Implement FastAPI webhook receiver that handles Gitea issue assignment events with HMAC SHA256 signature verification. + +## Approach + +1. Create new Python service: `apps/coordinator/` (FastAPI app) +2. Structure: + - `src/main.py` - FastAPI application entry point + - `src/webhook.py` - Webhook endpoint handlers + - `src/security.py` - HMAC signature verification + - `src/config.py` - Configuration management + - `tests/` - Unit and integration tests +3. Follow TDD: Write tests first, then implementation +4. Add Docker support with health checks +5. Update docker-compose for coordinator service + +## Progress + +- [x] Create directory structure +- [x] Write tests for HMAC signature verification (RED) +- [x] Implement signature verification (GREEN) +- [x] Write tests for webhook endpoint (RED) +- [x] Implement webhook endpoint (GREEN) +- [x] Write tests for event routing (RED) +- [x] Implement event routing (GREEN) +- [x] Add health check endpoint +- [x] Create Dockerfile +- [x] Update docker-compose.yml +- [x] Run quality gates (build, lint, test, coverage) +- [x] Update .env.example with webhook secret +- [ ] Commit implementation +- [ ] Update issue status + +## Testing + +- Unit tests for `security.verify_signature()` +- Unit tests for each event handler (assigned, unassigned, closed) +- Integration test with mock Gitea webhook payload +- Security test: Invalid signature returns 401 +- Health check test + +## Notes + +- Python service alongside NestJS apps (polyglot monorepo) +- Use pytest for testing framework +- Use pydantic for request validation +- Minimum 85% coverage required +- Need to add webhook secret to .env.example + +## Token Tracking + +- Estimated: 52,000 tokens +- Actual: TBD