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:
495
apps/api/tests/e2e_network_test.py
Normal file
495
apps/api/tests/e2e_network_test.py
Normal 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"])
|
||||
459
apps/api/tests/test_redis_multisig.py
Normal file
459
apps/api/tests/test_redis_multisig.py
Normal 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())
|
||||
325
apps/api/tests/test_webhook_telegram_integration.py
Normal file
325
apps/api/tests/test_webhook_telegram_integration.py
Normal 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"])
|
||||
Reference in New Issue
Block a user