Files
awoooi/apps/api/tests/test_pre_decision_investigator.py
Your Name 87378b452d
All checks were successful
CD Pipeline / tests (push) Successful in 1m29s
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / build-and-deploy (push) Successful in 4m31s
CD Pipeline / post-deploy-checks (push) Successful in 1m40s
fix(api): normalize ssh mcp evidence inputs
2026-06-01 01:11:26 +08:00

481 lines
19 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.
"""
PreDecisionInvestigator 測試
============================
ADR-081: Phase 1 決策前情報調查員
測試項目:
- 工具並行蒐集(多維度)
- 工具部分失敗不阻塞Graceful Degradation
- 工具逾時被丟棄
- EvidenceSnapshot 正確填入感官維度
- evidence_summary 組裝 + Token Budget 截斷
- fingerprint 計算一致性
- _fill_snapshot_dimension 正確路由
注意:不依賴真實 Redis / DB — 純邏輯測試
2026-04-15 Claude Sonnet 4.6 + ogt: Phase 1 初始建立
"""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
from src.plugins.mcp.registry import AuditedMCPToolProvider
from src.services import pre_decision_investigator as pdi_module
from src.services.evidence_snapshot import EvidenceSnapshot
from src.services.mcp_tool_registry import (
RegisteredTool,
SensorDimension,
)
from src.services.pre_decision_investigator import (
PreDecisionInvestigator,
_compute_fingerprint,
_fill_snapshot_dimension,
_build_tool_params,
)
# ─────────────────────────────────────────────────────────────────────────────
# Stubs
# ─────────────────────────────────────────────────────────────────────────────
def _make_tool(name: str) -> MCPTool:
return MCPTool(name=name, description="", input_schema={}, server_name="test")
class _SuccessProvider(MCPToolProvider):
"""永遠成功,回傳固定 output"""
def __init__(self, output: dict | str | None = None) -> None:
self._output = output if output is not None else {"status": "Running"}
@property
def name(self) -> str:
return "success_provider"
async def list_tools(self) -> list[MCPTool]:
return []
async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult:
return MCPToolResult(success=True, execution_id="ok", output=self._output)
class _FailProvider(MCPToolProvider):
"""永遠失敗"""
@property
def name(self) -> str:
return "fail_provider"
async def list_tools(self) -> list[MCPTool]:
return []
async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult:
return MCPToolResult(success=False, execution_id="fail", error="connection refused")
class _TimeoutProvider(MCPToolProvider):
"""永遠逾時"""
@property
def name(self) -> str:
return "timeout_provider"
async def list_tools(self) -> list[MCPTool]:
return []
async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult:
await asyncio.sleep(9999)
return MCPToolResult(success=True, execution_id="never", output={})
class _CaptureProvider(_SuccessProvider):
"""成功 provider額外保存收到的參數。"""
def __init__(self) -> None:
super().__init__({"status": "Running"})
self.seen_parameters: dict | None = None
async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult:
self.seen_parameters = dict(parameters)
return await super().execute(tool_name, parameters)
class _DbContext:
async def __aenter__(self) -> object:
return object()
async def __aexit__(self, *_args: object) -> None:
return None
def _stub_incident(
alertname: str = "KubePodCrashLooping",
namespace: str = "awoooi-prod",
pod: str = "api-xyz",
severity: str = "critical",
) -> object:
"""返回最小 Incident stub僅需 .signals[0].labels"""
class _Signal:
labels = {
"alertname": alertname,
"namespace": namespace,
"pod": pod,
"severity": severity,
}
class _Incident:
incident_id = f"INC-{alertname[:4]}"
signals = [_Signal()]
return _Incident()
def _stub_host_incident() -> object:
class _Signal:
alert_name = "HostErrorLogFlood"
labels = {
"alertname": "HostErrorLogFlood",
"error_count": "30",
}
class _Incident:
incident_id = "INC-HOST"
signals = [_Signal()]
affected_services = ["ollama"]
return _Incident()
def _reg(tool_name: str, provider: MCPToolProvider, dim: SensorDimension) -> RegisteredTool:
return RegisteredTool(
tool=_make_tool(tool_name),
provider=provider,
dimensions=[dim],
priority=5,
)
def test_build_tool_params_uses_host_alias_and_service_from_affected_service() -> None:
params = _build_tool_params(_stub_host_incident())
assert params["host"] == "192.168.0.188"
assert params["target"] == "ollama"
assert params["service_name"] == "ollama"
assert params["container_name"] == "ollama"
assert params["filter_name"] == "ollama"
assert params["severity"] == "ERROR,FATAL,CRITICAL"
assert params["search_text"] == "HostErrorLogFlood"
assert params["time_window_minutes"] == 30
def test_build_tool_params_maps_wooo_alias_to_allowed_ssh_host() -> None:
class _Signal:
alert_name = "HostHighCpuLoad"
labels = {
"alertname": "HostHighCpuLoad",
"instance": "wooo:9100",
}
class _Incident:
incident_id = "INC-WOOO"
signals = [_Signal()]
affected_services = ["gitea"]
params = _build_tool_params(_Incident())
assert params["host"] == "192.168.0.110"
assert params["filter_name"] == "gitea"
# ─────────────────────────────────────────────────────────────────────────────
# _compute_fingerprint
# ─────────────────────────────────────────────────────────────────────────────
class TestComputeFingerprint:
def test_same_labels_same_fingerprint(self):
i1 = _stub_incident("Kube", "prod", "pod1", "critical")
i2 = _stub_incident("Kube", "prod", "pod1", "critical")
assert _compute_fingerprint(i1) == _compute_fingerprint(i2)
def test_different_alertname_different_fingerprint(self):
i1 = _stub_incident("Kube", "prod", "pod1", "critical")
i2 = _stub_incident("Host", "prod", "pod1", "critical")
assert _compute_fingerprint(i1) != _compute_fingerprint(i2)
def test_fingerprint_length_16(self):
i = _stub_incident()
fp = _compute_fingerprint(i)
assert len(fp) == 16
def test_fingerprint_hex_chars_only(self):
i = _stub_incident()
fp = _compute_fingerprint(i)
assert all(c in "0123456789abcdef" for c in fp)
# ─────────────────────────────────────────────────────────────────────────────
# _build_tool_params
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildToolParams:
def test_namespace_extracted(self):
p = _build_tool_params(_stub_incident(namespace="mynamespace"))
assert p["namespace"] == "mynamespace"
def test_pod_name_extracted(self):
p = _build_tool_params(_stub_incident(pod="mypod"))
assert p["pod_name"] == "mypod"
def test_alertname_extracted(self):
p = _build_tool_params(_stub_incident(alertname="MyAlert"))
assert p["alertname"] == "MyAlert"
def test_default_namespace_fallback(self):
class _Signal:
labels = {}
class _Inc:
incident_id = "x"
signals = [_Signal()]
p = _build_tool_params(_Inc())
assert p["namespace"] == "awoooi-prod"
# ─────────────────────────────────────────────────────────────────────────────
# _fill_snapshot_dimension
# ─────────────────────────────────────────────────────────────────────────────
class TestFillSnapshotDimension:
def _reg(self, dim: SensorDimension) -> RegisteredTool:
return RegisteredTool(
tool=_make_tool("t"),
provider=_SuccessProvider(),
dimensions=[dim],
priority=5,
)
def test_d1_dict_fills_k8s_state(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D1_K8S_STATE), {"status": "Running"})
assert snap.k8s_state == {"status": "Running"}
def test_d1_str_wraps_in_raw(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D1_K8S_STATE), "raw k8s output")
assert snap.k8s_state == {"raw": "raw k8s output"}
def test_d2_fills_recent_logs(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D2_LOGS), "log line 1\nlog line 2")
assert "log line 1" in snap.recent_logs
def test_d2_dict_serialized_to_string(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D2_LOGS), {"msg": "hello"})
assert "hello" in snap.recent_logs
def test_d3_fills_metrics(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D3_METRICS), {"cpu": 95.2})
assert snap.metrics_snapshot == {"cpu": 95.2}
def test_d4_list_fills_deployments(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D4_CHANGES), [{"rev": "abc"}])
assert snap.recent_deployments == [{"rev": "abc"}]
def test_d4_dict_wrapped_in_list(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D4_CHANGES), {"rev": "abc"})
assert snap.recent_deployments == [{"rev": "abc"}]
def test_d5_fills_business_metrics(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D5_BUSINESS), {"sli": 0.99})
assert snap.business_metrics == {"sli": 0.99}
def test_d6_truncated_at_2000(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D6_HISTORY), "X" * 5000)
assert len(snap.historical_context) <= 2100 # 2000 + possible truncation note
def test_d7_fills_peer_health(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D7_PEERS), {"replica_0": "ok"})
assert snap.peer_health == {"replica_0": "ok"}
def test_d8_fills_topology(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D8_TOPOLOGY), {"upstream": "db"})
assert snap.dependency_topology == {"upstream": "db"}
def test_none_raw_is_noop(self):
snap = EvidenceSnapshot(incident_id="x")
_fill_snapshot_dimension(snap, self._reg(SensorDimension.D1_K8S_STATE), None)
assert snap.k8s_state is None # 未被修改
# ─────────────────────────────────────────────────────────────────────────────
# PreDecisionInvestigator._collect_one
# ─────────────────────────────────────────────────────────────────────────────
class TestCollectOne:
"""單工具蒐集行為(不需要 DB / Redis"""
@pytest.mark.asyncio
async def test_success_fills_snapshot(self):
investigator = PreDecisionInvestigator()
snap = EvidenceSnapshot(incident_id="x")
reg = _reg("kubectl_describe", _SuccessProvider({"status": "Running"}), SensorDimension.D1_K8S_STATE)
await investigator._collect_one(snap, reg, {"namespace": "prod"})
assert snap.mcp_health["kubectl_describe"] is True
assert snap.sensors_succeeded == 1
assert snap.k8s_state is not None
@pytest.mark.asyncio
async def test_collect_one_injects_mcp_audit_context(self):
investigator = PreDecisionInvestigator()
snap = EvidenceSnapshot(incident_id="INC-AUDIT")
provider = _CaptureProvider()
reg = _reg("kubectl_describe", provider, SensorDimension.D1_K8S_STATE)
await investigator._collect_one(snap, reg, {"namespace": "prod"})
assert provider.seen_parameters is not None
audit_context = provider.seen_parameters["_mcp_audit"]
assert audit_context["incident_id"] == "INC-AUDIT"
assert audit_context["session_id"] == "incident:INC-AUDIT:pre_decision"
assert audit_context["flywheel_node"] == "sense"
assert audit_context["agent_role"] == "pre_decision_investigator"
@pytest.mark.asyncio
async def test_collect_one_routes_audited_provider_through_gateway(
self,
monkeypatch: pytest.MonkeyPatch,
):
investigator = PreDecisionInvestigator()
snap = EvidenceSnapshot(incident_id="INC-GATEWAY")
provider = _CaptureProvider()
reg = _reg(
"kubectl_describe",
AuditedMCPToolProvider(provider),
SensorDimension.D1_K8S_STATE,
)
calls: list[dict[str, Any]] = []
class FakeGateway:
def __init__(self, db: object) -> None:
self.db = db
async def call(self, ctx, parameters: dict[str, Any]) -> MCPToolResult:
calls.append({"ctx": ctx, "parameters": parameters, "db": self.db})
return MCPToolResult(success=True, execution_id="gw", output={"status": "Running"})
monkeypatch.setattr(pdi_module, "get_db_context", lambda _project_id: _DbContext())
monkeypatch.setattr(pdi_module, "McpGateway", FakeGateway)
await investigator._collect_one(snap, reg, {"namespace": "prod"})
assert snap.mcp_health["kubectl_describe"] is True
assert snap.k8s_state is not None
assert provider.seen_parameters is None
assert calls
ctx = calls[0]["ctx"]
assert ctx.project_id == "awoooi"
assert ctx.agent_id == "pre_decision_investigator"
assert ctx.tool_name == "kubectl_describe"
assert ctx.trace_id == "INC-GATEWAY"
assert ctx.required_scope == "read"
assert calls[0]["parameters"]["_mcp_audit"]["incident_id"] == "INC-GATEWAY"
@pytest.mark.asyncio
async def test_failed_tool_marks_health_false(self):
investigator = PreDecisionInvestigator()
snap = EvidenceSnapshot(incident_id="x")
reg = _reg("kubectl_logs", _FailProvider(), SensorDimension.D2_LOGS)
await investigator._collect_one(snap, reg, {})
assert snap.mcp_health["kubectl_logs"] is False
assert snap.sensors_succeeded == 0
@pytest.mark.asyncio
async def test_timeout_marks_health_false(self):
"""工具逾時必須被丟棄,不阻塞主路徑"""
investigator = PreDecisionInvestigator()
snap = EvidenceSnapshot(incident_id="x")
reg = _reg("slow_tool", _TimeoutProvider(), SensorDimension.D3_METRICS)
# _collect_one 本身有 MCP_TOOL_TIMEOUT_SEC=5 的 wait_for 保護
# 但在測試中我們直接驗證它不會 raise只是設 health=False
# 用一個足夠短的超時替代patch不做因為這是純邏輯驗證
with pytest.raises(Exception):
# TimeoutProvider 會永久阻塞,直接觸發 asyncio.TimeoutError
await asyncio.wait_for(
investigator._collect_one(snap, reg, {}),
timeout=0.1,
)
# 超時後 health 預設 False在 _collect_one 開頭設定)
assert snap.mcp_health.get("slow_tool") is False
# ─────────────────────────────────────────────────────────────────────────────
# PreDecisionInvestigator._collect_all
# ─────────────────────────────────────────────────────────────────────────────
class TestCollectAll:
"""多工具並行蒐集 — Graceful Degradation"""
@pytest.mark.asyncio
async def test_partial_failure_does_not_block(self):
"""失敗工具不阻塞成功工具"""
investigator = PreDecisionInvestigator()
snap = EvidenceSnapshot(incident_id="x")
snap.sensors_attempted = 2
tools = [
_reg("kubectl_describe", _SuccessProvider({"status": "Running"}), SensorDimension.D1_K8S_STATE),
_reg("kubectl_logs", _FailProvider(), SensorDimension.D2_LOGS),
]
incident = _stub_incident()
await investigator._collect_all(snap, tools, incident)
assert snap.mcp_health["kubectl_describe"] is True
assert snap.mcp_health["kubectl_logs"] is False
assert snap.sensors_succeeded == 1
assert snap.k8s_state is not None
@pytest.mark.asyncio
async def test_all_success_fills_multiple_dimensions(self):
investigator = PreDecisionInvestigator()
snap = EvidenceSnapshot(incident_id="x")
tools = [
_reg("kubectl_describe", _SuccessProvider({"status": "Running"}), SensorDimension.D1_K8S_STATE),
_reg("prometheus_query", _SuccessProvider({"cpu": 95.0}), SensorDimension.D3_METRICS),
]
incident = _stub_incident()
await investigator._collect_all(snap, tools, incident)
assert snap.k8s_state is not None
assert snap.metrics_snapshot is not None
assert snap.sensors_succeeded == 2
@pytest.mark.asyncio
async def test_empty_tools_produces_empty_snapshot(self):
investigator = PreDecisionInvestigator()
snap = EvidenceSnapshot(incident_id="x")
await investigator._collect_all(snap, [], _stub_incident())
assert snap.sensors_succeeded == 0
assert snap.mcp_health == {}