All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 9m10s
ADR-082 §B2:dispatch_llm_action() 風險閘控 + allowlist + 模板渲染 23 tests pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
290 lines
9.4 KiB
Python
290 lines
9.4 KiB
Python
"""
|
||
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 環境無法
|
||
正常 import;stub 清單來自真實 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 registry(monkeypatch _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}" # 找不到 → 原始字串
|