""" 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}" # 找不到 → 原始字串