Files
awoooi/apps/api/tests/test_callback_dispatcher_llm.py
Your Name ea23972f7a
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 9m10s
feat(dispatch): B2 LLM 動態 MCP 派發安全閘 + telegram_gateway LLM 按鈕流程
ADR-082 §B2:dispatch_llm_action() 風險閘控 + allowlist + 模板渲染
23 tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:22:31 +08:00

290 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
B2: dispatch_llm_action 單元測試
==================================
2026-04-27 Claude Sonnet 4.6: B2 — LLM 動態 MCP 規格派發閘控
覆蓋:
1. critical risk 被拒
2. high risk 無 confirmed → 拒(附 nonce
3. high risk + confirmed=True → 允許
4. low / medium → 直接允許
5. mcp_tool 不在 registry → 拒
6. params 模板渲染({labels.instance} / {context.incident_id}
7. 渲染失敗時不 crash
🔴 遵循「禁止 Mock 測試鐵律」: 不 mock registry使用真實 YAML 或
stub registry patch僅 _load_llm_tool_registry 以 monkeypatch 替換,
原因solver_agent 有 prometheus metric 依賴,在 pure unit test 環境無法
正常 importstub 清單來自真實 YAML 中已知 action 名稱)。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
import pytest
from src.services.callback_dispatcher import (
dispatch_llm_action,
_render_llm_params,
)
# =============================================================================
# Fixture: stub RecommendedAction避免 import solver_agent metrics 依賴)
# =============================================================================
@dataclass
class _StubAction:
"""最小化 RecommendedAction stub欄位與 protocol.py 完全對齊。"""
name: str
label: str
emoji: str
mcp_provider: str
mcp_tool: str
params: dict[str, str]
risk: Literal["low", "medium", "high", "critical"]
reasoning: str = ""
# =============================================================================
# Fixture: stub registrymonkeypatch _load_llm_tool_registry
# =============================================================================
_STUB_REGISTRY: dict[str, dict] = {
"check_pod_logs": {
"provider": "k8s",
"tool": "check_pod_logs",
"risk": "low",
"label": "查 Pod 日誌",
"emoji": "📋",
},
"restart_deployment": {
"provider": "k8s",
"tool": "restart_deployment",
"risk": "medium",
"label": "重啟 Deployment",
"emoji": "🔄",
},
"drain_node": {
"provider": "k8s",
"tool": "drain_node",
"risk": "high",
"label": "排空節點",
"emoji": "⚠️",
},
}
@pytest.fixture(autouse=True)
def _patch_registry(monkeypatch):
"""將 _load_llm_tool_registry 替換為 stub隔離 prometheus metric 依賴。"""
import src.services.callback_dispatcher as mod
monkeypatch.setattr(mod, "_load_llm_tool_registry", lambda: _STUB_REGISTRY)
# =============================================================================
# 測試 1: critical risk 直接拒絕
# =============================================================================
def test_critical_risk_rejected():
action = _StubAction(
name="nuke_cluster",
label="核爆",
emoji="💣",
mcp_provider="k8s",
mcp_tool="delete_all_pods",
params={},
risk="critical",
)
result = dispatch_llm_action(action, {"incident_id": "INC-001"})
assert result["ok"] is False
assert result["reason"] == "critical_action_rejected"
# =============================================================================
# 測試 2: high risk 無 confirmed → 拒,附 nonce
# =============================================================================
def test_high_risk_no_confirmed_rejected_with_nonce():
action = _StubAction(
name="drain_node",
label="排空節點",
emoji="⚠️",
mcp_provider="k8s",
mcp_tool="drain_node",
params={},
risk="high",
)
result = dispatch_llm_action(action, {"incident_id": "INC-002"})
assert result["ok"] is False
assert result["reason"] == "high_risk_requires_confirmation"
assert "nonce" in result
assert isinstance(result["nonce"], str)
assert len(result["nonce"]) > 0
# =============================================================================
# 測試 3: high risk + confirmed=True → 允許
# =============================================================================
def test_high_risk_with_confirmed_allowed():
action = _StubAction(
name="drain_node",
label="排空節點",
emoji="⚠️",
mcp_provider="k8s",
mcp_tool="drain_node",
params={"node": "worker-1"},
risk="high",
)
result = dispatch_llm_action(action, {"incident_id": "INC-003", "confirmed": True})
assert result["ok"] is True
assert result["mcp_tool"] == "drain_node"
assert result["button_source"] == "llm"
# high risk 允許後也應有 nonce
assert result["nonce"] is not None
# =============================================================================
# 測試 4a: low risk 直接允許,無 nonce
# =============================================================================
def test_low_risk_allowed_no_nonce():
action = _StubAction(
name="check_pod_logs",
label="查 Pod 日誌",
emoji="📋",
mcp_provider="k8s",
mcp_tool="check_pod_logs",
params={},
risk="low",
)
result = dispatch_llm_action(action, {"incident_id": "INC-004"})
assert result["ok"] is True
assert result["nonce"] is None
assert result["button_source"] == "llm"
# =============================================================================
# 測試 4b: medium risk 直接允許,附 nonce
# =============================================================================
def test_medium_risk_allowed_with_nonce():
action = _StubAction(
name="restart_deployment",
label="重啟 Deployment",
emoji="🔄",
mcp_provider="k8s",
mcp_tool="restart_deployment",
params={},
risk="medium",
)
result = dispatch_llm_action(action, {"incident_id": "INC-005"})
assert result["ok"] is True
assert result["nonce"] is not None
assert result["risk"] == "medium"
# =============================================================================
# 測試 5: mcp_tool 不在 registry → 拒
# =============================================================================
def test_tool_not_in_registry_rejected():
action = _StubAction(
name="mystery_action",
label="神秘動作",
emoji="",
mcp_provider="k8s",
mcp_tool="non_existent_tool",
params={},
risk="low",
)
result = dispatch_llm_action(action, {"incident_id": "INC-006"})
assert result["ok"] is False
assert result["reason"] == "tool_not_in_registry"
# =============================================================================
# 測試 6: params 模板渲染
# =============================================================================
def test_params_template_rendering():
"""{labels.instance}{context.incident_id} 都應正確渲染。"""
action = _StubAction(
name="check_pod_logs",
label="查 Pod 日誌",
emoji="📋",
mcp_provider="k8s",
mcp_tool="check_pod_logs",
params={
"host": "{labels.instance}",
"namespace": "{labels.namespace}",
"incident": "{context.incident_id}",
"raw_id": "{incident_id}",
},
risk="low",
)
context = {
"incident_id": "INC-007",
"labels": {
"instance": "192.168.0.110",
"namespace": "production",
},
}
result = dispatch_llm_action(action, context)
assert result["ok"] is True
assert result["params"]["host"] == "192.168.0.110"
assert result["params"]["namespace"] == "production"
assert result["params"]["incident"] == "INC-007"
assert result["params"]["raw_id"] == "INC-007"
# =============================================================================
# 測試 7: 渲染失敗時不 crash保留原始字串
# =============================================================================
def test_params_render_failure_keeps_original():
"""找不到的 key → 保留 {xxx} 原始字串,不 crash。"""
action = _StubAction(
name="check_pod_logs",
label="查 Pod 日誌",
emoji="📋",
mcp_provider="k8s",
mcp_tool="check_pod_logs",
params={
"host": "{labels.nonexistent_key}",
"static": "no_template",
},
risk="low",
)
result = dispatch_llm_action(action, {"incident_id": "INC-008", "labels": {}})
assert result["ok"] is True
# 找不到的 key 保留原始模板字串
assert result["params"]["host"] == "{labels.nonexistent_key}"
# 靜態值不變
assert result["params"]["static"] == "no_template"
# =============================================================================
# 測試 8: _render_llm_params 單元測試(直接測渲染函數)
# =============================================================================
def test_render_llm_params_direct():
params = {
"a": "{labels.zone}",
"b": "{context.user_id}",
"c": "literal",
"d": "{labels.missing}",
}
context = {
"labels": {"zone": "ap-east-1"},
"user_id": "u42",
}
rendered = _render_llm_params(params, context)
assert rendered["a"] == "ap-east-1"
assert rendered["b"] == "u42"
assert rendered["c"] == "literal"
assert rendered["d"] == "{labels.missing}" # 找不到 → 原始字串