from __future__ import annotations from contextlib import asynccontextmanager from typing import Any from uuid import UUID import pytest from src.models.approval import ApprovalRequest, RiskLevel from src.plugins.mcp.gateway import GateApprovalError from src.plugins.mcp.interfaces import MCPToolResult from src.services.approval_execution import ApprovalExecutionService class FakeRedis: def __init__(self) -> None: self.set_calls: list[tuple[str, str, int | None]] = [] async def set(self, key: str, value: str, ex: int | None = None) -> None: self.set_calls.append((key, value, ex)) @pytest.mark.asyncio async def test_ssh_approval_execution_projects_approval_into_gateway( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_redis = FakeRedis() gateway_calls: list[dict[str, Any]] = [] db_context_projects: list[str | None] = [] fake_db = object() @asynccontextmanager async def fake_db_context(project_id: str | None = None): db_context_projects.append(project_id) yield fake_db class FakeGateway: def __init__(self, db: object) -> None: self.db = db async def call(self, ctx: Any, parameters: dict[str, Any]) -> MCPToolResult: gateway_calls.append({"db": self.db, "ctx": ctx, "parameters": parameters}) return MCPToolResult( success=True, output={"tool": ctx.tool_name, "ok": True}, execution_id="fake-gateway-ssh-exec", ) monkeypatch.setattr("src.services.approval_execution.get_redis", lambda: fake_redis) monkeypatch.setattr("src.services.approval_execution.get_db_context", fake_db_context) monkeypatch.setattr("src.services.approval_execution.McpGateway", FakeGateway) approval = ApprovalRequest( action="docker restart sentry-worker", description="測試 SSH approved execution gateway audit", risk_level=RiskLevel.LOW, requested_by="test", required_signatures=0, incident_id="INC-TEST-GATEWAY", ) result = await ApprovalExecutionService()._execute_ssh_host_action( approval=approval, host="192.168.0.110", ) assert result.success is True assert db_context_projects == ["awoooi"] assert len(gateway_calls) == 1 call = gateway_calls[0] ctx = call["ctx"] assert ctx.project_id == "awoooi" assert ctx.agent_id == "approval_executor" assert ctx.tool_name == "ssh_docker_restart" assert ctx.required_scope == "write" assert ctx.is_shadow is False assert ctx.environment == {"env": "prod"} assert ctx.run_id == approval.id assert isinstance(ctx.run_id, UUID) assert ctx.trace_id == "INC-TEST-GATEWAY" parameters = call["parameters"] assert parameters["host"] == "192.168.0.110" assert parameters["container_name"] == "sentry-worker" assert parameters["trust_score"] == 0.85 assert parameters["_mcp_audit"]["agent_role"] == "approval_executor" assert parameters["_mcp_audit"]["flywheel_node"] == "execute" assert parameters["_mcp_audit"]["incident_id"] == "INC-TEST-GATEWAY" assert fake_redis.set_calls == [ ( f"mcp_approval:awoooi:approval_executor:ssh_docker_restart:{approval.id}", "approved", 600, ) ] @pytest.mark.asyncio async def test_ssh_diagnose_approval_uses_gateway_without_gate5_projection( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_redis = FakeRedis() gateway_calls: list[dict[str, Any]] = [] @asynccontextmanager async def fake_db_context(project_id: str | None = None): yield {"project_id": project_id} class FakeGateway: def __init__(self, db: object) -> None: self.db = db async def call(self, ctx: Any, parameters: dict[str, Any]) -> MCPToolResult: gateway_calls.append({"db": self.db, "ctx": ctx, "parameters": parameters}) return MCPToolResult( success=True, output={"tool": ctx.tool_name, "ok": True}, execution_id="fake-gateway-read-exec", ) monkeypatch.setattr("src.services.approval_execution.get_redis", lambda: fake_redis) monkeypatch.setattr("src.services.approval_execution.get_db_context", fake_db_context) monkeypatch.setattr("src.services.approval_execution.McpGateway", FakeGateway) approval = ApprovalRequest( action="df -h", description="測試 SSH diagnose gateway audit", risk_level=RiskLevel.LOW, requested_by="test", required_signatures=0, incident_id="INC-TEST-READ", ) result = await ApprovalExecutionService()._execute_ssh_host_action( approval=approval, host="192.168.0.110", ) assert result.success is True assert fake_redis.set_calls == [] assert len(gateway_calls) == 1 ctx = gateway_calls[0]["ctx"] assert ctx.agent_id == "approval_executor" assert ctx.tool_name == "ssh_diagnose" assert ctx.required_scope == "read" assert ctx.is_shadow is False @pytest.mark.asyncio async def test_gateway_block_returns_failed_execution_result_without_rollback( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_redis = FakeRedis() db_context_exits: list[str] = [] @asynccontextmanager async def fake_db_context(project_id: str | None = None): try: yield {"project_id": project_id} except Exception: db_context_exits.append("raised") raise else: db_context_exits.append("normal") class FakeGateway: def __init__(self, db: object) -> None: self.db = db async def call(self, ctx: Any, parameters: dict[str, Any]) -> MCPToolResult: raise GateApprovalError("approval missing in smoke path") monkeypatch.setattr("src.services.approval_execution.get_redis", lambda: fake_redis) monkeypatch.setattr("src.services.approval_execution.get_db_context", fake_db_context) monkeypatch.setattr("src.services.approval_execution.McpGateway", FakeGateway) approval = ApprovalRequest( action="docker restart sentry-worker", description="測試 Gateway blocked audit 不被 rollback", risk_level=RiskLevel.LOW, requested_by="test", required_signatures=0, incident_id="INC-TEST-BLOCKED", ) result = await ApprovalExecutionService()._execute_ssh_host_action( approval=approval, host="192.168.0.110", ) assert result.success is False assert result.error is not None assert "E-MCP-GATE-005" in result.error assert db_context_exits == ["normal"] assert fake_redis.set_calls == [ ( f"mcp_approval:awoooi:approval_executor:ssh_docker_restart:{approval.id}", "approved", 600, ) ]