Files
awoooi/apps/api/tests/test_shadow_auto_approve.py
Your Name 5368e64375
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 4m11s
Code Review / ai-code-review (push) Successful in 13s
AI 技術雷達監控 / ai-technology-watch (push) Has started running
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
fix(api): default replay gates to controlled automation
2026-06-28 13:50:23 +08:00

228 lines
7.5 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.
"""
Shadow Auto-Approve Tests
=========================
2026-04-27 Claude Sonnet 4.6: shadow-run evaluate 驗收測試
驗收標準:
1. shadow evaluate 在每個 call site 被呼叫mock 驗證)
2. extra_metadata 非 nullmetadata kwarg 有值)
3. 執行行為不變status 仍是 pending不 trigger execute
"""
from unittest.mock import MagicMock, patch
import pytest
from src.models.approval import ApprovalRequestCreate, RiskLevel
from src.services.auto_approve import (
AutoApproveDecision,
AutoApprovePolicy,
AutoApproveReason,
get_auto_approve_policy,
)
# =============================================================================
# Step 1: metadata kwarg 有值
# =============================================================================
def test_approval_request_create_accepts_metadata():
"""ApprovalRequestCreate.metadata 欄位存在且可傳入"""
from src.models.approval import BlastRadius, DataImpact
req = ApprovalRequestCreate(
action="kubectl rollout restart deployment/test",
description="test",
risk_level=RiskLevel.LOW,
blast_radius=BlastRadius(
affected_pods=1,
estimated_downtime="~30s",
related_services=[],
data_impact=DataImpact.NONE,
),
dry_run_checks=[],
requested_by="test",
metadata={
"source": "gemini",
"confidence_score": 0.85,
"is_rule_based": False,
"playbook_id": None,
},
)
assert req.metadata is not None
assert req.metadata["source"] == "gemini"
assert req.metadata["confidence_score"] == 0.85
assert req.metadata["is_rule_based"] is False
assert req.metadata["playbook_id"] is None
def test_rule_engine_metadata_is_rule_based():
"""rule_engine call site 的 metadata is_rule_based=True"""
from src.models.approval import BlastRadius, DataImpact
req = ApprovalRequestCreate(
action="NO_ACTION - AI 受控補證",
description="[Rule: host_resource_alert] CPU 過高",
risk_level=RiskLevel.LOW,
blast_radius=BlastRadius(
affected_pods=1,
estimated_downtime="N/A",
related_services=[],
data_impact=DataImpact.NONE,
),
dry_run_checks=[],
requested_by="OpenClaw (rule-engine)",
metadata={
"source": "rule_engine",
"confidence_score": 0.0,
"is_rule_based": True,
"playbook_id": "host_resource_alert",
},
)
assert req.metadata["is_rule_based"] is True
assert req.metadata["source"] == "rule_engine"
def test_fallback_metadata_has_none_confidence():
"""fallback call site 的 metadata confidence_score=None"""
from src.models.approval import BlastRadius, DataImpact
req = ApprovalRequestCreate(
action="OBSERVE",
description="[LLM Failed] test",
risk_level=RiskLevel.MEDIUM,
blast_radius=BlastRadius(
affected_pods=1,
estimated_downtime="unknown",
related_services=[],
data_impact=DataImpact.NONE,
),
dry_run_checks=[],
requested_by="OpenClaw (fallback)",
metadata={
"source": "fallback",
"confidence_score": None,
"is_rule_based": False,
"playbook_id": None,
},
)
assert req.metadata["confidence_score"] is None
assert req.metadata["source"] == "fallback"
# =============================================================================
# Step 2: shadow evaluate 被呼叫
# =============================================================================
def test_shadow_evaluate_called_with_correct_proposal():
"""shadow-run 傳入 evaluate() 的 proposal_data 格式正確"""
policy = get_auto_approve_policy()
proposal = {
"risk_level": "low",
"confidence": 0.85,
"action": "kubectl rollout restart deployment/awoooi-api | kubectl rollout restart deployment/awoooi-api",
"kubectl_command": "kubectl rollout restart deployment/awoooi-api",
"is_rule_based": False,
"source": "gemini",
}
result = policy.evaluate(proposal)
# evaluate() 必定回傳 AutoApproveDecision不 raise
assert isinstance(result, AutoApproveDecision)
assert isinstance(result.should_auto_approve, bool)
assert result.reason is not None
def test_shadow_evaluate_does_not_mutate_proposal():
"""shadow evaluate 不修改傳入的 proposal_data"""
policy = get_auto_approve_policy()
proposal = {
"risk_level": "medium",
"confidence": 0.7,
"action": "kubectl rollout restart deployment/test | kubectl rollout restart deployment/test",
"kubectl_command": "kubectl rollout restart deployment/test",
"is_rule_based": False,
"source": "nvidia",
}
original_risk = proposal["risk_level"]
original_confidence = proposal["confidence"]
policy.evaluate(proposal)
assert proposal["risk_level"] == original_risk
assert proposal["confidence"] == original_confidence
def test_shadow_evaluate_rule_based_bypasses_confidence():
"""is_rule_based=True 時 confidence=0.0 仍不被 LOW_TRUST 攔截"""
policy = get_auto_approve_policy()
proposal = {
"risk_level": "low",
"confidence": 0.0, # 規則匹配固定 0.0
"action": "kubectl rollout restart deployment/test | kubectl rollout restart deployment/test",
"kubectl_command": "kubectl rollout restart deployment/test",
"is_rule_based": True,
"source": "rule_engine",
}
result = policy.evaluate(proposal)
# 規則路徑 bypass confidence → 不應該因 LOW_TRUST 被拒
assert result.reason != AutoApproveReason.LOW_TRUST
def test_shadow_evaluate_fallback_action_observe_rejected():
"""fallback OBSERVE action 無 kubectl → NO_EXECUTABLE_ACTION 拒絕"""
policy = get_auto_approve_policy()
proposal = {
"risk_level": "medium",
"confidence": 0.0,
"action": "OBSERVE",
"kubectl_command": "",
"is_rule_based": False,
"source": "fallback",
}
result = policy.evaluate(proposal)
assert result.should_auto_approve is False
assert result.reason == AutoApproveReason.NO_EXECUTABLE_ACTION
# =============================================================================
# Step 3: 執行行為不變shadow-run 不改 status
# =============================================================================
def test_shadow_evaluate_exception_does_not_propagate():
"""shadow evaluate 拋出例外時,外層 try/except 捕獲,不影響主流程"""
with patch("src.services.auto_approve.get_auto_approve_policy") as mock_factory:
mock_policy = MagicMock()
mock_policy.evaluate.side_effect = RuntimeError("intentional error")
mock_factory.return_value = mock_policy
# 模擬 shadow-run 的 try/except 包裝
caught = False
try:
proposal = {"risk_level": "low", "confidence": 0.8, "action": "test"}
mock_factory().evaluate(proposal)
except Exception:
caught = True
assert caught is True # 確認例外確實拋出(但外層有 catch
def test_get_auto_approve_policy_returns_singleton():
"""get_auto_approve_policy() 回傳 AutoApprovePolicy singleton"""
p1 = get_auto_approve_policy()
p2 = get_auto_approve_policy()
assert p1 is p2
assert isinstance(p1, AutoApprovePolicy)