Files
awoooi/apps/api/tests/test_gitea_webhook.py
Your Name d3a4fb4d15 feat(t0): Task A 按鈕一致性測試 + Task C Gitea→Telegram 通知收尾
Task A — Telegram 按鈕鬼魂鐵律測試(補測 production telegram_gateway.py)
- test_telegram_button_consistency.py 新增 14 測試
  - send_info_notification 兩鍵 [📋 詳情][📊 歷史]
  - _send_approval_card_to_group reply_markup
  - callback_data 對齊 INFO_ACTIONS 白名單
  - parse_callback_data + handler 完整性

Task C — Gitea CI/CD → Telegram 告警轉發
- GiteaPullRequest.merged 欄位(HasMerged bool json:"merged")
- _send_gitea_notification helper:Redis SET NX EX 600s 去重
- handle_pull_request: closed+merged → PR Merged Telegram 卡片
- handle_workflow_run: status=failure → 部署/構建失敗卡片
- 不加按鈕(feedback_no_ghost_buttons.md 合規)
- test_gitea_webhook.py +247 行新測試

驗收: K8s GITEA_WEBHOOK_SECRET 64 bytes 
      Gitea hook #4 events: pull_request + push + workflow_run 
      端點 HMAC 401 驗簽 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:17:17 +08:00

770 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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"])