355 lines
11 KiB
Python
355 lines
11 KiB
Python
import json
|
|
|
|
import pytest
|
|
from django.contrib.auth import get_user_model
|
|
from django.db import transaction
|
|
from django.test import override_settings
|
|
|
|
from apps.agents.models import AgentRun
|
|
from apps.agents.tests.helpers import create_agent
|
|
from apps.audit.models import OperationAuditLog
|
|
from apps.modelhub.crypto import encrypt_secret
|
|
from apps.tickets.models import Ticket
|
|
from apps.tools.models import (
|
|
ToolAuthorization,
|
|
ToolCall,
|
|
ToolCallStatus,
|
|
ToolProvider,
|
|
ToolRiskLevel,
|
|
UnifiedTool,
|
|
)
|
|
from apps.tools.services import (
|
|
ToolExecutionError,
|
|
decide_tool_confirmation,
|
|
request_tool_call,
|
|
sync_mcp_tools,
|
|
)
|
|
from apps.workitems.models import WorkItem, WorkItemType
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
return None
|
|
|
|
def read(self):
|
|
return json.dumps(self.payload).encode()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_http_mcp_sync_and_read_only_call_are_audited_and_idempotent(
|
|
django_capture_on_commit_callbacks,
|
|
monkeypatch,
|
|
):
|
|
responses = iter(
|
|
[
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": "ai-ticket-platform",
|
|
"result": {
|
|
"tools": [
|
|
{
|
|
"name": "lookup_customer",
|
|
"description": "只读查询客户状态",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"customer_id": {"type": "string"}},
|
|
"required": ["customer_id"],
|
|
},
|
|
}
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": "ai-ticket-platform",
|
|
"result": {"content": [{"type": "text", "text": "active"}]},
|
|
},
|
|
]
|
|
)
|
|
monkeypatch.setattr(
|
|
"apps.tools.services.urlopen",
|
|
lambda *args, **kwargs: FakeResponse(next(responses)),
|
|
)
|
|
provider = ToolProvider.objects.create(
|
|
name="内部查询 MCP",
|
|
base_url="https://mcp.internal.example/rpc",
|
|
)
|
|
tools = sync_mcp_tools(provider)
|
|
assert len(tools) == 1
|
|
tool = tools[0]
|
|
assert tool.code == f"mcp.{provider.id}.lookup_customer"
|
|
actor = get_user_model().objects.create_superuser(
|
|
username="tool-admin",
|
|
email="tool-admin@example.com",
|
|
password="password",
|
|
)
|
|
|
|
with django_capture_on_commit_callbacks(execute=True):
|
|
first = request_tool_call(
|
|
tool,
|
|
{"customer_id": "C-001"},
|
|
user=actor,
|
|
idempotency_key="lookup-C-001",
|
|
)
|
|
first.refresh_from_db()
|
|
second = request_tool_call(
|
|
tool,
|
|
{"customer_id": "C-001"},
|
|
user=actor,
|
|
idempotency_key="lookup-C-001",
|
|
)
|
|
|
|
assert first.status == ToolCallStatus.SUCCEEDED
|
|
assert first.output_payload["content"][0]["text"] == "active"
|
|
assert second.id == first.id
|
|
assert ToolCall.objects.count() == 1
|
|
assert OperationAuditLog.objects.filter(
|
|
module="tools",
|
|
action="tool_call_finished",
|
|
target_id=str(first.public_id),
|
|
).exists()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_high_risk_tool_requires_independent_confirmation(
|
|
django_capture_on_commit_callbacks,
|
|
monkeypatch,
|
|
):
|
|
monkeypatch.setattr(
|
|
"apps.tools.services.urlopen",
|
|
lambda *args, **kwargs: FakeResponse(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": "ai-ticket-platform",
|
|
"result": {"content": [{"type": "text", "text": "done"}]},
|
|
}
|
|
),
|
|
)
|
|
provider = ToolProvider.objects.create(
|
|
name="变更 MCP",
|
|
base_url="https://mcp.internal.example/rpc",
|
|
)
|
|
tool = UnifiedTool.objects.create(
|
|
code="mcp.change.restart",
|
|
provider=provider,
|
|
external_name="restart_service",
|
|
name="重启服务",
|
|
input_schema={"type": "object"},
|
|
risk_level=ToolRiskLevel.HIGH_RISK,
|
|
)
|
|
requester = get_user_model().objects.create_superuser(
|
|
username="tool-requester",
|
|
email="requester@example.com",
|
|
password="password",
|
|
)
|
|
reviewer = get_user_model().objects.create_superuser(
|
|
username="tool-reviewer",
|
|
email="reviewer@example.com",
|
|
password="password",
|
|
)
|
|
call = request_tool_call(tool, {}, user=requester)
|
|
assert call.status == ToolCallStatus.PENDING_CONFIRMATION
|
|
assert call.confirmation.status == "pending"
|
|
task = WorkItem.objects.get(task_type=WorkItemType.TOOL_CONFIRMATION)
|
|
assert task.status == "active"
|
|
|
|
with pytest.raises(ToolExecutionError, match="不能由申请人自审"):
|
|
decide_tool_confirmation(call, reviewer=requester, decision="approve")
|
|
|
|
with django_capture_on_commit_callbacks(execute=True):
|
|
decide_tool_confirmation(
|
|
call,
|
|
reviewer=reviewer,
|
|
decision="approve",
|
|
reason="已核对变更窗口",
|
|
)
|
|
call.refresh_from_db()
|
|
call.confirmation.refresh_from_db()
|
|
assert call.status == ToolCallStatus.SUCCEEDED
|
|
assert call.confirmation.status == "approved"
|
|
assert call.confirmation.decided_by == reviewer
|
|
task.refresh_from_db()
|
|
assert task.status == "completed"
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_tool_api_hides_credentials_and_blocks_unprivileged_invocation(
|
|
admin_client,
|
|
client,
|
|
):
|
|
response = admin_client.post(
|
|
"/api/tools/providers",
|
|
{
|
|
"name": "受控 MCP",
|
|
"provider_type": "mcp_http",
|
|
"base_url": "https://mcp.internal.example/rpc",
|
|
"token": "top-secret-token",
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["credential_set"] is True
|
|
assert "token" not in response.json()
|
|
|
|
user = get_user_model().objects.create_user(
|
|
username="unprivileged-tool-user",
|
|
password="password",
|
|
)
|
|
client.force_login(user)
|
|
response = client.post(
|
|
"/api/tools/calls",
|
|
{"tool_code": "missing", "input": {}},
|
|
content_type="application/json",
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_tool_governance_api_lists_grants_and_calls(admin_client):
|
|
provider = ToolProvider.objects.create(
|
|
name="治理 MCP",
|
|
base_url="https://mcp.internal.example/rpc",
|
|
)
|
|
tool = UnifiedTool.objects.create(
|
|
code="mcp.governance.lookup",
|
|
provider=provider,
|
|
external_name="lookup",
|
|
name="治理查询",
|
|
input_schema={"type": "object"},
|
|
)
|
|
|
|
response = admin_client.post(
|
|
f"/api/tools/{tool.id}/authorizations",
|
|
{"principal_type": "group", "principal_id": 7, "allowed": True},
|
|
content_type="application/json",
|
|
)
|
|
assert response.status_code == 200
|
|
response = admin_client.get(f"/api/tools/{tool.id}/authorizations")
|
|
assert response.status_code == 200
|
|
assert response.json()[0]["principal_type"] == "group"
|
|
|
|
response = admin_client.patch(
|
|
f"/api/tools/providers/{provider.id}",
|
|
{"timeout_seconds": 45, "enabled": False},
|
|
content_type="application/json",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["timeout_seconds"] == 45
|
|
assert response.json()["enabled"] is False
|
|
|
|
actor = get_user_model().objects.filter(is_superuser=True).first()
|
|
call = ToolCall.objects.create(
|
|
tool=tool,
|
|
requested_by=actor,
|
|
status=ToolCallStatus.SUCCEEDED,
|
|
input_payload={"query": "status"},
|
|
output_payload={"status": "ok"},
|
|
)
|
|
response = admin_client.get("/api/tools/calls")
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload[0]["public_id"] == str(call.public_id)
|
|
assert payload[0]["requested_by_id"] == actor.id
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@override_settings(INTERNAL_MCP_TOKEN="internal-ticket-token")
|
|
def test_internal_ticket_mcp_requires_bearer_and_queries_real_tickets(client):
|
|
Ticket.objects.create(
|
|
ticket_no="TK-MCP-001",
|
|
title="客户无法登录控制台",
|
|
description="登录后立即返回认证失败",
|
|
status="pending",
|
|
)
|
|
rpc = {
|
|
"jsonrpc": "2.0",
|
|
"id": "gateway-test",
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "search_tickets",
|
|
"arguments": {"query": "登录", "limit": 5},
|
|
},
|
|
}
|
|
unauthorized = client.post(
|
|
"/internal-mcp",
|
|
rpc,
|
|
content_type="application/json",
|
|
)
|
|
assert unauthorized.status_code == 401
|
|
|
|
response = client.post(
|
|
"/internal-mcp",
|
|
rpc,
|
|
content_type="application/json",
|
|
HTTP_AUTHORIZATION="Bearer internal-ticket-token",
|
|
)
|
|
assert response.status_code == 200
|
|
structured = response.json()["result"]["structuredContent"]
|
|
assert structured["count"] == 1
|
|
assert structured["tickets"][0]["ticket_no"] == "TK-MCP-001"
|
|
|
|
|
|
@pytest.mark.django_db(transaction=True)
|
|
@override_settings(INTERNAL_MCP_TOKEN="real-http-ticket-token")
|
|
def test_internal_ticket_mcp_real_http_agent_call_end_to_end(live_server):
|
|
ticket = Ticket.objects.create(
|
|
ticket_no="TK-MCP-E2E",
|
|
title="生产订单同步延迟",
|
|
description="订单同步任务积压,需要查询同类工单",
|
|
status="in_progress",
|
|
)
|
|
actor = get_user_model().objects.create_superuser(
|
|
username="mcp-e2e-user",
|
|
email="mcp-e2e@example.com",
|
|
password="password",
|
|
)
|
|
definition, version = create_agent(published=True, two_steps=False)
|
|
provider = ToolProvider.objects.create(
|
|
name="平台内部工单 MCP",
|
|
base_url=f"{live_server.url}/internal-mcp",
|
|
token_encrypted=encrypt_secret("real-http-ticket-token"),
|
|
)
|
|
tools = sync_mcp_tools(provider)
|
|
assert len(tools) == 1
|
|
tool = tools[0]
|
|
assert tool.external_name == "search_tickets"
|
|
version.allowed_actions = [f"tool.call:{tool.code}"]
|
|
version.save(update_fields=["allowed_actions"])
|
|
run = AgentRun.objects.create(
|
|
definition=definition,
|
|
agent_version=version,
|
|
created_by=actor,
|
|
config_snapshot={
|
|
"agent": {"allowed_actions": [f"tool.call:{tool.code}"]}
|
|
},
|
|
)
|
|
ToolAuthorization.objects.create(
|
|
tool=tool,
|
|
principal_type="agent",
|
|
principal_id=definition.id,
|
|
allowed=True,
|
|
)
|
|
|
|
with transaction.atomic():
|
|
call = request_tool_call(
|
|
tool,
|
|
{"query": "订单同步", "status": "in_progress"},
|
|
user=actor,
|
|
run=run,
|
|
idempotency_key="internal-ticket-mcp-e2e",
|
|
)
|
|
call.refresh_from_db()
|
|
|
|
assert call.status == ToolCallStatus.SUCCEEDED
|
|
structured = call.output_payload["structuredContent"]
|
|
assert structured["count"] == 1
|
|
assert structured["tickets"][0]["id"] == ticket.id
|
|
assert call.authorization_snapshot["agent_grant"] is True
|
|
assert call.risk_snapshot["risk_level"] == ToolRiskLevel.READ_ONLY
|