Files
awoooi/apps/api/tests/test_auto_repair_service.py
Your Name cc547736ab feat(wave6-8): P2.1 fusion + P2.2 governance + P2.4 consensus + Wave 7/8 BLOCKER 修復
承接 Wave 6/7/8 多 engineer 在 agent 限額前完成的代碼,補 commit 解 production
HEAD 隱性 import error(decision_fusion 已被 decision_manager 引用但檔案 untracked)。

新增(後端核心):
- decision_fusion.py (562 行) — P2.1 方法 III(OpenClaw + Hermes + Elephant 三 LLM 融合)
- aiops_timeline.py + aiops_timeline_service.py — critic B4 修復
  /api/v1/aiops/timeline endpoint,DB 存取抽到 service 層遵守 leWOOOgo 積木化
- migrations/p2_decision_fusion_columns.sql + rollback — approval_records fusion 欄位

修改(後端整合):
- decision_manager.py — fusion 三斷鏈修補(critic B1+B2+B3):
  · B1: 寫 _evidence_snapshot_ref 到 token.proposal_data
  · B2: fusion 前計算 complexity_score 並寫 token
  · B3: fusion composite 寫 token.proposal_data["decision_fusion"]
- auto_approve.py — fusion + consensus 認識(critic B3+B5):
  · composite > 0.7 → auto_execute_eligible bypass min_confidence
  · source=consensus_engine + score>=0.6 → 規則可信路徑
- consensus_engine.py — db-fix _save_consensus 重用 agent_sessions
- governance_agent.py — db-fix _alert PG 寫入 ai_governance_events
- approval_db.py — fusion 3 欄位 + 2 partial index + CheckConstraint
- db/models.py — schema 對齊 migration
- core/config.py — vuln #1 修復:OLLAMA_URL/_FALLBACK_URL field_validator
  拒絕公網 IP + 外部域名,僅允許私網/loopback/K8s SVC 白名單
- core/feature_flags.py — P2 fusion + consensus flags
- main.py — governance_agent lifespan 啟動
- failover_alerter.py — Wave8-X2: in-memory dedup fallback(Redis 拒絕後不 fail-open)
- ollama_*.py — metrics 整合 + recovery 改善
- auto_repair_service.py — verifier 接線

新增(測試 2438 行):
- test_decision_fusion.py / test_governance_agent.py / test_consensus_integration.py
- test_p2_db_fixes.py / test_wave8_fusion_fixes.py
- test_config_url_validation.py(vuln #1 12 tests)
- test_failover_alerter.py +Wave8-X2 in-memory dedup 補測

驗收: 116 tests pass (decision_fusion + wave8_fusion + config_url + consensus +
                      governance + p2_db_fixes + failover_alerter)

Conflict resolution:
- 3 檔(config.py + auto_approve.py + decision_manager.py)git stash pop 衝突
  保留 stashed (engineer 最終版),補回 ValueError 「公網 IP」字樣對齊 test

Note: 此 commit 解 production HEAD 隱性 import error
仍未修: vuln #4 prompt injection / debugger B14 quota fail-closed
       / B25-B26 drain_pending_tasks / B8 governance fail alert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Multiple Engineers (Wave 6/7/8) <noreply@anthropic.com>
2026-04-27 08:11:40 +08:00

406 lines
14 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.
"""
Auto Repair Service Tests - #8 自動升級決策
==========================================
測試自動修復服務層功能
版本: v1.0
建立: 2026-03-26 (台北時區)
建立者: Claude Code (#8 自動升級決策)
"""
import pytest
from src.models.incident import Incident, IncidentStatus, Severity, Signal
from src.models.playbook import (
ActionType,
Playbook,
PlaybookStatus,
RepairStep,
RiskLevel,
SymptomPattern,
)
from src.services.auto_repair_service import AutoRepairService
from src.utils.timezone import now_taipei
class MockPlaybookService:
"""Mock playbook service for testing"""
def __init__(self):
self._playbooks: dict[str, Playbook] = {}
self._recommendations: list = []
def add_playbook(self, playbook: Playbook):
self._playbooks[playbook.playbook_id] = playbook
def set_recommendations(self, recommendations: list):
self._recommendations = recommendations
async def get_recommendations(self, symptoms, top_k=3):
return self._recommendations
async def get_by_id(self, playbook_id: str):
return self._playbooks.get(playbook_id)
async def record_execution(self, playbook_id: str, success: bool):
playbook = self._playbooks.get(playbook_id)
if playbook:
if success:
playbook.success_count += 1
else:
playbook.failure_count += 1
return playbook is not None
def create_test_incident(
incident_id: str = "INC-TEST-001",
severity: Severity = Severity.P2,
) -> Incident:
"""Create a test incident"""
now = now_taipei()
return Incident(
incident_id=incident_id,
status=IncidentStatus.INVESTIGATING,
severity=severity,
affected_services=["test-service"],
signals=[
Signal(
alert_name="HighCPU",
severity=severity,
source="prometheus",
fired_at=now,
labels={"namespace": "prod"},
),
],
)
def create_high_quality_playbook(
playbook_id: str = "PB-TEST-001",
risk_level: RiskLevel = RiskLevel.MEDIUM,
) -> Playbook:
"""Create a high quality playbook (success_rate >= 95%, count >= 10)"""
return Playbook(
playbook_id=playbook_id,
name="HighCPU - test-service 修復劇本",
description="High quality playbook for auto repair",
status=PlaybookStatus.APPROVED,
symptom_pattern=SymptomPattern(
alert_names=["HighCPU"],
affected_services=["test-service"],
severity_range=["P2"],
),
repair_steps=[
RepairStep(
step_number=1,
action_type=ActionType.KUBECTL,
command="kubectl rollout restart deployment/{target}",
risk_level=risk_level,
),
],
success_count=20, # >= 10
failure_count=1, # success_rate = 95.2%
ai_confidence=0.9,
)
class MockPlaybookRecommendation:
"""Mock recommendation for testing"""
def __init__(self, playbook: Playbook, similarity_score: float):
self.playbook = playbook
self.similarity_score = similarity_score
async def _no_cooldown(*args, **kwargs) -> tuple[bool, str]:
"""單元測試用 cooldown: 永遠允許 (不需要 Redis)"""
return True, "允許自動修復 (test bypass)"
class TestAutoRepairService:
"""Auto Repair Service unit tests"""
@pytest.fixture
def mock_playbook_service(self):
return MockPlaybookService()
@pytest.fixture
def service(self, mock_playbook_service):
# 2026-04-01 ogt: 注入 no-op cooldown 以隔離 Redis 依賴
return AutoRepairService(
playbook_service=mock_playbook_service,
cooldown_checker=_no_cooldown,
)
@pytest.mark.asyncio
async def test_evaluate_blocks_p1_severity(self, service):
"""Test that P1 severity incidents are blocked"""
incident = create_test_incident(severity=Severity.P1)
decision = await service.evaluate_auto_repair(incident)
assert decision.can_auto_repair is False
assert decision.blocked_by == "HIGH_SEVERITY"
@pytest.mark.asyncio
async def test_evaluate_blocks_p0_severity(self, service):
"""Test that P0 severity incidents are blocked"""
incident = create_test_incident(severity=Severity.P0)
decision = await service.evaluate_auto_repair(incident)
assert decision.can_auto_repair is False
assert decision.blocked_by == "HIGH_SEVERITY"
@pytest.mark.asyncio
async def test_evaluate_no_playbook_match(self, service, mock_playbook_service):
"""Test when no playbook matches"""
mock_playbook_service.set_recommendations([])
incident = create_test_incident(severity=Severity.P2)
decision = await service.evaluate_auto_repair(incident)
assert decision.can_auto_repair is False
assert decision.blocked_by == "NO_MATCH"
@pytest.mark.asyncio
async def test_evaluate_low_similarity(self, service, mock_playbook_service):
"""Test that low similarity no longer blocks auto-repair.
2026-04-07: 統帥指令移除相似度門檻 — 只要 APPROVED Playbook 匹配即執行。
2026-04-08 Claude Sonnet 4.6: 更新測試預期以符合當前設計。
"""
playbook = create_high_quality_playbook()
mock_playbook_service.add_playbook(playbook)
mock_playbook_service.set_recommendations([
MockPlaybookRecommendation(playbook, similarity_score=0.5) # Below old 0.7 threshold
])
incident = create_test_incident(severity=Severity.P2)
decision = await service.evaluate_auto_repair(incident)
# 相似度門檻已移除 — APPROVED Playbook 即使低相似度也應通過
assert decision.can_auto_repair is True
assert decision.blocked_by is None
@pytest.mark.asyncio
async def test_evaluate_not_high_quality(self, service, mock_playbook_service):
"""Test low-quality playbook is now approved (gates removed 2026-04-07).
2026-04-07: 統帥指令移除品質門檻 — 只要 APPROVED 狀態即可執行。
2026-04-08 Claude Sonnet 4.6: 更新測試預期以符合當前設計。
"""
playbook = Playbook(
playbook_id="PB-LOW-QUALITY",
name="Low quality playbook",
description="Not enough executions",
status=PlaybookStatus.APPROVED,
symptom_pattern=SymptomPattern(
alert_names=["HighCPU"],
affected_services=["test-service"],
),
repair_steps=[
RepairStep(
step_number=1,
action_type=ActionType.KUBECTL,
command="kubectl rollout restart",
risk_level=RiskLevel.MEDIUM,
description="restart deployment",
),
],
success_count=2,
failure_count=0,
)
mock_playbook_service.add_playbook(playbook)
mock_playbook_service.set_recommendations([
MockPlaybookRecommendation(playbook, similarity_score=0.9)
])
incident = create_test_incident(severity=Severity.P2)
decision = await service.evaluate_auto_repair(incident)
# 品質門檻已移除 — APPROVED Playbook 直接通過
assert decision.can_auto_repair is True
assert decision.blocked_by is None
@pytest.mark.asyncio
async def test_evaluate_high_risk_blocked(self, service, mock_playbook_service):
"""Test HIGH risk playbook is now approved (gates removed 2026-04-07).
2026-04-07: 統帥指令移除風險等級門檻 — 只要 APPROVED 狀態即可執行。
2026-04-08 Claude Sonnet 4.6: 更新測試預期以符合當前設計。
"""
playbook = create_high_quality_playbook(risk_level=RiskLevel.HIGH)
mock_playbook_service.add_playbook(playbook)
mock_playbook_service.set_recommendations([
MockPlaybookRecommendation(playbook, similarity_score=0.9)
])
incident = create_test_incident(severity=Severity.P2)
decision = await service.evaluate_auto_repair(incident)
# 風險等級門檻已移除 — HIGH risk APPROVED Playbook 也通過
assert decision.can_auto_repair is True
assert decision.blocked_by is None
@pytest.mark.asyncio
async def test_evaluate_critical_risk_blocked(self, service, mock_playbook_service):
"""Test CRITICAL risk playbook is now approved (gates removed 2026-04-07).
2026-04-07: 統帥指令移除風險等級門檻。
2026-04-08 Claude Sonnet 4.6: 更新測試預期以符合當前設計。
"""
playbook = create_high_quality_playbook(risk_level=RiskLevel.CRITICAL)
mock_playbook_service.add_playbook(playbook)
mock_playbook_service.set_recommendations([
MockPlaybookRecommendation(playbook, similarity_score=0.9)
])
incident = create_test_incident(severity=Severity.P2)
decision = await service.evaluate_auto_repair(incident)
# 風險等級門檻已移除 — CRITICAL risk APPROVED Playbook 也通過
assert decision.can_auto_repair is True
assert decision.blocked_by is None
@pytest.mark.asyncio
async def test_evaluate_success(self, service, mock_playbook_service):
"""Test successful auto repair evaluation"""
playbook = create_high_quality_playbook(risk_level=RiskLevel.MEDIUM)
mock_playbook_service.add_playbook(playbook)
mock_playbook_service.set_recommendations([
MockPlaybookRecommendation(playbook, similarity_score=0.85)
])
incident = create_test_incident(severity=Severity.P2)
decision = await service.evaluate_auto_repair(incident)
assert decision.can_auto_repair is True
assert decision.playbook is not None
assert decision.playbook.playbook_id == playbook.playbook_id
assert decision.blocked_by is None
@pytest.mark.asyncio
async def test_evaluate_low_risk_allowed(self, service, mock_playbook_service):
"""Test that LOW risk actions are allowed"""
playbook = create_high_quality_playbook(risk_level=RiskLevel.LOW)
mock_playbook_service.add_playbook(playbook)
mock_playbook_service.set_recommendations([
MockPlaybookRecommendation(playbook, similarity_score=0.85)
])
incident = create_test_incident(severity=Severity.P2)
decision = await service.evaluate_auto_repair(incident)
assert decision.can_auto_repair is True
assert decision.risk_level == RiskLevel.LOW
@pytest.mark.asyncio
async def test_is_high_quality_calculation(self):
"""Test is_high_quality property"""
# High quality: APPROVED + 95%+ success rate + 10+ successes
playbook = create_high_quality_playbook()
assert playbook.is_high_quality is True
assert playbook.success_rate >= 0.95
assert playbook.success_count >= 10
@pytest.mark.asyncio
async def test_not_high_quality_low_success_rate(self):
"""Test playbook with low success rate is not high quality"""
playbook = Playbook(
playbook_id="PB-LOW-RATE",
name="Low success rate",
description="Too many failures",
status=PlaybookStatus.APPROVED,
symptom_pattern=SymptomPattern(
alert_names=["Test"],
affected_services=["test"],
),
repair_steps=[],
success_count=15,
failure_count=5, # 75% success rate
)
assert playbook.is_high_quality is False
assert playbook.success_rate < 0.95
# =============================================================================
# B25/B26 — drain_pending_tasks
# 2026-04-27 Wave8-X3 by Claude — K8s rolling restart drain fix
# =============================================================================
class TestDrainPendingTasks:
"""drain_pending_tasks 優雅關閉背景任務。"""
@pytest.fixture
def service(self):
return AutoRepairService(
playbook_service=MockPlaybookService(),
cooldown_checker=_no_cooldown,
)
@pytest.mark.asyncio
async def test_drain_no_pending_tasks_returns_zero(self, service):
"""沒有待處理 task → 立即返回 drained=0"""
result = await service.drain_pending_tasks(timeout=5.0)
assert result["drained"] == 0
assert result["timeout"] is False
@pytest.mark.asyncio
async def test_drain_waits_for_pending_tasks(self, service):
"""有 pending task → drain 等待完成後回報正確數量"""
import asyncio
completed = []
async def quick_task():
await asyncio.sleep(0.01)
completed.append(1)
task = asyncio.create_task(quick_task())
service._pending_tasks.add(task)
task.add_done_callback(service._pending_tasks.discard)
result = await service.drain_pending_tasks(timeout=5.0)
assert result["drained"] == 1
assert result.get("still_pending", 0) == 0
assert result["timeout"] is False
assert len(completed) == 1
@pytest.mark.asyncio
async def test_drain_timeout_reports_still_pending(self, service):
"""Task 超過 timeout → timeout=Truestill_pending > 0"""
import asyncio
async def slow_task():
await asyncio.sleep(10) # 遠超 timeout
task = asyncio.create_task(slow_task())
service._pending_tasks.add(task)
task.add_done_callback(service._pending_tasks.discard)
result = await service.drain_pending_tasks(timeout=0.05)
assert result["timeout"] is True
assert result.get("still_pending", 0) >= 1
# 清理:取消還在跑的 task 避免 test 洩漏
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_drain_multiple_tasks_all_complete(self, service):
"""多個 task → 全部完成drained 等於 task 數"""
import asyncio
async def quick():
await asyncio.sleep(0.01)
tasks = [asyncio.create_task(quick()) for _ in range(3)]
for t in tasks:
service._pending_tasks.add(t)
t.add_done_callback(service._pending_tasks.discard)
result = await service.drain_pending_tasks(timeout=5.0)
assert result["drained"] == 3
assert result["timeout"] is False