""" ADR-059: Gitea Webhook 整合測試 =================================== 測試 Gitea Webhook → OpenClaw AI 代碼審查整合 測試策略 (遵循 feedback_no_mock_testing.md): - 使用 ASGITransport 撞擊真實端點 - 不使用 Mock,直接測試 HTTP 層 - 驗證 HMAC 簽章邏輯 (X-Gitea-Signature) 🔴 IMPORTANT: 禁止 Mock 測試! """ import hashlib import hmac import json import httpx import pytest from fastapi import FastAPI from httpx import ASGITransport # 2026-04-05 Claude Code: 改用最小化 app,只掛載 gitea_webhook router # 原 `from src.main import app` 會 import 整個應用,觸發 sqlalchemy.ext.asyncio # C extension (asyncpg.protocol.protocol) 在 CI runner 上 segfault (exit 139) # gitea_webhook router 的 import chain 不走 DB,可獨立測試 from src.api.v1.gitea_webhook import router as gitea_webhook_router app = FastAPI() app.include_router(gitea_webhook_router, prefix="/api/v1") # 環境變數設定已移至 conftest.py (解決 E402) # ============================================================================= # Test Fixtures # ============================================================================= @pytest.fixture def webhook_secret(): """測試用 Webhook Secret""" return "test-secret-key-12345" @pytest.fixture def sample_pr_payload(): """範例 PR Payload""" return { "action": "opened", "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": { "login": "test-user", "id": 1, "avatar_url": "https://github.com/images/error/octocat_happy.gif", }, "pull_request": { "id": 1, "number": 42, "title": "Add new feature", "body": "This PR adds a new feature", "state": "open", "html_url": "https://github.com/test-owner/test-repo/pull/42", "diff_url": "https://github.com/test-owner/test-repo/pull/42.diff", "user": { "login": "test-user", "id": 1, }, "head": {"ref": "feature-branch", "sha": "abc123"}, "base": {"ref": "main", "sha": "def456"}, "additions": 50, "deletions": 10, "changed_files": 3, }, } @pytest.fixture def sample_push_payload(): """範例 Push Payload""" return { "ref": "refs/heads/main", "before": "0000000000000000000000000000000000000000", "after": "abc1234567890abcdef1234567890abcdef12345", "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": { "login": "test-user", "id": 1, }, "pusher": { "name": "test-user", "email": "test@example.com", }, "commits": [ { "id": "abc1234567890abcdef1234567890abcdef12345", "message": "feat: add new feature", "timestamp": "2026-03-26T10:00:00+08:00", "url": "https://github.com/test-owner/test-repo/commit/abc123", "author": {"name": "Test User", "email": "test@example.com"}, "added": ["new_file.py"], "removed": [], "modified": ["existing_file.py"], } ], } @pytest.fixture def ping_payload(): """Ping 事件 Payload""" return { "zen": "Responsive is better than fast.", "hook_id": 12345, "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": { "login": "test-user", "id": 1, }, } def generate_signature(secret: str, body: bytes) -> str: """生成 Gitea Webhook 簽章 (X-Gitea-Signature) Gitea 送出純 hex(無 sha256= 前綴),與 GitHub 不同。 2026-04-05 ogt: 修正為純 hex 格式 """ return hmac.new( secret.encode(), body, hashlib.sha256, ).hexdigest() def prepare_request(secret: str, payload: dict) -> tuple[bytes, str]: """準備請求 body 和簽章""" body = json.dumps(payload, separators=(',', ':')).encode() signature = generate_signature(secret, body) return body, signature # ============================================================================= # HMAC 簽章驗證測試 # ============================================================================= @pytest.mark.asyncio async def test_webhook_missing_signature(sample_pr_payload): """測試: 缺少簽章應該被拒絕""" body = json.dumps(sample_pr_payload, separators=(',', ':')).encode() async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "pull_request", "X-Gitea-Delivery": "test-delivery-id", # 故意不提供 X-Gitea-Signature }, ) # 在 dev 環境下,缺少 secret 但配置了 secret,應該要求簽章 # 由於我們設定了 GITEA_WEBHOOK_SECRET,缺少簽章應該返回 401 assert response.status_code == 401 @pytest.mark.asyncio async def test_webhook_invalid_signature(sample_pr_payload): """測試: 無效簽章應該被拒絕""" body = json.dumps(sample_pr_payload, separators=(',', ':')).encode() async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "pull_request", "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": "sha256=invalid_signature_here", }, ) assert response.status_code == 401 @pytest.mark.asyncio async def test_webhook_valid_signature(sample_pr_payload, webhook_secret): """測試: 有效簽章應該被接受""" body, signature = prepare_request(webhook_secret, sample_pr_payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "pull_request", "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": signature, }, ) # 應該返回 202 Accepted assert response.status_code == 202 data = response.json() assert data["status"] == "accepted" assert data["event_type"] == "pull_request" assert data["review_id"] is not None # ============================================================================= # 倉庫白名單測試 # ============================================================================= @pytest.mark.asyncio async def test_webhook_repo_not_in_whitelist(webhook_secret): """測試: 不在白名單的倉庫應該被忽略""" payload = { "action": "opened", "repository": { "id": 999999, "name": "unauthorized-repo", "full_name": "unknown-owner/unauthorized-repo", # 不在白名單 "private": False, "html_url": "https://github.com/unknown-owner/unauthorized-repo", }, "sender": { "login": "hacker", "id": 999, }, "pull_request": { "id": 1, "number": 1, "title": "Malicious PR", "state": "open", "html_url": "https://github.com/unknown-owner/unauthorized-repo/pull/1", "diff_url": "https://github.com/unknown-owner/unauthorized-repo/pull/1.diff", "user": {"login": "hacker", "id": 999}, "head": {"ref": "evil", "sha": "bad123"}, "base": {"ref": "main", "sha": "good456"}, }, } body, signature = prepare_request(webhook_secret, payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "pull_request", "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "ignored" assert "not in whitelist" in data["message"] # ============================================================================= # 事件類型測試 # ============================================================================= @pytest.mark.asyncio async def test_webhook_ping_event(ping_payload, webhook_secret): """測試: Ping 事件應該回應 Pong""" body, signature = prepare_request(webhook_secret, ping_payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "ping", "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "accepted" assert "Pong" in data["message"] assert data["event_type"] == "ping" @pytest.mark.asyncio async def test_webhook_push_event(sample_push_payload, webhook_secret): """測試: Push 到主分支應該觸發審查""" body, signature = prepare_request(webhook_secret, sample_push_payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "push", "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "accepted" assert data["event_type"] == "push" assert data["review_id"] is not None @pytest.mark.asyncio async def test_webhook_push_non_default_branch(webhook_secret): """測試: Push 到非主分支應該被忽略""" payload = { "ref": "refs/heads/feature-branch", # 非主分支 "before": "0000000000000000000000000000000000000000", "after": "abc123", "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": {"login": "test-user", "id": 1}, "commits": [ { "id": "abc123", "message": "feature commit", "timestamp": "2026-03-26T10:00:00+08:00", "url": "https://github.com/test-owner/test-repo/commit/abc123", "author": {"name": "Test", "email": "test@example.com"}, } ], } body, signature = prepare_request(webhook_secret, payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "push", "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "ignored" assert "non-default branch" in data["message"] @pytest.mark.asyncio async def test_webhook_unsupported_event(webhook_secret): """測試: 不支援的事件類型應該被忽略""" payload = { "action": "added", "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": {"login": "test-user", "id": 1}, } body, signature = prepare_request(webhook_secret, payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "star", # 不支援的事件 "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "ignored" assert "not supported" in data["message"] @pytest.mark.asyncio async def test_webhook_pr_unsupported_action(webhook_secret): """測試: PR 不支援的 action 應該被忽略""" payload = { "action": "closed", # 我們只處理 opened, synchronize, reopened "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": {"login": "test-user", "id": 1}, "pull_request": { "id": 1, "number": 42, "title": "Closed PR", "state": "closed", "html_url": "https://github.com/test-owner/test-repo/pull/42", "diff_url": "https://github.com/test-owner/test-repo/pull/42.diff", "user": {"login": "test-user", "id": 1}, "head": {"ref": "feature", "sha": "abc"}, "base": {"ref": "main", "sha": "def"}, }, } body, signature = prepare_request(webhook_secret, payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "pull_request", "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "ignored" assert "not supported" in data["message"] # ============================================================================= # Task C: Gitea CI/CD 告警轉發 Telegram 測試 (2026-04-25) # ============================================================================= @pytest.mark.asyncio async def test_webhook_pr_closed_not_merged_ignored(webhook_secret): """測試: PR closed 但未合併 (merged=False) 應該被忽略(不發通知)""" payload = { "action": "closed", "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": {"login": "test-user", "id": 1}, "pull_request": { "id": 1, "number": 42, "title": "Closed without merge", "state": "closed", "merged": False, # 明確標記未合併 "html_url": "https://github.com/test-owner/test-repo/pull/42", "diff_url": "https://github.com/test-owner/test-repo/pull/42.diff", "user": {"login": "test-user", "id": 1}, "head": {"ref": "feature", "sha": "abc"}, "base": {"ref": "main", "sha": "def"}, }, } body, signature = prepare_request(webhook_secret, payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "pull_request", "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "ignored" assert "not supported" in data["message"] @pytest.mark.asyncio async def test_webhook_pr_merged_accepted(webhook_secret): """測試: PR merged (action=closed + merged=True) 應該回 accepted 並排入通知""" payload = { "action": "closed", "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": {"login": "test-user", "id": 1}, "pull_request": { "id": 1, "number": 99, "title": "feat: add CI/CD Telegram notifications", "state": "closed", "merged": True, # 合併完成 "html_url": "https://github.com/test-owner/test-repo/pull/99", "diff_url": "https://github.com/test-owner/test-repo/pull/99.diff", "user": {"login": "test-user", "id": 1}, "head": {"ref": "feat/ci-telegram", "sha": "abc123"}, "base": {"ref": "main", "sha": "def456"}, "additions": 150, "deletions": 20, "changed_files": 5, }, } body, signature = prepare_request(webhook_secret, payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "pull_request", "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "accepted" assert "merge notification" in data["message"] assert data["event_type"] == "pull_request" @pytest.mark.asyncio async def test_webhook_workflow_run_failure_accepted(webhook_secret): """測試: workflow_run failure 應該回 accepted 並排入 incident + Telegram 通知""" payload = { "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": {"login": "ci-bot", "id": 2}, "workflow_run": { "id": 9001, "name": "CI Build", "status": "failure", "conclusion": "failure", "head_sha": "deadbeef12345678", "head_branch": "main", "html_url": "https://github.com/test-owner/test-repo/actions/runs/9001", }, } body, signature = prepare_request(webhook_secret, payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "workflow_run", "X-Gitea-Delivery": "test-delivery-wf", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "accepted" assert data["event_type"] == "workflow_run" assert "CI Build" in data["message"] @pytest.mark.asyncio async def test_webhook_workflow_run_success_ignored(webhook_secret): """測試: workflow_run success 應該被忽略(只關心失敗)""" payload = { "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": {"login": "ci-bot", "id": 2}, "workflow_run": { "id": 9002, "name": "CI Build", "status": "success", "conclusion": "success", "head_sha": "aabbccdd12345678", "head_branch": "main", "html_url": "https://github.com/test-owner/test-repo/actions/runs/9002", }, } body, signature = prepare_request(webhook_secret, payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "workflow_run", "X-Gitea-Delivery": "test-delivery-wf-ok", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "ignored" assert "not a failure" in data["message"] @pytest.mark.asyncio async def test_webhook_deploy_failure_detected(webhook_secret): """測試: workflow name 含 deploy → 事件標記為部署失敗(不同於構建失敗)""" payload = { "repository": { "id": 123456, "name": "test-repo", "full_name": "test-owner/test-repo", "private": False, "html_url": "https://github.com/test-owner/test-repo", }, "sender": {"login": "ci-bot", "id": 2}, "workflow_run": { "id": 9003, "name": "Deploy to Production", # 含 deploy "status": "failure", "conclusion": "failure", "head_sha": "feed1234abcd5678", "head_branch": "main", "html_url": "https://github.com/test-owner/test-repo/actions/runs/9003", }, } body, signature = prepare_request(webhook_secret, payload) async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "workflow_run", "X-Gitea-Delivery": "test-delivery-deploy", "X-Gitea-Signature": signature, }, ) assert response.status_code == 202 data = response.json() assert data["status"] == "accepted" assert data["event_type"] == "workflow_run" # ============================================================================= # 簽章格式測試 # ============================================================================= @pytest.mark.asyncio async def test_webhook_wrong_signature_format(sample_pr_payload): """測試: 錯誤的簽章格式應該被拒絕""" body = json.dumps(sample_pr_payload, separators=(',', ':')).encode() async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as client: response = await client.post( "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "pull_request", "X-Gitea-Delivery": "test-delivery-id", "X-Gitea-Signature": "md5=wrong_format", # 錯誤格式 }, ) assert response.status_code == 401 # ============================================================================= # 執行測試 # ============================================================================= if __name__ == "__main__": pytest.main([__file__, "-v"])