fischer-agentkit/tests/unit/mcp/test_server_auth.py

399 lines
16 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.

"""U13 — MCP Server 认证 + 合并至主 app 的单元测试。
覆盖场景:
- 无认证 / 无效凭据 → 401
- 有效 API Key / 有效 JWTmember→ 200 + 工具列表
- 有效 JWT 但无权限guest→ 403
- tools/call 成功 / 未知工具404 / 执行错误
- JSON-RPC 2.0 端点initialize / tools/list / tools/call / 未知方法 / 解析错误)
- MCPServer 类向后兼容DeprecationWarning
- create_mcp_router() 返回 APIRouter / 空 registry
"""
from __future__ import annotations
import warnings
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import jwt
from fastapi import FastAPI
from fastapi.routing import APIRouter
from agentkit.mcp.server import MCPServer, create_mcp_router
from agentkit.server.auth.middleware import AuthMiddleware
# 测试用的固定凭据 — 仅限单元测试,不可用于生产。
JWT_SECRET = "u13-test-jwt-secret-xxxxxxxxxxxxx"
API_KEY = "u13-test-api-key-yyy"
# ── 测试辅助函数 ──────────────────────────────────────────
def _make_app(tool_registry: Any = None, *, dev_mode: bool = False) -> FastAPI:
"""构造测试用 FastAPI app挂载 MCP router + AuthMiddleware。
Args:
tool_registry: ToolRegistry 实例(或 mock。None 表示未配置。
dev_mode: True = 不添加 AuthMiddleware开发模式所有请求放行
"""
app = FastAPI()
app.state.tool_registry = tool_registry
if not dev_mode:
app.add_middleware(AuthMiddleware, jwt_secret=JWT_SECRET, api_key=API_KEY)
mcp_router = create_mcp_router(tool_registry=tool_registry)
app.include_router(mcp_router, prefix="/api/v1/mcp")
return app
def _make_mock_tool(
name: str = "echo",
description: str = "Echo tool",
input_schema: dict | None = None,
result: str = "echo: hi",
) -> MagicMock:
"""构造一个 mock 工具,模拟 Tool 接口。"""
tool = MagicMock()
tool.name = name
tool.description = description
tool.input_schema = input_schema or {"type": "object", "properties": {}}
tool.safe_execute = AsyncMock(return_value=result)
return tool
def _make_mock_registry(tools: list) -> MagicMock:
"""构造一个 mock ToolRegistry支持 list_tools() 和 get(name)。"""
registry = MagicMock()
registry.list_tools.return_value = tools
def _get(name: str):
for t in tools:
if t.name == name:
return t
raise KeyError(name)
registry.get = _get
return registry
def _make_jwt(
role: str = "member",
user_id: str = "u1",
username: str = "alice",
) -> str:
"""签发一个测试用 access JWTtype=accessHS256"""
payload = {
"sub": user_id,
"username": username,
"role": role,
"type": "access",
"iat": 1700000000,
"exp": 9999999999, # 远未来,避免过期
}
token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
return token.decode("utf-8") if isinstance(token, bytes) else token
def _make_registry_with_two_tools() -> MagicMock:
"""构造含两个 mock 工具的 registryecho + reverse"""
echo = _make_mock_tool(name="echo", description="Echo input", result="echo: hi")
reverse = _make_mock_tool(
name="reverse",
description="Reverse text",
result="dcba",
)
return _make_mock_registry([echo, reverse])
# ── 1-2: 无认证 / 无效凭据 ────────────────────────────────
class TestNoAuth:
"""无认证或无效凭据应返回 401。"""
async def test_no_auth_returns_401(self):
"""场景 1: 不带 Authorization 头 → 401。"""
app = _make_app(tool_registry=_make_registry_with_two_tools())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/v1/mcp/tools/list")
assert resp.status_code == 401
async def test_invalid_api_key_returns_401(self):
"""场景 2: 无效 X-API-Key → 401。"""
app = _make_app(tool_registry=_make_registry_with_two_tools())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
"/api/v1/mcp/tools/list",
headers={"X-API-Key": "invalid-key"},
)
assert resp.status_code == 401
# ── 3-5: 有效凭据 + 权限校验 ──────────────────────────────
class TestValidAuth:
"""有效凭据应通过认证,权限校验决定 200/403。"""
async def test_valid_api_key_returns_tool_list(self):
"""场景 3: 有效 X-API-Key → 200 + 工具列表。"""
app = _make_app(tool_registry=_make_registry_with_two_tools())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
"/api/v1/mcp/tools/list",
headers={"X-API-Key": API_KEY},
)
assert resp.status_code == 200
body = resp.json()
assert "tools" in body
names = {t["name"] for t in body["tools"]}
assert names == {"echo", "reverse"}
async def test_valid_jwt_member_returns_tool_list(self):
"""场景 4: 有效 JWTmember 角色)→ 200 + 工具列表。"""
app = _make_app(tool_registry=_make_registry_with_two_tools())
token = _make_jwt(role="member")
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
"/api/v1/mcp/tools/list",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
body = resp.json()
assert len(body["tools"]) == 2
async def test_valid_jwt_guest_no_permission_returns_403(self):
"""场景 5: 有效 JWT 但 role=guest无 CHAT 权限)→ 403。
guest 角色不在 ROLE_PERMISSIONS 中,故 has_permission 返回 False。
"""
app = _make_app(tool_registry=_make_registry_with_two_tools())
token = _make_jwt(role="guest")
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
"/api/v1/mcp/tools/list",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
# ── 6-8: tools/call 端点 ─────────────────────────────────
class TestCallTool:
"""POST /api/v1/mcp/tools/call 的成功 / 404 / 执行错误。"""
async def test_call_tool_success(self):
"""场景 6: 有效认证 + 有效工具 → 200 + 结果。"""
registry = _make_registry_with_two_tools()
app = _make_app(tool_registry=registry)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/mcp/tools/call",
json={"name": "echo", "arguments": {"text": "hi"}},
headers={"X-API-Key": API_KEY},
)
assert resp.status_code == 200
body = resp.json()
assert body["content"][0]["type"] == "text"
assert "echo: hi" in body["content"][0]["text"]
async def test_call_tool_unknown_returns_404(self):
"""场景 7: 有效认证 + 未知工具 → 404。"""
registry = _make_registry_with_two_tools()
app = _make_app(tool_registry=registry)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/mcp/tools/call",
json={"name": "nonexistent", "arguments": {}},
headers={"X-API-Key": API_KEY},
)
assert resp.status_code == 404
async def test_call_tool_execution_error_returns_isError(self):
"""场景 8: 有效认证 + 工具抛异常 → 200 + isError=True。"""
failing_tool = _make_mock_tool(name="boom", result="should-not-reach")
failing_tool.safe_execute = AsyncMock(side_effect=RuntimeError("boom!"))
registry = _make_mock_registry([failing_tool])
app = _make_app(tool_registry=registry)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/mcp/tools/call",
json={"name": "boom", "arguments": {}},
headers={"X-API-Key": API_KEY},
)
assert resp.status_code == 200
body = resp.json()
assert body.get("isError") is True
assert "boom!" in body["content"][0]["text"]
# ── 9: health 端点 ───────────────────────────────────────
class TestHealthEndpoint:
"""GET /api/v1/mcp/health — 有效认证 → 200。"""
async def test_health_with_valid_auth(self):
"""场景 9: 有效认证 → 200 + {"status": "ok"}。"""
app = _make_app(tool_registry=None)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
"/api/v1/mcp/health",
headers={"X-API-Key": API_KEY},
)
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
# ── 10-14: JSON-RPC 2.0 端点 ─────────────────────────────
class TestJsonRpcEndpoint:
"""POST /api/v1/mcp/ — MCP 协议兼容的 JSON-RPC 2.0 端点。"""
async def test_jsonrpc_initialize(self):
"""场景 10: initialize → 200 + protocolVersion。"""
app = _make_app(tool_registry=_make_registry_with_two_tools())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/mcp/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1},
headers={"X-API-Key": API_KEY},
)
assert resp.status_code == 200
body = resp.json()
assert body["jsonrpc"] == "2.0"
assert body["id"] == 1
assert body["result"]["protocolVersion"] == "2024-11-05"
assert body["result"]["serverInfo"]["name"] == "agentkit-mcp-server"
async def test_jsonrpc_tools_list(self):
"""场景 11: tools/list via JSON-RPC → 200 + tools 数组。"""
app = _make_app(tool_registry=_make_registry_with_two_tools())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/mcp/",
json={"jsonrpc": "2.0", "method": "tools/list", "id": 2},
headers={"X-API-Key": API_KEY},
)
assert resp.status_code == 200
body = resp.json()
assert body["id"] == 2
assert len(body["result"]["tools"]) == 2
async def test_jsonrpc_tools_call(self):
"""场景 12: tools/call via JSON-RPC → 200 + 结果。"""
app = _make_app(tool_registry=_make_registry_with_two_tools())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/mcp/",
json={
"jsonrpc": "2.0",
"method": "tools/call",
"params": {"name": "echo", "arguments": {}},
"id": 3,
},
headers={"X-API-Key": API_KEY},
)
assert resp.status_code == 200
body = resp.json()
assert body["id"] == 3
assert "content" in body["result"]
async def test_jsonrpc_unknown_method_returns_error_32601(self):
"""场景 13: 未知 method → error code -32601。"""
app = _make_app(tool_registry=None)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/mcp/",
json={"jsonrpc": "2.0", "method": "unknown", "id": 4},
headers={"X-API-Key": API_KEY},
)
assert resp.status_code == 200
body = resp.json()
assert body["error"]["code"] == -32601
assert body["id"] == 4
async def test_jsonrpc_parse_error_returns_error_32700(self):
"""场景 14: 无效 JSON → error code -32700。"""
app = _make_app(tool_registry=None)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/mcp/",
content=b"this is not json",
headers={
"X-API-Key": API_KEY,
"Content-Type": "application/json",
},
)
assert resp.status_code == 200
body = resp.json()
assert body["error"]["code"] == -32700
assert body["id"] is None
# ── 15: MCPServer 向后兼容 ───────────────────────────────
class TestMCPServerDeprecation:
"""旧 MCPServer 类应发出 DeprecationWarning。"""
def test_create_app_emits_deprecation_warning(self):
"""场景 15: MCPServer()._create_app() 发出 DeprecationWarning。"""
server = MCPServer()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
server._create_app()
dep_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert len(dep_warnings) >= 1
assert "create_mcp_router" in str(dep_warnings[0].message)
# ── 16-17: create_mcp_router 工厂 ────────────────────────
class TestCreateMcpRouter:
"""create_mcp_router() 工厂行为。"""
def test_create_mcp_router_returns_apirouter(self):
"""场景 16: create_mcp_router() 返回 APIRouter 实例。"""
router = create_mcp_router(tool_registry=None)
assert isinstance(router, APIRouter)
# 验证路由数量tools/list, tools/call, health, /JSON-RPC
paths = {route.path for route in router.routes}
assert "/tools/list" in paths
assert "/tools/call" in paths
assert "/health" in paths
assert "/" in paths
async def test_empty_registry_returns_empty_list(self):
"""场景 17: tool_registry=None → /tools/list 返回 {"tools": []}。"""
app = _make_app(tool_registry=None)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
"/api/v1/mcp/tools/list",
headers={"X-API-Key": API_KEY},
)
assert resp.status_code == 200
assert resp.json() == {"tools": []}