feat: add all application source code

- apps/api: FastAPI backend with Dockerfile
- apps/web: Next.js frontend with Dockerfile
- apps/sensor: Signal collection agent
- packages: shared packages

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-22 18:57:44 +08:00
parent a840bf975b
commit 196d269b92
245 changed files with 42207 additions and 6 deletions

View File

@@ -0,0 +1,495 @@
#!/usr/bin/env python3
"""
Phase 5 E2E 網路層測試 - HMAC 安全驗證 + Nonce 防重放
=====================================================
首席架構師要求: 必須真正撞擊網路端點,驗證安全機制有效性
測試涵蓋:
1. HMAC 驗證 - 缺少 Header
2. HMAC 驗證 - 簽章錯誤
3. HMAC 驗證 - 正確簽章
4. Telegram Nonce - 重放攻擊防禦
5. Telegram 白名單 - 未授權使用者
使用方式:
cd apps/api && pytest tests/e2e_network_test.py -v
"""
import hashlib
import hmac
import json
import pytest
from unittest.mock import patch
import httpx
from httpx import ASGITransport, AsyncClient
from src.main import app
from src.core.config import settings
# =============================================================================
# Helper Functions
# =============================================================================
def compute_hmac_signature(secret: str, payload: dict) -> str:
"""計算 HMAC-SHA256 簽章"""
body = json.dumps(payload).encode()
signature = hmac.new(
secret.encode(),
body,
hashlib.sha256,
).hexdigest()
return f"sha256={signature}"
# =============================================================================
# Test Fixtures
# =============================================================================
@pytest.fixture
def hmac_secret():
"""測試用 HMAC Secret"""
return "test-hmac-secret-for-e2e-testing"
@pytest.fixture
def valid_alert_payload():
"""有效的告警 Payload"""
return {
"alert_type": "k8s_pod_crash",
"severity": "warning",
"source": "prometheus",
"target_resource": "test-pod-123",
"namespace": "default",
"message": "E2E Test Alert",
"metrics": {"cpu_percent": 50},
}
# =============================================================================
# Test: HMAC Verification
# =============================================================================
class TestHMACVerification:
"""HMAC 簽章驗證測試套件"""
@pytest.mark.asyncio
async def test_missing_hmac_header_in_prod(
self,
hmac_secret: str,
valid_alert_payload: dict,
):
"""
[Edge Case 1] 缺少 HMAC Header (生產環境)
預期: 401 Unauthorized
"""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
with patch.object(settings, "WEBHOOK_HMAC_SECRET", hmac_secret):
with patch.object(settings, "ENVIRONMENT", "prod"):
response = await client.post(
"/api/v1/webhooks/alerts",
json=valid_alert_payload,
# 故意不帶 X-Signature-256 Header
)
assert response.status_code == 401
assert "HMAC verification failed" in response.json()["detail"]
assert "Missing X-Signature-256" in response.json()["detail"]
@pytest.mark.asyncio
async def test_missing_hmac_header_in_dev_without_secret(
self,
valid_alert_payload: dict,
):
"""
[Edge Case 2] 開發環境無 Secret 設定 - 允許跳過驗證
預期: 通過 (200) 或 業務邏輯錯誤 (非 401)
"""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
with patch.object(settings, "WEBHOOK_HMAC_SECRET", ""):
with patch.object(settings, "ENVIRONMENT", "dev"):
response = await client.post(
"/api/v1/webhooks/alerts",
json=valid_alert_payload,
)
# 開發環境允許跳過 HMAC不應該是 401
assert response.status_code != 401
@pytest.mark.asyncio
async def test_wrong_hmac_signature(
self,
hmac_secret: str,
valid_alert_payload: dict,
):
"""
[Edge Case 3] HMAC 簽章錯誤
預期: 401 Unauthorized
"""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
with patch.object(settings, "WEBHOOK_HMAC_SECRET", hmac_secret):
with patch.object(settings, "ENVIRONMENT", "prod"):
response = await client.post(
"/api/v1/webhooks/alerts",
json=valid_alert_payload,
headers={
"X-Signature-256": "sha256=0000000000000000000000000000000000000000000000000000000000000000",
},
)
assert response.status_code == 401
assert "HMAC verification failed" in response.json()["detail"]
assert "Invalid signature" in response.json()["detail"]
@pytest.mark.asyncio
async def test_invalid_signature_format(
self,
hmac_secret: str,
valid_alert_payload: dict,
):
"""
[Edge Case 4] 簽章格式錯誤 (非 sha256= 開頭)
預期: 401 Unauthorized
"""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
with patch.object(settings, "WEBHOOK_HMAC_SECRET", hmac_secret):
with patch.object(settings, "ENVIRONMENT", "prod"):
response = await client.post(
"/api/v1/webhooks/alerts",
json=valid_alert_payload,
headers={
"X-Signature-256": "md5=invalid_format",
},
)
assert response.status_code == 401
assert "Invalid signature format" in response.json()["detail"]
@pytest.mark.asyncio
async def test_valid_hmac_signature(
self,
hmac_secret: str,
valid_alert_payload: dict,
):
"""
[Happy Path] 正確的 HMAC 簽章
預期: 通過 HMAC 驗證 (200 或業務邏輯錯誤,但非 401)
注意: 必須使用與 httpx 相同的 JSON 序列化方式
"""
# 使用與 httpx 相同的 JSON 序列化 (separators 無空格)
import json
body = json.dumps(valid_alert_payload, separators=(",", ":")).encode()
signature = "sha256=" + hmac.new(
hmac_secret.encode(),
body,
hashlib.sha256,
).hexdigest()
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
with patch.object(settings, "WEBHOOK_HMAC_SECRET", hmac_secret):
with patch.object(settings, "ENVIRONMENT", "prod"):
response = await client.post(
"/api/v1/webhooks/alerts",
content=body,
headers={
"Content-Type": "application/json",
"X-Signature-256": signature,
},
)
# 不應該是 401 (HMAC 錯誤)
# 可能是 200 或其他業務錯誤 (如 DB 連線)
assert response.status_code != 401, f"HMAC 驗證應該通過,但收到: {response.json()}"
@pytest.mark.asyncio
async def test_hmac_secret_missing_in_prod_blocks_request(
self,
valid_alert_payload: dict,
):
"""
[Edge Case 5] 生產環境未設定 Secret - Fail-Closed
預期: 401 Unauthorized (嚴禁跳過)
"""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
with patch.object(settings, "WEBHOOK_HMAC_SECRET", ""):
with patch.object(settings, "ENVIRONMENT", "prod"):
response = await client.post(
"/api/v1/webhooks/alerts",
json=valid_alert_payload,
)
assert response.status_code == 401
assert "WEBHOOK_HMAC_SECRET missing in production" in response.json()["detail"]
# =============================================================================
# Test: Telegram Security Interceptor
# =============================================================================
class TestTelegramSecurityInterceptor:
"""Telegram 安全攔截器測試套件"""
def test_nonce_generation_and_parsing(self):
"""
[Unit Test] Nonce 生成與解析
驗證 Nonce 結構正確
"""
from src.services.security_interceptor import TelegramSecurityInterceptor
interceptor = TelegramSecurityInterceptor()
# 生成 Nonce
approval_id = "test-approval-123"
action = "approve"
nonce = interceptor.generate_callback_nonce(approval_id, action)
# 解析 Nonce
parsed = interceptor.parse_callback_data(nonce)
assert parsed["action"] == action
assert parsed["approval_id"] == approval_id
assert "nonce" in parsed
@pytest.mark.asyncio
async def test_nonce_replay_attack_blocked(self):
"""
[Edge Case] Nonce 重放攻擊 - 必須被阻擋
同一個 Nonce 第二次使用應該被拒絕
"""
from src.services.security_interceptor import (
TelegramSecurityInterceptor,
NonceReplayError,
)
interceptor = TelegramSecurityInterceptor()
await interceptor.initialize()
# 生成 Nonce
approval_id = "replay-test-456"
nonce = interceptor.generate_callback_nonce(approval_id, "approve")
parsed = interceptor.parse_callback_data(nonce)
# 模擬白名單使用者
with patch.object(settings, "OPENCLAW_TG_USER_WHITELIST", [12345]):
# 第一次使用 - 應該成功
user = await interceptor.verify_callback(
user_id=12345,
callback_id="callback-1",
nonce=parsed["nonce"],
)
assert user.is_whitelisted
# 第二次使用相同 Nonce - 應該被阻擋
with pytest.raises(NonceReplayError):
await interceptor.verify_callback(
user_id=12345,
callback_id="callback-2",
nonce=parsed["nonce"],
)
@pytest.mark.asyncio
async def test_whitelist_enforcement(self):
"""
[Edge Case] 白名單驗證 - 未授權使用者
非白名單使用者應該被拒絕
"""
from src.services.security_interceptor import (
TelegramSecurityInterceptor,
UserNotWhitelistedError,
)
interceptor = TelegramSecurityInterceptor()
await interceptor.initialize()
# 設定白名單只有 12345
with patch.object(settings, "OPENCLAW_TG_USER_WHITELIST", [12345]):
# 白名單使用者 - 應該通過
assert interceptor.is_whitelisted(12345) is True
# 非白名單使用者 - 應該被拒絕
assert interceptor.is_whitelisted(99999) is False
# 嘗試驗證非白名單使用者 - 應該拋出例外
with pytest.raises(UserNotWhitelistedError):
await interceptor.verify_callback(
user_id=99999,
callback_id="callback-blocked",
nonce=None,
)
# =============================================================================
# Test: Telegram Webhook Endpoint
# =============================================================================
class TestTelegramWebhook:
"""Telegram Webhook 端點測試"""
@pytest.mark.asyncio
async def test_webhook_ignores_non_callback_query(self):
"""
[Edge Case] 非 callback_query 的 Update 應該被忽略
預期: 200 OK, 但無實際處理
"""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
response = await client.post(
"/api/v1/telegram/webhook",
json={
"update_id": 123456,
"message": {
"text": "Hello",
},
},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert "Ignored" in data["message"]
@pytest.mark.asyncio
async def test_webhook_rejects_invalid_callback_data(self):
"""
[Edge Case] 缺少必要欄位的 callback_query
預期: 200 OK, 但回傳錯誤訊息
"""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
response = await client.post(
"/api/v1/telegram/webhook",
json={
"update_id": 123456,
"callback_query": {
"id": "callback-123",
# 缺少 data 和 from
},
},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is False
assert "Invalid callback data" in data["message"]
# =============================================================================
# Test: Shadow Mode (物理繳械)
# =============================================================================
class TestShadowMode:
"""影子模式測試 - 確保物理繳械有效"""
def test_shadow_mode_config_exists(self):
"""
[Config] SHADOW_MODE_ENABLED 設定存在
預期: 設定存在且預設為 True
"""
assert hasattr(settings, "SHADOW_MODE_ENABLED")
# 影子模式預設應該開啟 (安全優先)
assert settings.SHADOW_MODE_ENABLED is True
@pytest.mark.asyncio
async def test_executor_respects_shadow_mode(self):
"""
[Executor] 影子模式下強制 Dry-Run
預期: 執行操作時僅記錄,不真正執行
"""
from src.services.executor import ActionExecutor, OperationType
executor = ActionExecutor()
# 確保影子模式開啟
with patch.object(settings, "SHADOW_MODE_ENABLED", True):
# 測試 DELETE_POD - 應該被攔截
result = await executor.delete_pod("test-pod", "default")
assert result.success is True
assert "[SHADOW MODE]" in result.message
assert result.k8s_response["shadow_mode"] is True
assert result.k8s_response["dry_run"] is True
# 測試 RESTART_DEPLOYMENT - 應該被攔截
result = await executor.restart_deployment("test-deploy", "default")
assert result.success is True
assert "[SHADOW MODE]" in result.message
assert result.k8s_response["shadow_mode"] is True
# =============================================================================
# Integration Test Summary
# =============================================================================
class TestIntegrationSummary:
"""整合測試摘要 - 確保所有端點可達"""
@pytest.mark.asyncio
async def test_health_endpoints_accessible(self):
"""驗證健康檢查端點可達"""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
# Webhook 健康檢查
response = await client.get("/api/v1/webhooks/health")
assert response.status_code == 200
# Telegram 健康檢查
response = await client.get("/api/v1/telegram/health")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_api_docs_accessible(self):
"""驗證 API 文檔可達"""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
# Docs 位於 /api/v1/docs
response = await client.get("/api/v1/docs")
assert response.status_code == 200
response = await client.get("/api/v1/openapi.json")
assert response.status_code == 200
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])

View File

@@ -0,0 +1,459 @@
"""
Multi-Sig Redis 自動化測試腳本
==============================
Phase 6.1.1: 全自動單元自檢
測試項目:
1. Redis 連線池初始化
2. 簽核單 CRUD 操作
3. 分散式鎖競爭測試
4. TTL 驗證 (7 天)
5. 雙重簽核防禦
統帥鐵律:
- 禁止人工 QA此腳本必須全自動執行
- 輸出必須為 Raw Data (stdout logs)
"""
import asyncio
import sys
import os
from datetime import datetime, timezone
from uuid import uuid4
# 添加專案路徑
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import structlog
# 配置 structlog 輸出
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(0),
)
logger = structlog.get_logger(__name__)
async def test_redis_connection():
"""測試 1: Redis 連線池初始化"""
logger.info("=" * 60)
logger.info("TEST_1_REDIS_CONNECTION", status="starting")
from src.core.redis_client import init_redis_pool, get_redis, close_redis_pool
try:
# 初始化連線池
pool = await init_redis_pool()
logger.info("redis_pool_initialized", pool_type=type(pool).__name__)
# 取得連線
redis_client = get_redis()
# PING 測試
pong = await redis_client.ping()
logger.info("redis_ping", response=pong)
# 寫入測試值
test_key = "test:connection:check"
await redis_client.set(test_key, "awoooi_phase6", ex=60)
value = await redis_client.get(test_key)
logger.info("redis_set_get", key=test_key, value=value)
# 清理測試值
await redis_client.delete(test_key)
logger.info("TEST_1_REDIS_CONNECTION", status="PASSED")
return True
except Exception as e:
logger.error("TEST_1_REDIS_CONNECTION", status="FAILED", error=str(e))
return False
async def test_approval_crud():
"""測試 2: 簽核單 CRUD 操作"""
logger.info("=" * 60)
logger.info("TEST_2_APPROVAL_CRUD", status="starting")
from src.services.multi_sig_redis import get_multi_sig_redis_service
service = get_multi_sig_redis_service()
approval_id = str(uuid4())
try:
# CREATE
state = await service.create_approval(
approval_id=approval_id,
action="DELETE_POD",
description="測試簽核單 - Phase 6.1.1 自動化測試",
risk_level="high",
required_signatures=2,
namespace="awoooi",
resource_name="test-pod-001",
)
logger.info("approval_created",
id=state["id"],
status=state["status"],
required=state["required_signatures"])
# READ
retrieved = await service.get_approval(approval_id)
assert retrieved is not None, "Approval not found after create"
assert retrieved["status"] == "pending", f"Expected pending, got {retrieved['status']}"
logger.info("approval_retrieved",
id=retrieved["id"],
signatures_count=len(retrieved["signatures"]))
# EXISTS CHECK
exists = await service.exists(approval_id)
assert exists, "Approval should exist"
logger.info("approval_exists", exists=exists)
# UPDATE (reject)
rejected = await service.reject_approval(
approval_id=approval_id,
rejector_id="test-ciso",
rejector_name="資安長測試",
reason="Phase 6.1.1 自動化測試拒絕",
)
assert rejected["status"] == "rejected", f"Expected rejected, got {rejected['status']}"
logger.info("approval_rejected",
status=rejected["status"],
rejector=rejected.get("rejector_name"))
logger.info("TEST_2_APPROVAL_CRUD", status="PASSED")
return True
except Exception as e:
logger.error("TEST_2_APPROVAL_CRUD", status="FAILED", error=str(e))
import traceback
traceback.print_exc()
return False
async def test_signature_flow():
"""測試 3: 簽核流程 (含分散式鎖)"""
logger.info("=" * 60)
logger.info("TEST_3_SIGNATURE_FLOW", status="starting")
from src.services.multi_sig_redis import get_multi_sig_redis_service
service = get_multi_sig_redis_service()
approval_id = str(uuid4())
try:
# 建立需要 2 人簽核的單子
await service.create_approval(
approval_id=approval_id,
action="RESTART_SERVICE",
description="測試簽核流程",
risk_level="critical",
required_signatures=2,
namespace="awoooi",
)
logger.info("approval_created_for_signing", id=approval_id, required=2)
# 第一人簽核
state1 = await service.add_signature(
approval_id=approval_id,
signer_id="cto-001",
signer_name="技術長",
comment="同意執行",
source="web",
)
logger.info("signature_1_added",
current=state1["current_signatures"],
required=state1["required_signatures"],
status=state1["status"])
assert state1["status"] == "pending", "Should still be pending with 1/2 signatures"
# 第二人簽核 (應該觸發 approved)
state2 = await service.add_signature(
approval_id=approval_id,
signer_id="ceo-001",
signer_name="執行長",
comment="核准",
source="telegram",
telegram_user_id=123456789,
)
logger.info("signature_2_added",
current=state2["current_signatures"],
required=state2["required_signatures"],
status=state2["status"])
assert state2["status"] == "approved", f"Should be approved, got {state2['status']}"
logger.info("TEST_3_SIGNATURE_FLOW", status="PASSED")
return True
except Exception as e:
logger.error("TEST_3_SIGNATURE_FLOW", status="FAILED", error=str(e))
import traceback
traceback.print_exc()
return False
async def test_duplicate_signature_defense():
"""測試 4: 雙重簽核防禦"""
logger.info("=" * 60)
logger.info("TEST_4_DUPLICATE_SIGNATURE_DEFENSE", status="starting")
from src.services.multi_sig_redis import get_multi_sig_redis_service
service = get_multi_sig_redis_service()
approval_id = str(uuid4())
try:
await service.create_approval(
approval_id=approval_id,
action="SCALE_DEPLOYMENT",
description="雙重簽核防禦測試",
risk_level="medium",
required_signatures=3,
)
# 第一次簽核
await service.add_signature(
approval_id=approval_id,
signer_id="same-user",
signer_name="測試用戶",
)
logger.info("first_signature_success", signer="same-user")
# 嘗試重複簽核 (應該被拒絕)
try:
await service.add_signature(
approval_id=approval_id,
signer_id="same-user",
signer_name="測試用戶",
)
logger.error("duplicate_signature_allowed", status="SECURITY_BREACH")
return False
except RuntimeError as e:
if "Already signed" in str(e):
logger.info("duplicate_signature_blocked", error=str(e))
else:
raise
logger.info("TEST_4_DUPLICATE_SIGNATURE_DEFENSE", status="PASSED")
return True
except Exception as e:
logger.error("TEST_4_DUPLICATE_SIGNATURE_DEFENSE", status="FAILED", error=str(e))
import traceback
traceback.print_exc()
return False
async def test_ttl_verification():
"""測試 5: TTL 驗證 (7 天 = 604800 秒)"""
logger.info("=" * 60)
logger.info("TEST_5_TTL_VERIFICATION", status="starting")
from src.services.multi_sig_redis import get_multi_sig_redis_service, APPROVAL_TTL_SECONDS
from src.core.redis_client import get_redis
service = get_multi_sig_redis_service()
redis_client = get_redis()
approval_id = str(uuid4())
try:
await service.create_approval(
approval_id=approval_id,
action="TTL_TEST",
description="TTL 驗證測試",
risk_level="low",
required_signatures=1,
)
# 檢查 TTL
key = f"approval:{approval_id}"
ttl = await redis_client.ttl(key)
logger.info("ttl_check",
key=key,
ttl_seconds=ttl,
expected_ttl=APPROVAL_TTL_SECONDS,
ttl_days=ttl / 86400 if ttl > 0 else 0)
# TTL 應該接近 604800 秒 (允許 10 秒誤差)
assert ttl > APPROVAL_TTL_SECONDS - 10, f"TTL too low: {ttl}"
assert ttl <= APPROVAL_TTL_SECONDS, f"TTL too high: {ttl}"
logger.info("TEST_5_TTL_VERIFICATION", status="PASSED")
return True
except Exception as e:
logger.error("TEST_5_TTL_VERIFICATION", status="FAILED", error=str(e))
import traceback
traceback.print_exc()
return False
async def test_concurrent_signatures():
"""測試 6: 併發簽核測試 (分散式鎖壓力測試)"""
logger.info("=" * 60)
logger.info("TEST_6_CONCURRENT_SIGNATURES", status="starting")
from src.services.multi_sig_redis import get_multi_sig_redis_service
service = get_multi_sig_redis_service()
approval_id = str(uuid4())
try:
await service.create_approval(
approval_id=approval_id,
action="CONCURRENT_TEST",
description="併發鎖測試",
risk_level="high",
required_signatures=5,
)
# 模擬 5 個不同用戶同時簽核
async def sign(user_num: int):
try:
result = await service.add_signature(
approval_id=approval_id,
signer_id=f"user-{user_num}",
signer_name=f"用戶 {user_num}",
source="concurrent_test",
)
return ("success", user_num, result["current_signatures"])
except Exception as e:
return ("error", user_num, str(e))
# 同時發起 5 個簽核請求
tasks = [sign(i) for i in range(1, 6)]
results = await asyncio.gather(*tasks)
success_count = sum(1 for r in results if r[0] == "success")
error_count = sum(1 for r in results if r[0] == "error")
for status, user_num, detail in results:
logger.info("concurrent_result",
user=user_num,
status=status,
detail=detail)
logger.info("concurrent_summary",
success=success_count,
errors=error_count)
# 驗證最終狀態
final = await service.get_approval(approval_id)
logger.info("final_state",
current_signatures=final["current_signatures"],
status=final["status"])
# 所有 5 個簽核都應成功
assert success_count == 5, f"Expected 5 successes, got {success_count}"
assert final["status"] == "approved", f"Expected approved, got {final['status']}"
logger.info("TEST_6_CONCURRENT_SIGNATURES", status="PASSED")
return True
except Exception as e:
logger.error("TEST_6_CONCURRENT_SIGNATURES", status="FAILED", error=str(e))
import traceback
traceback.print_exc()
return False
async def test_list_pending():
"""測試 7: 列出待簽核單"""
logger.info("=" * 60)
logger.info("TEST_7_LIST_PENDING", status="starting")
from src.services.multi_sig_redis import get_multi_sig_redis_service
service = get_multi_sig_redis_service()
try:
# 建立幾個待簽核單
ids = []
for i in range(3):
approval_id = str(uuid4())
await service.create_approval(
approval_id=approval_id,
action=f"LIST_TEST_{i}",
description=f"列表測試 {i}",
risk_level="low",
required_signatures=1,
)
ids.append(approval_id)
# 列出待簽核單
pending = await service.list_pending(limit=100)
logger.info("pending_list_count", count=len(pending))
# 應該至少包含我們建立的 3 個
found = sum(1 for p in pending if p["id"] in ids)
logger.info("found_our_approvals", found=found, expected=3)
assert found >= 3, f"Expected at least 3, found {found}"
logger.info("TEST_7_LIST_PENDING", status="PASSED")
return True
except Exception as e:
logger.error("TEST_7_LIST_PENDING", status="FAILED", error=str(e))
import traceback
traceback.print_exc()
return False
async def main():
"""主測試入口"""
logger.info("=" * 60)
logger.info("PHASE_6_1_1_REDIS_MULTISIG_TEST", status="STARTING")
logger.info("timestamp", time=datetime.now(timezone.utc).isoformat())
logger.info("=" * 60)
results = {}
# 測試 1: Redis 連線
results["redis_connection"] = await test_redis_connection()
if not results["redis_connection"]:
logger.error("CRITICAL", message="Redis 連線失敗,終止測試")
return
# 測試 2-7
results["approval_crud"] = await test_approval_crud()
results["signature_flow"] = await test_signature_flow()
results["duplicate_defense"] = await test_duplicate_signature_defense()
results["ttl_verification"] = await test_ttl_verification()
results["concurrent_signatures"] = await test_concurrent_signatures()
results["list_pending"] = await test_list_pending()
# 關閉連線池
from src.core.redis_client import close_redis_pool
await close_redis_pool()
# 總結報告
logger.info("=" * 60)
logger.info("TEST_SUMMARY")
passed = sum(1 for v in results.values() if v)
failed = sum(1 for v in results.values() if not v)
for test_name, passed_flag in results.items():
status = "✅ PASSED" if passed_flag else "❌ FAILED"
logger.info(f" {test_name}: {status}")
logger.info("-" * 60)
logger.info(f"TOTAL: {passed} passed, {failed} failed")
logger.info("=" * 60)
if failed > 0:
sys.exit(1)
else:
logger.info("ALL_TESTS_PASSED", message="Phase 6.1.1 Redis Multi-Sig 驗證完成")
sys.exit(0)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
Webhook → Telegram 全鏈路整合測試
==================================
Phase 5: 修復一級整合事故
測試涵蓋:
1. 新告警 → 自動推送 Telegram
2. 收斂告警 → 也必須推送 Telegram (含聚合次數)
3. 斷言 TelegramGateway.send_approval_card 被正確參數呼叫
4. 驗證 SOUL.md 格式資料完整性
使用方式:
cd apps/api && pytest tests/test_webhook_telegram_integration.py -v
"""
import json
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from uuid import UUID
import httpx
from httpx import ASGITransport, AsyncClient
from src.main import app
from src.core.config import settings
# =============================================================================
# Test Fixtures
# =============================================================================
@pytest.fixture
def valid_alert_payload():
"""有效的告警 Payload"""
return {
"alert_type": "k8s_pod_crash",
"severity": "critical",
"source": "prometheus",
"target_resource": "harbor-core-7d4b8c9f5-xk2m3",
"namespace": "harbor",
"message": "Pod terminated due to OOMKilled",
"metrics": {"memory_percent": 99.8, "restart_count": 5},
"labels": {"app": "harbor-core", "reason": "OOMKilled"},
}
@pytest.fixture
def mock_approval_service():
"""Mock ApprovalService"""
mock_service = AsyncMock()
# Mock find_by_fingerprint 回傳 None (新告警)
mock_service.find_by_fingerprint.return_value = None
# Mock create_approval_with_fingerprint 回傳模擬的 Approval
mock_approval = MagicMock()
mock_approval.id = UUID("12345678-1234-5678-1234-567812345678")
mock_approval.status.value = "pending"
mock_approval.risk_level.value = "critical"
mock_approval.action = "kubectl delete pod harbor-core-7d4b8c9f5-xk2m3 -n harbor"
mock_approval.hit_count = 1
mock_service.create_approval_with_fingerprint.return_value = mock_approval
return mock_service
@pytest.fixture
def mock_converged_approval_service():
"""Mock ApprovalService - 收斂情境"""
mock_service = AsyncMock()
# Mock find_by_fingerprint 回傳現有的 Approval (收斂)
existing_approval = MagicMock()
existing_approval.id = UUID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
existing_approval.hit_count = 5
existing_approval.risk_level.value = "critical"
existing_approval.action = "kubectl delete pod harbor-core -n harbor"
mock_service.find_by_fingerprint.return_value = existing_approval
# Mock increment_hit_count
updated_approval = MagicMock()
updated_approval.id = existing_approval.id
updated_approval.hit_count = 6 # 聚合後 +1
updated_approval.risk_level.value = "critical"
updated_approval.action = "kubectl delete pod harbor-core -n harbor"
mock_service.increment_hit_count.return_value = updated_approval
return mock_service
# =============================================================================
# Test: 新告警 → Telegram 推送
# =============================================================================
class TestNewAlertTelegramPush:
"""新告警必須推送到 Telegram"""
@pytest.mark.asyncio
async def test_new_alert_triggers_telegram_push(
self,
valid_alert_payload: dict,
mock_approval_service,
):
"""
[核心斷言] 新告警建立 ApprovalRecord 後,
必須呼叫 TelegramGateway.send_approval_card()
"""
mock_telegram_gateway = AsyncMock()
mock_telegram_gateway.send_approval_card = AsyncMock(return_value={"ok": True})
with patch("src.api.v1.webhooks.get_approval_service", return_value=mock_approval_service):
with patch("src.api.v1.webhooks.get_openclaw") as mock_openclaw:
# Mock OpenClaw 回傳 None (使用靜態分析)
mock_openclaw.return_value.analyze_alert = AsyncMock(
return_value=(None, "mock", "")
)
with patch("src.api.v1.webhooks.get_telegram_gateway", return_value=mock_telegram_gateway):
with patch.object(settings, "OPENCLAW_TG_BOT_TOKEN", "test-token"):
with patch.object(settings, "WEBHOOK_HMAC_SECRET", ""):
with patch.object(settings, "ENVIRONMENT", "dev"):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
response = await client.post(
"/api/v1/webhooks/alerts",
json=valid_alert_payload,
)
# 驗證 HTTP 回應
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["approval_created"] is True
# =====================================================================
# [核心斷言] TelegramGateway.send_approval_card 必須被呼叫
# =====================================================================
# 因為使用 BackgroundTasks需要等待一下
import asyncio
await asyncio.sleep(0.1)
mock_telegram_gateway.send_approval_card.assert_called_once()
# 驗證呼叫參數符合 SOUL.md 格式
call_kwargs = mock_telegram_gateway.send_approval_card.call_args.kwargs
assert "approval_id" in call_kwargs
assert call_kwargs["approval_id"] == "12345678-1234-5678-1234-567812345678"
assert "risk_level" in call_kwargs
assert "resource_name" in call_kwargs
assert call_kwargs["resource_name"] == "harbor-core-7d4b8c9f5-xk2m3"
assert "root_cause" in call_kwargs
assert "suggested_action" in call_kwargs
# =============================================================================
# Test: 收斂告警 → Telegram 推送 (含聚合次數)
# =============================================================================
class TestConvergedAlertTelegramPush:
"""收斂告警也必須推送到 Telegram"""
@pytest.mark.asyncio
async def test_converged_alert_also_triggers_telegram_push(
self,
valid_alert_payload: dict,
mock_converged_approval_service,
):
"""
[核心斷言] 收斂告警 (相同指紋) 聚合後,
也必須推送 Telegram並包含聚合次數
"""
mock_telegram_gateway = AsyncMock()
mock_telegram_gateway.send_approval_card = AsyncMock(return_value={"ok": True})
with patch("src.api.v1.webhooks.get_approval_service", return_value=mock_converged_approval_service):
with patch("src.api.v1.webhooks.get_telegram_gateway", return_value=mock_telegram_gateway):
with patch.object(settings, "OPENCLAW_TG_BOT_TOKEN", "test-token"):
with patch.object(settings, "WEBHOOK_HMAC_SECRET", ""):
with patch.object(settings, "ENVIRONMENT", "dev"):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
response = await client.post(
"/api/v1/webhooks/alerts",
json=valid_alert_payload,
)
# 驗證 HTTP 回應
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["converged"] is True
assert data["hit_count"] == 6 # 5 + 1
# =====================================================================
# [核心斷言] 收斂告警也必須呼叫 TelegramGateway
# =====================================================================
import asyncio
await asyncio.sleep(0.1)
mock_telegram_gateway.send_approval_card.assert_called_once()
# 驗證聚合次數被嵌入 root_cause 字串
call_kwargs = mock_telegram_gateway.send_approval_card.call_args.kwargs
assert "[x6]" in call_kwargs["root_cause"], \
f"hit_count should be embedded in root_cause, got: {call_kwargs['root_cause']}"
# =============================================================================
# Test: Telegram 推送失敗不影響主流程
# =============================================================================
class TestTelegramPushFailureIsolation:
"""Telegram 推送失敗不應影響 Webhook 回應"""
@pytest.mark.asyncio
async def test_telegram_failure_does_not_break_webhook(
self,
valid_alert_payload: dict,
mock_approval_service,
):
"""
[防禦性] Telegram API 錯誤時Webhook 仍應回傳 200
"""
mock_telegram_gateway = AsyncMock()
# 模擬 Telegram API 失敗
mock_telegram_gateway.send_approval_card = AsyncMock(
side_effect=Exception("Telegram API timeout")
)
with patch("src.api.v1.webhooks.get_approval_service", return_value=mock_approval_service):
with patch("src.api.v1.webhooks.get_openclaw") as mock_openclaw:
mock_openclaw.return_value.analyze_alert = AsyncMock(
return_value=(None, "mock", "")
)
with patch("src.api.v1.webhooks.get_telegram_gateway", return_value=mock_telegram_gateway):
with patch.object(settings, "OPENCLAW_TG_BOT_TOKEN", "test-token"):
with patch.object(settings, "WEBHOOK_HMAC_SECRET", ""):
with patch.object(settings, "ENVIRONMENT", "dev"):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
response = await client.post(
"/api/v1/webhooks/alerts",
json=valid_alert_payload,
)
# =====================================================================
# [核心斷言] 即使 Telegram 失敗Webhook 仍回傳 200
# =====================================================================
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["approval_created"] is True
# =============================================================================
# Test: SOUL.md 格式驗證
# =============================================================================
class TestSOULMDFormatCompliance:
"""驗證推送資料符合 SOUL.md 格式規範"""
@pytest.mark.asyncio
async def test_telegram_payload_respects_soul_md_limits(
self,
mock_approval_service,
):
"""
[SOUL.md] 驗證字數限制:
- resource_name: 50 字元
- root_cause: 100 字元
- suggested_action: 50 字元
"""
# 超長資料
long_alert_payload = {
"alert_type": "k8s_pod_crash",
"severity": "critical",
"source": "prometheus",
"target_resource": "x" * 100, # 超過 50 字元
"namespace": "default",
"message": "y" * 200, # 超過 100 字元
"metrics": {},
}
mock_telegram_gateway = AsyncMock()
mock_telegram_gateway.send_approval_card = AsyncMock(return_value={"ok": True})
with patch("src.api.v1.webhooks.get_approval_service", return_value=mock_approval_service):
with patch("src.api.v1.webhooks.get_openclaw") as mock_openclaw:
mock_openclaw.return_value.analyze_alert = AsyncMock(
return_value=(None, "mock", "")
)
with patch("src.api.v1.webhooks.get_telegram_gateway", return_value=mock_telegram_gateway):
with patch.object(settings, "OPENCLAW_TG_BOT_TOKEN", "test-token"):
with patch.object(settings, "WEBHOOK_HMAC_SECRET", ""):
with patch.object(settings, "ENVIRONMENT", "dev"):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
response = await client.post(
"/api/v1/webhooks/alerts",
json=long_alert_payload,
)
assert response.status_code == 200
import asyncio
await asyncio.sleep(0.1)
# 驗證呼叫參數已被截斷
call_kwargs = mock_telegram_gateway.send_approval_card.call_args.kwargs
assert len(call_kwargs["resource_name"]) <= 50
assert len(call_kwargs["root_cause"]) <= 100
assert len(call_kwargs["suggested_action"]) <= 50
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])