From 67a1e0bb459f18ff15db96480ea7cc591fe18850 Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Wed, 8 Apr 2026 07:04:50 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Jira=20webhook=20FastAPI=20adapter=20?= =?UTF-8?q?=E2=80=94=20replaces=20Flask=20Blueprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/jira_webhooks.py | 129 ++++++++++++++++++++++++++++++++++++ app/main.py | 2 + connectors/jira/service.py | 2 + tests/test_jira_webhooks.py | 100 ++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 app/api/jira_webhooks.py create mode 100644 tests/test_jira_webhooks.py diff --git a/app/api/jira_webhooks.py b/app/api/jira_webhooks.py new file mode 100644 index 0000000..22c5fb9 --- /dev/null +++ b/app/api/jira_webhooks.py @@ -0,0 +1,129 @@ +""" +Jira webhook endpoints — FastAPI replacement for Flask Blueprint. + +Receives Jira webhook notifications, verifies HMAC-SHA256 signatures, +and delegates processing to the Jira service. +""" + +import hashlib +import hmac +import json +import logging +from datetime import datetime + +from fastapi import APIRouter, Request, Response +from fastapi.responses import JSONResponse + +from connectors.jira.service import Config, get_jira_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/webhooks", tags=["jira-webhooks"]) + +# Path for storing raw webhook events (debugging/audit) +WEBHOOK_LOG_DIR = Config.JIRA_DATA_DIR / "webhook_events" + + +def _verify_signature(payload: bytes, signature: str | None) -> bool: + """Verify HMAC-SHA256 signature from Jira webhook.""" + secret = Config.JIRA_WEBHOOK_SECRET + + if not secret: + logger.warning("JIRA_WEBHOOK_SECRET not configured, skipping signature verification") + return True + + if not signature: + logger.warning("No signature provided in webhook request") + return False + + if signature.startswith("sha256="): + signature = signature[7:] + + expected = hmac.new( + secret.encode("utf-8"), + payload, + hashlib.sha256, + ).hexdigest() + + return hmac.compare_digest(signature, expected) + + +def _log_webhook_event(event_data: dict) -> None: + """Log webhook event to file for debugging/audit.""" + try: + WEBHOOK_LOG_DIR.mkdir(parents=True, exist_ok=True) + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f") + event_type = event_data.get("webhookEvent", "unknown").replace(":", "_") + filename = f"{timestamp}_{event_type}.json" + filepath = WEBHOOK_LOG_DIR / filename + + with open(filepath, "w") as f: + json.dump(event_data, f, indent=2, default=str) + except Exception as e: + logger.warning(f"Failed to log webhook event: {e}") + + +@router.post("/jira") +async def receive_jira_webhook(request: Request) -> Response: + """Receive and process Jira webhook notifications.""" + payload = await request.body() + + # Verify signature + signature = request.headers.get("X-Hub-Signature-256") or request.headers.get("X-Hub-Signature") + if not _verify_signature(payload, signature): + logger.warning("Invalid webhook signature from %s", request.client.host if request.client else "unknown") + return JSONResponse({"detail": "Invalid signature"}, status_code=401) + + # Parse JSON + if not payload: + return JSONResponse({"detail": "Empty payload"}, status_code=400) + + try: + event_data = json.loads(payload) + except (json.JSONDecodeError, ValueError) as e: + logger.error(f"Failed to parse webhook JSON: {e}") + return JSONResponse({"detail": "Invalid JSON payload"}, status_code=400) + + if not event_data: + return JSONResponse({"detail": "Empty payload"}, status_code=400) + + # Log event for debugging + _log_webhook_event(event_data) + + webhook_event = event_data.get("webhookEvent", "unknown") + issue = event_data.get("issue", {}) + issue_key = issue.get("key", "unknown") + + logger.info(f"Received webhook: {webhook_event} for issue {issue_key}") + + jira_service = get_jira_service() + + if not jira_service.is_configured(): + logger.error("Jira service not configured, cannot process webhook") + return JSONResponse( + {"status": "error", "message": "Jira service not configured"}, + status_code=503, + ) + + success = jira_service.process_webhook_event(event_data) + + if success: + return JSONResponse({"status": "ok", "event": webhook_event, "issue": issue_key}) + else: + return JSONResponse( + {"status": "error", "message": "Failed to process event", "event": webhook_event, "issue": issue_key}, + status_code=500, + ) + + +@router.get("/jira/health") +async def jira_webhook_health() -> dict: + """Health check for Jira webhook endpoint.""" + jira_service = get_jira_service() + + return { + "status": "ok", + "configured": jira_service.is_configured(), + "webhook_secret_set": bool(Config.JIRA_WEBHOOK_SECRET), + "jira_domain": Config.JIRA_DOMAIN or "(not set)", + } diff --git a/app/main.py b/app/main.py index 61e1a4f..397fb6e 100644 --- a/app/main.py +++ b/app/main.py @@ -25,6 +25,7 @@ from app.api.telegram import router as telegram_router from app.api.admin import router as admin_router from app.api.permissions import router as permissions_router from app.api.access_requests import router as access_requests_router +from app.api.jira_webhooks import router as jira_webhooks_router from app.web.router import router as web_router logger = logging.getLogger(__name__) @@ -105,6 +106,7 @@ def create_app() -> FastAPI: app.include_router(admin_router) app.include_router(permissions_router) app.include_router(access_requests_router) + app.include_router(jira_webhooks_router) # Web UI router (must be last — has catch-all routes) app.include_router(web_router) diff --git a/connectors/jira/service.py b/connectors/jira/service.py index 4eee3b8..a911678 100644 --- a/connectors/jira/service.py +++ b/connectors/jira/service.py @@ -30,6 +30,8 @@ class _JiraConfig: JIRA_CLOUD_ID = os.environ.get("JIRA_CLOUD_ID", "") JIRA_SLA_EMAIL = os.environ.get("JIRA_SLA_EMAIL", "") JIRA_SLA_API_TOKEN = os.environ.get("JIRA_SLA_API_TOKEN", "") + JIRA_WEBHOOK_SECRET = os.environ.get("JIRA_WEBHOOK_SECRET", "") + DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true") Config = _JiraConfig diff --git a/tests/test_jira_webhooks.py b/tests/test_jira_webhooks.py new file mode 100644 index 0000000..f314636 --- /dev/null +++ b/tests/test_jira_webhooks.py @@ -0,0 +1,100 @@ +"""Tests for Jira webhook FastAPI router.""" + +import hashlib +import hmac +import json +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +def _sign(payload: bytes, secret: str) -> str: + """Compute sha256= for a given payload and secret.""" + mac = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() + return f"sha256={mac}" + + +@pytest.fixture() +def webhook_client(tmp_path, monkeypatch): + """Create a TestClient with required env vars and dirs.""" + data_dir = tmp_path / "data" + data_dir.mkdir() + (data_dir / "issues").mkdir() + + monkeypatch.setenv("DATA_DIR", str(data_dir)) + monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret") + monkeypatch.setenv("JIRA_WEBHOOK_SECRET", "test-webhook-secret") + monkeypatch.setenv("JIRA_DATA_DIR", str(data_dir)) + + # Re-read env into Config (class attrs read os.environ at import time) + from connectors.jira import service as svc + monkeypatch.setattr(svc.Config, "JIRA_WEBHOOK_SECRET", "test-webhook-secret") + monkeypatch.setattr(svc.Config, "JIRA_DATA_DIR", data_dir) + + # Reimport app to pick up router + from app.main import create_app + app = create_app() + return TestClient(app) + + +def test_health(webhook_client): + """GET /webhooks/jira/health returns 200.""" + resp = webhook_client.get("/webhooks/jira/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert "webhook_secret_set" in body + + +def test_missing_signature_401(webhook_client): + """POST without signature header returns 401.""" + payload = json.dumps({"webhookEvent": "jira:issue_updated", "issue": {"key": "TEST-1"}}).encode() + resp = webhook_client.post("/webhooks/jira", content=payload, headers={"Content-Type": "application/json"}) + assert resp.status_code == 401 + + +def test_invalid_signature_401(webhook_client): + """POST with wrong signature returns 401.""" + payload = json.dumps({"webhookEvent": "jira:issue_updated", "issue": {"key": "TEST-1"}}).encode() + resp = webhook_client.post( + "/webhooks/jira", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": "sha256=badhex", + }, + ) + assert resp.status_code == 401 + + +def test_valid_signature_accepted(webhook_client): + """POST with correct HMAC-SHA256 is not rejected as 401.""" + payload = json.dumps({"webhookEvent": "jira:issue_updated", "issue": {"key": "TEST-1"}}).encode() + sig = _sign(payload, "test-webhook-secret") + resp = webhook_client.post( + "/webhooks/jira", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": sig, + }, + ) + # Should pass signature check; 200 or 503 (service not configured) are fine + assert resp.status_code in (200, 503) + + +def test_empty_payload_400(webhook_client): + """POST with empty body and valid signature returns 400.""" + payload = b"" + sig = _sign(payload, "test-webhook-secret") + resp = webhook_client.post( + "/webhooks/jira", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": sig, + }, + ) + assert resp.status_code == 400