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>
This commit is contained in:
@@ -52,6 +52,11 @@ router = APIRouter(prefix="/webhooks/gitea", tags=["Gitea Webhook"])
|
||||
# OpenClaw 配置 (使用 settings 中的 OPENCLAW_URL)
|
||||
OPENCLAW_URL = settings.OPENCLAW_URL
|
||||
|
||||
# Telegram 通知去重 TTL — 10 分鐘,與 Sentry/SLO Watchdog 對齊
|
||||
# 2026-04-25 ogt + Claude Sonnet 4.6 (Task C: Gitea CI/CD 告警轉發 Telegram)
|
||||
GITEA_TG_DEDUP_TTL = 600 # 秒
|
||||
GITEA_TG_DEDUP_KEY_PREFIX = "gitea:tg:dedup:"
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models
|
||||
# =============================================================================
|
||||
@@ -87,6 +92,9 @@ class GiteaPullRequest(BaseModel):
|
||||
additions: int = 0
|
||||
deletions: int = 0
|
||||
changed_files: int = 0
|
||||
# Gitea: HasMerged bool json:"merged" — True 代表 PR 已合併 (action=closed + merged=true)
|
||||
# 2026-04-25 ogt + Claude Sonnet 4.6 (Task C: Gitea CI/CD 告警轉發 Telegram)
|
||||
merged: bool = False
|
||||
|
||||
|
||||
class GiteaCommit(BaseModel):
|
||||
@@ -364,6 +372,61 @@ async def handle_gitea_webhook(
|
||||
) from e
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Telegram 通知 Helper (帶 Redis 去重)
|
||||
# 2026-04-25 ogt + Claude Sonnet 4.6 (Task C: Gitea CI/CD 告警轉發 Telegram)
|
||||
# 設計原則:
|
||||
# - 純通知,不加按鈕(遵循 feedback_no_ghost_buttons.md)
|
||||
# - Redis SET NX EX 600s 去重(同一 repo+event+id 10 分鐘內不重複)
|
||||
# - 不改動 incident 通知鏈路,獨立背景任務
|
||||
# - Telegram token/chat_id 從 settings (K8s Secret 注入) 讀取,不寫死
|
||||
# =============================================================================
|
||||
|
||||
async def _send_gitea_notification(
|
||||
dedup_key: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""
|
||||
發送 Gitea 事件 Telegram 通知(帶去重)
|
||||
|
||||
Args:
|
||||
dedup_key: Redis 去重 key(格式: {event}:{repo}:{id},不含 prefix)
|
||||
message: HTML 格式 Telegram 訊息
|
||||
"""
|
||||
try:
|
||||
# 去重檢查:同一 key 在 TTL 內不重複發送
|
||||
from src.core.redis_client import get_redis # type: ignore[import]
|
||||
redis = await get_redis()
|
||||
full_key = GITEA_TG_DEDUP_KEY_PREFIX + dedup_key
|
||||
acquired = await redis.set(
|
||||
full_key,
|
||||
"1",
|
||||
ex=GITEA_TG_DEDUP_TTL,
|
||||
nx=True, # NX: 只在 key 不存在時設定(原子操作)
|
||||
)
|
||||
if not acquired:
|
||||
logger.debug(
|
||||
"gitea_tg_dedup_skip",
|
||||
dedup_key=dedup_key,
|
||||
ttl=GITEA_TG_DEDUP_TTL,
|
||||
)
|
||||
return
|
||||
|
||||
if not settings.OPENCLAW_TG_BOT_TOKEN:
|
||||
logger.debug("gitea_tg_skipped", reason="Bot token not configured")
|
||||
return
|
||||
|
||||
from src.services.telegram_gateway import get_telegram_gateway # type: ignore[import]
|
||||
gateway = get_telegram_gateway()
|
||||
await gateway.initialize()
|
||||
await gateway.send_notification(message)
|
||||
|
||||
logger.info("gitea_tg_notification_sent", dedup_key=dedup_key)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("gitea_tg_notification_failed", dedup_key=dedup_key, error=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Event Handlers (HTTP 層: 解析、驗證、回應 — 業務邏輯在 Service 層)
|
||||
# =============================================================================
|
||||
@@ -380,6 +443,7 @@ async def handle_pull_request(
|
||||
- opened: 新建 PR
|
||||
- synchronize: 推送新 commit 到 PR
|
||||
- reopened: 重新開啟 PR
|
||||
- closed + merged=True: PR 合併完成 → Telegram 通知 (Task C 2026-04-25)
|
||||
"""
|
||||
pr = payload.pull_request
|
||||
if not pr:
|
||||
@@ -389,6 +453,40 @@ async def handle_pull_request(
|
||||
event_type="pull_request",
|
||||
)
|
||||
|
||||
# PR 合併完成通知 (action=closed + merged=True)
|
||||
# 2026-04-25 ogt + Claude Sonnet 4.6 (Task C: Gitea CI/CD 告警轉發 Telegram)
|
||||
if payload.action == "closed" and pr.merged:
|
||||
repo = payload.repository.full_name
|
||||
author = payload.sender.login
|
||||
pr_url = pr.html_url
|
||||
base_branch = pr.base.get("ref", "main") if isinstance(pr.base, dict) else "main"
|
||||
|
||||
# 格式遵循 feedback_telegram_alert_format.md
|
||||
message = (
|
||||
f"<b>PR Merged</b> | {repo}\n"
|
||||
"──────────────────────\n"
|
||||
f"├─ PR: <a href=\"{pr_url}\">#{pr.number} {pr.title[:60]}</a>\n"
|
||||
f"├─ 作者: @{author}\n"
|
||||
f"├─ 目標分支: {base_branch}\n"
|
||||
f"└─ 變更: +{pr.additions} -{pr.deletions} ({pr.changed_files} 檔)"
|
||||
)
|
||||
|
||||
dedup_key = f"pr_merged:{repo}:{pr.number}"
|
||||
background_tasks.add_task(_send_gitea_notification, dedup_key, message)
|
||||
|
||||
logger.info(
|
||||
"gitea_pr_merged_notification_scheduled",
|
||||
repo=repo,
|
||||
pr_number=pr.number,
|
||||
author=author,
|
||||
)
|
||||
|
||||
return GiteaWebhookResponse(
|
||||
status="accepted",
|
||||
message=f"PR #{pr.number} merge notification scheduled",
|
||||
event_type="pull_request",
|
||||
)
|
||||
|
||||
# 只處理需要審查的 action
|
||||
supported_actions = {"opened", "synchronize", "reopened"}
|
||||
if payload.action not in supported_actions:
|
||||
@@ -498,7 +596,11 @@ async def handle_workflow_run(
|
||||
處理 Gitea Actions workflow_run 事件 — ADR-074 M3
|
||||
|
||||
只處理 status=failure(或 conclusion=failure)的管線失敗。
|
||||
建立 TYPE-1 Incident(純通知,不自動修復)。
|
||||
雙路並行:
|
||||
1. 建立 TYPE-1 Incident(既有路徑,保持不變)
|
||||
2. 直接發 Telegram 通知(Task C 2026-04-25 新增)
|
||||
- workflow name 含 deploy → "部署失敗"
|
||||
- 否則 → "構建失敗"
|
||||
"""
|
||||
wf = payload.workflow_run
|
||||
if not wf:
|
||||
@@ -531,6 +633,7 @@ async def handle_workflow_run(
|
||||
run_url=run_url,
|
||||
)
|
||||
|
||||
# 既有路徑:建立 TYPE-1 Incident (保持不變)
|
||||
async def _create_ci_incident() -> None:
|
||||
try:
|
||||
svc = get_incident_service()
|
||||
@@ -562,6 +665,26 @@ async def handle_workflow_run(
|
||||
|
||||
background_tasks.add_task(_create_ci_incident)
|
||||
|
||||
# 新增路徑:直接 Telegram 通知 (Task C 2026-04-25 ogt + Claude Sonnet 4.6)
|
||||
# workflow name 含 deploy 關鍵字 → 部署失敗;否則 → 構建失敗
|
||||
# 格式遵循 feedback_telegram_alert_format.md:狀態 + 資源 + 連結
|
||||
is_deploy = "deploy" in wf.name.lower()
|
||||
event_label = "Deployment Failed" if is_deploy else "Build Failed"
|
||||
run_link = f" | <a href=\"{run_url}\">查看日誌</a>" if run_url else ""
|
||||
|
||||
tg_message = (
|
||||
f"<b>{event_label}</b> | {repo}\n"
|
||||
"──────────────────────\n"
|
||||
f"├─ Workflow: <code>{wf.name}</code>\n"
|
||||
f"├─ 分支: {branch}\n"
|
||||
f"├─ Commit: <code>{sha_short}</code>\n"
|
||||
f"└─ 狀態: failure{run_link}"
|
||||
)
|
||||
|
||||
# 去重 key:同一 repo + workflow + branch + sha 的失敗,10 分鐘內不重複
|
||||
dedup_key = f"workflow_failure:{repo}:{wf.name}:{branch}:{sha_short}"
|
||||
background_tasks.add_task(_send_gitea_notification, dedup_key, tg_message)
|
||||
|
||||
return GiteaWebhookResponse(
|
||||
status="accepted",
|
||||
message=f"CI pipeline failure for '{wf.name}' on '{branch}' queued as TYPE-1 incident",
|
||||
|
||||
@@ -487,6 +487,253 @@ async def test_webhook_pr_unsupported_action(webhook_secret):
|
||||
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"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 簽章格式測試
|
||||
# =============================================================================
|
||||
|
||||
197
apps/api/tests/test_telegram_button_consistency.py
Normal file
197
apps/api/tests/test_telegram_button_consistency.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Telegram 按鈕一致性測試
|
||||
======================
|
||||
驗證 send_info_notification 與 _send_approval_card_to_group 的 reply_markup 鏈路
|
||||
|
||||
測試策略 (遵循 feedback_no_mock_testing.md):
|
||||
- 直接讀取 source code 驗證實作結構(不發送實際 Telegram 訊息)
|
||||
- 鬼魂按鈕鐵律(feedback_no_ghost_buttons.md):
|
||||
每個按鈕必須同時具備 callback_data 格式/handler/MCP能力
|
||||
|
||||
建立: 2026-04-25 (台北時區)
|
||||
建立者: ogt + Claude Sonnet 4.6
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def _read_gateway() -> str:
|
||||
with open("src/services/telegram_gateway.py", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _read_security_interceptor() -> str:
|
||||
with open("src/services/security_interceptor.py", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: send_info_notification 按鈕結構
|
||||
# =============================================================================
|
||||
|
||||
class TestSendInfoNotificationKeyboard:
|
||||
"""驗證 TYPE-1 純資訊通知現在附帶 read-only 按鈕"""
|
||||
|
||||
def test_send_info_notification_has_reply_markup(self):
|
||||
"""send_info_notification payload 包含 reply_markup"""
|
||||
source = _read_gateway()
|
||||
# 取出 send_info_notification 函式體(從定義到下一個 async def)
|
||||
match = re.search(
|
||||
r"async def send_info_notification.*?(?=\n async def |\n # ====)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "找不到 send_info_notification 函式"
|
||||
fn_body = match.group(0)
|
||||
assert '"reply_markup"' in fn_body, (
|
||||
"send_info_notification 必須在 payload 中包含 reply_markup"
|
||||
)
|
||||
|
||||
def test_send_info_notification_detail_button_correct_format(self):
|
||||
"""detail 按鈕使用 2-part info 格式 detail:{incident_id}"""
|
||||
source = _read_gateway()
|
||||
match = re.search(
|
||||
r"async def send_info_notification.*?(?=\n async def |\n # ====)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "找不到 send_info_notification 函式"
|
||||
fn_body = match.group(0)
|
||||
assert 'f"detail:{incident_id}"' in fn_body, (
|
||||
"detail 按鈕 callback_data 必須使用 detail:{incident_id} 格式(ADR-050 2-part info)"
|
||||
)
|
||||
|
||||
def test_send_info_notification_history_button_correct_format(self):
|
||||
"""history 按鈕使用 2-part info 格式 history:{incident_id}"""
|
||||
source = _read_gateway()
|
||||
match = re.search(
|
||||
r"async def send_info_notification.*?(?=\n async def |\n # ====)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "找不到 send_info_notification 函式"
|
||||
fn_body = match.group(0)
|
||||
assert 'f"history:{incident_id}"' in fn_body, (
|
||||
"history 按鈕 callback_data 必須使用 history:{incident_id} 格式(ADR-050 2-part info)"
|
||||
)
|
||||
|
||||
def test_send_info_notification_no_nonce_in_buttons(self):
|
||||
"""send_info_notification 按鈕不含 nonce(純查類,無副作用)"""
|
||||
source = _read_gateway()
|
||||
match = re.search(
|
||||
r"async def send_info_notification.*?(?=\n async def |\n # ====)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "找不到 send_info_notification 函式"
|
||||
fn_body = match.group(0)
|
||||
assert "generate_callback_nonce" not in fn_body, (
|
||||
"send_info_notification 的按鈕不應包含 nonce(TYPE-1 為純資訊,不應有寫類操作)"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: _send_approval_card_to_group 按鈕結構
|
||||
# =============================================================================
|
||||
|
||||
class TestSendApprovalCardToGroupKeyboard:
|
||||
"""驗證群組卡片現在附帶 read-only 按鈕"""
|
||||
|
||||
def test_group_card_calls_send_to_group_with_reply_markup(self):
|
||||
"""_send_approval_card_to_group 呼叫 send_to_group 時傳遞 reply_markup"""
|
||||
source = _read_gateway()
|
||||
match = re.search(
|
||||
r"async def _send_approval_card_to_group.*?(?=\n # ====)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "找不到 _send_approval_card_to_group 函式"
|
||||
fn_body = match.group(0)
|
||||
assert "reply_markup" in fn_body, (
|
||||
"_send_approval_card_to_group 必須傳 reply_markup 給 send_to_group"
|
||||
)
|
||||
|
||||
def test_group_card_detail_button_correct_format(self):
|
||||
"""群組卡片 detail 按鈕使用 2-part info 格式"""
|
||||
source = _read_gateway()
|
||||
match = re.search(
|
||||
r"async def _send_approval_card_to_group.*?(?=\n # ====)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "找不到 _send_approval_card_to_group 函式"
|
||||
fn_body = match.group(0)
|
||||
assert 'f"detail:{incident_id}"' in fn_body, (
|
||||
"群組卡片 detail 按鈕必須使用 2-part info 格式"
|
||||
)
|
||||
|
||||
def test_group_card_no_nonce_buttons(self):
|
||||
"""群組卡片絕不包含 nonce(ADR-075 斷點 C:防洩漏)"""
|
||||
source = _read_gateway()
|
||||
match = re.search(
|
||||
r"async def _send_approval_card_to_group.*?(?=\n # ====)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "找不到 _send_approval_card_to_group 函式"
|
||||
fn_body = match.group(0)
|
||||
assert "generate_callback_nonce" not in fn_body, (
|
||||
"_send_approval_card_to_group 群組訊息絕不含 nonce(ADR-075 斷點 C 安全設計)"
|
||||
)
|
||||
|
||||
def test_group_card_keyboard_conditional_on_incident_id(self):
|
||||
"""群組按鍵僅在有 incident_id 時才建立(防 empty callback_data)"""
|
||||
source = _read_gateway()
|
||||
match = re.search(
|
||||
r"async def _send_approval_card_to_group.*?(?=\n # ====)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "找不到 _send_approval_card_to_group 函式"
|
||||
fn_body = match.group(0)
|
||||
# 確認有 if incident_id 守衛
|
||||
assert "if incident_id" in fn_body, (
|
||||
"群組按鍵必須有 if incident_id 守衛,避免 callback_data 空字串"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: callback handler 完整性(鬼魂按鈕鐵律)
|
||||
# =============================================================================
|
||||
|
||||
class TestCallbackHandlerCompleteness:
|
||||
"""驗證 detail/history 按鈕有對應的 handler(鬼魂按鈕鐵律三條件之二)"""
|
||||
|
||||
def test_detail_action_in_info_actions_whitelist(self):
|
||||
"""detail action 在 INFO_ACTIONS 白名單中"""
|
||||
source = _read_security_interceptor()
|
||||
assert '"detail"' in source, "detail 必須在 INFO_ACTIONS 白名單"
|
||||
# 確認是在 INFO_ACTIONS 集合定義中
|
||||
assert "INFO_ACTIONS" in source
|
||||
|
||||
def test_history_action_in_info_actions_whitelist(self):
|
||||
"""history action 在 INFO_ACTIONS 白名單中"""
|
||||
source = _read_security_interceptor()
|
||||
assert '"history"' in source, "history 必須在 INFO_ACTIONS 白名單"
|
||||
|
||||
def test_detail_handler_exists_in_handle_callback(self):
|
||||
"""handle_callback 有 detail handler 分支"""
|
||||
source = _read_gateway()
|
||||
assert 'action == "detail"' in source, (
|
||||
"handle_callback 必須有 detail handler 分支"
|
||||
)
|
||||
|
||||
def test_history_handler_exists_in_handle_callback(self):
|
||||
"""handle_callback 有 history handler 分支"""
|
||||
source = _read_gateway()
|
||||
assert 'action == "history"' in source, (
|
||||
"handle_callback 必須有 history handler 分支"
|
||||
)
|
||||
|
||||
def test_send_incident_detail_method_exists(self):
|
||||
"""_send_incident_detail 方法存在(handler 的實際執行體)"""
|
||||
assert "_send_incident_detail" in _read_gateway()
|
||||
|
||||
def test_send_incident_history_method_exists(self):
|
||||
"""_send_incident_history 方法存在(handler 的實際執行體)"""
|
||||
assert "_send_incident_history" in _read_gateway()
|
||||
Reference in New Issue
Block a user