Files
ulthon_admin/tests/McpEndpointTest.php

561 lines
24 KiB
PHP
Raw Permalink 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.

<?php
declare(strict_types=1);
namespace tests;
use app\common\service\McpService;
use app\mcp\service\McpDispatch;
use GuzzleHttp\Client;
use PHPUnit\Framework\TestCase;
use think\facade\Config;
use think\facade\Db;
/**
* MCP 端点端到端测试(真实 HTTP 栈:/mcp initialize / tools/list / tools/call / 401 / 403 / 审计).
*
* ===== 方案说明(为什么不是 Guzzle 打容器 nginx 127.0.0.1:8000=====
* 任务书期望 e2e 直打容器 nginx但 nginx/fpm 栈读 .env 连的是开发库ulthon
* 而 e2e 断言要求 fixture 自包含(不依赖 orchestrator 手工夹具 key=2/3且测试库与
* 开发库严格分离(任务 MUST NOT + ulthon-testing 技能铁律)。事务内的 fixture 对独立
* HTTP 连接永远不可见——两者在本栈里数学上不可兼得。
*
* 采用的可行方案:测试进程内拉起一个专用 php -S 服务tests/router.php 入口),
* 该入口与 tests/bootstrap.php 共享同一套 config 级测试库覆盖require 复用防漂移),
* 走完整 think HTTP 内核(多应用路由 / 全局中间件 / Sessionfixture 提交进测试库、
* 测试结束硬删自清理。这是同时满足「真实 HTTP e2e」+「自包含 fixture」+「测试库隔离」
* 的唯一组合。真实 nginx 栈的冒烟由回归步骤tools:http:call另行覆盖。
* 入口文件名必须是 router.phpthink-multi-app 入口白名单,见其文件头注释)。
*
* ===== 隔离策略(区别于 McpServiceTest 的事务回滚)=====
* 本类不继承 app\common\test\TestCase事务对跨进程 HTTP 服务无意义fixture 以
* 专有前缀e2e_mcp_提交进测试库
* - setUpBeforeClass先清扫上次异常退出可能残留的同前缀行幂等
* - tearDownAfterClass删除本类全部 tracked fixture含 mcp_log 审计行)
*
* ===== JSON-RPC 会话要求T8 经验)=====
* initialize 先行,从响应头提取 Mcp-Session-Id后续 tools/list / tools/call 必须携带;
* Mcp-Protocol-Version 用 initialize 返回的协商版本。
*
* ===== 超管创建者 =====
* 不造 admin id=1seed 已存在);用全新管理员 + 全新角色(避开 SystemAuthNode 60s
* 缓存与 adminInfo autoCache 窗口learnings Task 4/6 的既定模式)。
*
* ===== 403 分支 =====
* mcp.enable 由控制器经 config() 读取;测试经专用入口的 X-E2E-Mcp-Disabled: 1 头
* 模拟关闭(生产代码零改动),而非动态改 .env不可行或 config mock跨进程无效
*/
class McpEndpointTest extends TestCase
{
private const HOST = '127.0.0.1';
private const PORT = 8127;
/** 授权节点 Aquick 控制器 index返回 code=0 JSON 信封). */
private const NODE_A = 'system.quick/index';
/** 授权节点 Bmcp_key 控制器 index. */
private const NODE_B = 'system.mcp_key/index';
/** 白名单内但创建者角色未授权的漂移节点(驼峰动作名). */
private const NODE_DRIFT = 'system.auth/toggleUser';
private static ?Client $client = null;
/** @var resource|null */
private static $serverProc = null;
private static bool $serverReady = false;
/** @var array<int, int> */
private static array $createdAdminIds = [];
/** @var array<int, int> */
private static array $createdRoleIds = [];
/** @var array<int, int> */
private static array $createdKeyIds = [];
// =========================================================================
// 服务生命周期 + fixture 清理
// =========================================================================
public static function setUpBeforeClass(): void
{
// 安全护栏:本类不继承 app TestCase无 setUp 校验),自证一次测试库
// tests/bootstrap.php 在更早处已有全局护栏,这里是防御纵深)
$default = (string) Config::get('database.default', 'main');
$dbName = (string) Config::get('database.connections.' . $default . '.database', '');
if ($dbName === '' || stripos($dbName, 'test') === false) {
throw new \RuntimeException('Refusing e2e tests against non-test database: ' . $dbName);
}
self::sweepStaleFixtures();
self::startServer();
}
public static function tearDownAfterClass(): void
{
self::stopServer();
self::cleanupFixtures(self::$createdAdminIds, self::$createdRoleIds, self::$createdKeyIds);
self::$createdAdminIds = [];
self::$createdRoleIds = [];
self::$createdKeyIds = [];
}
protected function setUp(): void
{
if (!self::$serverReady) {
$this->markTestSkipped('e2e HTTP 服务未能启动需在容器内运行php -S + PHP_CLI_SERVER_WORKERS');
}
}
/**
* 清扫上次异常退出残留的 fixture按专有前缀识别幂等.
*
* 前缀 e2mcp_6 字符system_auth.title 是 varchar(20)uniqid() 13 字符,
* 前缀 + uniqid = 19 恰好放得下e2e_mcp_ 前缀 8 字符会超长)。
*/
private static function sweepStaleFixtures(): void
{
$staleRoleIds = Db::name('system_auth')->whereLike('title', 'e2mcp_%')->column('id');
$staleKeyIds = Db::name('system_mcp_key')->whereLike('title', 'e2mcp_%')->column('id');
self::cleanupFixtures(
Db::name('system_admin')->whereLike('username', 'e2mcp_%')->column('id'),
array_map('intval', $staleRoleIds),
array_map('intval', $staleKeyIds)
);
}
/**
* 硬删 fixtureDb::name 直删不触发软删模型;顺序兼顾引用关系).
*
* @param array<int, int> $adminIds
* @param array<int, int> $roleIds
* @param array<int, int> $keyIds
*/
private static function cleanupFixtures(array $adminIds, array $roleIds, array $keyIds): void
{
if ($roleIds !== []) {
Db::name('system_auth_node')->whereIn('auth_id', $roleIds)->delete();
Db::name('system_auth')->whereIn('id', $roleIds)->delete();
}
if ($keyIds !== []) {
Db::name('system_mcp_log')->whereIn('key_id', $keyIds)->delete();
Db::name('system_mcp_key_node')->whereIn('key_id', $keyIds)->delete();
Db::name('system_mcp_key')->whereIn('id', $keyIds)->delete();
}
if ($adminIds !== []) {
Db::name('system_admin')->whereIn('id', $adminIds)->delete();
}
}
private static function startServer(): void
{
if (self::$serverReady) {
return;
}
// 先清扫上次异常退出残留的本端口 php -S自愈避免端口被占导致 skip
self::sweepServerProcesses();
$root = dirname(__DIR__);
$null = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null';
// 入口文件名必须命中 think-multi-app 入口白名单router.php否则应用绑定错乱
// exec 前缀proc_open 在 Unix 经 /bin/sh -c 包装exec 让 sh 自替换为 php
// 保证 proc_terminate 信号直达 php -S master 而不是只杀 sh 包装
$exec = PHP_OS_FAMILY === 'Windows' ? '' : 'exec ';
$cmd = sprintf(
'%sphp -S %s:%d -t %s %s',
$exec,
self::HOST,
self::PORT,
escapeshellarg($root . DIRECTORY_SEPARATOR . 'public'),
escapeshellarg($root . DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'router.php')
);
// PHP_CLI_SERVER_WORKERS>1tools/call 的 /admin 自请求回环需要并发 workerLinux/PHP>=7.4
self::$serverProc = proc_open(
$cmd,
[0 => ['pipe', 'r'], 1 => ['file', $null, 'w'], 2 => ['file', $null, 'w']],
$pipes,
$root,
['PHP_CLI_SERVER_WORKERS' => '4', 'XDEBUG_MODE' => 'off']
);
if (!is_resource(self::$serverProc)) {
return;
}
fclose($pipes[0]);
self::$client = new Client([
'base_uri' => 'http://' . self::HOST . ':' . self::PORT,
'http_errors' => false,
'connect_timeout' => 2,
'timeout' => 30,
]);
// 就绪探测POST /mcp 无认证 -> 期待 401任何 HTTP 响应即视为服务已就绪)
$deadline = microtime(true) + 15.0;
while (microtime(true) < $deadline) {
$status = proc_get_status(self::$serverProc);
if (!$status['running']) {
return; // 进程早退(端口占用等),由 setUp 走 skip
}
try {
$probe = self::$client->post('/mcp', ['json' => []]);
if ($probe->getStatusCode() > 0) {
self::$serverReady = true;
return;
}
} catch (\GuzzleHttp\Exception\ConnectException) {
usleep(200_000);
}
}
}
private static function stopServer(): void
{
if (is_resource(self::$serverProc)) {
proc_terminate(self::$serverProc);
proc_close(self::$serverProc);
self::$serverProc = null;
}
// SIGTERM 只送达到 masterPHP_CLI_SERVER_WORKERS fork 出的 worker 可能成为孤儿
// 继续占住端口,强清扫兜底(幂等,无残留时无操作)
self::sweepServerProcesses();
self::$serverReady = false;
}
/**
* 清扫残留的本端口 php -S 进程(容器最小镜像无 pkill/procps遍历 /proc 匹配 cmdline.
*
* 识别特征命令行同时含「php -S 127.0.0.1:8127」与本项目 tests/router.php
* 不会误杀其它进程;无残留时无操作。
*/
private static function sweepServerProcesses(): void
{
if (PHP_OS_FAMILY === 'Windows' || !is_dir('/proc')) {
return; // 容器Linux路径其它环境靠 proc_terminate 兜底
}
$signature = sprintf('php -S %s:%d', self::HOST, self::PORT);
foreach (glob('/proc/[0-9]*/cmdline') ?: [] as $cmdline) {
$raw = @file_get_contents($cmdline);
if ($raw === false) {
continue;
}
$cmd = str_replace("\0", ' ', $raw);
if (str_contains($cmd, $signature) && str_contains($cmd, 'tests' . DIRECTORY_SEPARATOR . 'router.php')) {
$pid = (int) basename(dirname($cmdline));
if ($pid > 1 && function_exists('posix_kill')) {
@posix_kill($pid, 9); // 尽力而为;失败由端口探测兜底(会走 skip 分支)
}
}
}
}
// =========================================================================
// fixture 工厂提交进测试库tracked + 前缀可清扫;列结构镜像 McpServiceTest
// =========================================================================
/**
* 造一套完整 fixture角色授权 $roleNodes+ 管理员 + 密钥(白名单 $whitelist.
*
* @return array{key_id: int, plaintext: string, admin_id: int, role_id: int}
*/
private function createFixture(
array $roleNodes = [self::NODE_A, self::NODE_B],
array $whitelist = [self::NODE_A, self::NODE_B, self::NODE_DRIFT]
): array {
$uniq = strtolower(uniqid());
$roleId = Db::name('system_auth')->insertGetId([
'title' => 'e2mcp_' . $uniq,
'status' => 2, // system_auth 枚举1=禁用 2=启用
'sort' => 100,
'create_time' => time(),
'update_time' => time(),
'delete_time' => 0,
]);
foreach ($roleNodes as $node) {
Db::name('system_auth_node')->insert(['auth_id' => $roleId, 'node' => $node]);
}
$adminId = Db::name('system_admin')->insertGetId([
'username' => 'e2mcp_admin_' . $uniq,
'password' => 'should-not-leak',
'auth_ids' => (string) $roleId,
'status' => 1,
'create_time' => time(),
'update_time' => time(),
'delete_time' => 0,
]);
$plaintext = 'sk-mcp-e2e-' . bin2hex(random_bytes(16));
$keyId = Db::name('system_mcp_key')->insertGetId([
'title' => 'e2mcp_key_' . $uniq,
'key' => hash('sha256', $plaintext),
'key_prefix' => substr($plaintext, 0, 11),
'bind_admin_id' => $adminId,
'status' => 1,
'remark' => 'e2e fixture',
'use_num' => 0,
'last_use_time' => 0,
'create_time' => time(),
'update_time' => time(),
'delete_time' => 0,
]);
foreach ($whitelist as $node) {
Db::name('system_mcp_key_node')->insert(['key_id' => $keyId, 'node' => $node]);
}
self::$createdRoleIds[] = (int) $roleId;
self::$createdAdminIds[] = (int) $adminId;
self::$createdKeyIds[] = (int) $keyId;
return [
'key_id' => (int) $keyId,
'plaintext' => $plaintext,
'admin_id' => (int) $adminId,
'role_id' => (int) $roleId,
];
}
// =========================================================================
// JSON-RPC 助手
// =========================================================================
/**
* 发送一次 JSON-RPC 请求.
*
* @return array{status: int, body: array, session_id: string, protocol: string, raw: string}
*/
private function rpc(string $method, array $params, ?string $bearer, ?string $sessionId = null, ?string $protocol = null, array $extraHeaders = []): array
{
$payload = [
'jsonrpc' => '2.0',
'id' => random_int(1, 1000000),
'method' => $method,
'params' => $params ?: new \stdClass(),
];
$headers = ['Accept' => 'application/json'] + $extraHeaders;
if ($bearer !== null) {
$headers['Authorization'] = 'Bearer ' . $bearer;
}
if ($sessionId !== null) {
$headers['Mcp-Session-Id'] = $sessionId;
}
if ($protocol !== null) {
$headers['Mcp-Protocol-Version'] = $protocol;
}
$response = self::$client->post('/mcp', ['headers' => $headers, 'json' => $payload]);
$raw = (string) $response->getBody();
$body = json_decode($raw, true);
return [
'status' => $response->getStatusCode(),
'body' => is_array($body) ? $body : [],
'session_id' => (string) ($response->getHeader('Mcp-Session-Id')[0] ?? ''),
'protocol' => (string) ($response->getHeader('Mcp-Protocol-Version')[0] ?? ''),
'raw' => $raw,
];
}
/**
* initialize 并返回会话上下文 [session_id, protocol, body].
*
* @return array{0: string, 1: string, 2: array}
*/
private function initialize(string $bearer, array $extraHeaders = []): array
{
$res = $this->rpc('initialize', [
'protocolVersion' => '2025-06-18',
'capabilities' => new \stdClass(),
'clientInfo' => ['name' => 'ulthon-e2e-test', 'version' => '1.0'],
], $bearer, null, null, $extraHeaders);
$this->assertSame(200, $res['status'], 'initialize 应 200body: ' . $res['raw']);
$this->assertNotSame('', $res['session_id'], 'initialize 必须返回 Mcp-Session-Id 头');
$protocol = $res['protocol'] !== ''
? $res['protocol']
: (string) ($res['body']['result']['protocolVersion'] ?? '2025-06-18');
return [$res['session_id'], $protocol, $res['body']];
}
// =========================================================================
// 1. initialize成功 / 401 / 403
// =========================================================================
public function test_initialize_returns_200_with_session_and_server_info(): void
{
$fx = $this->createFixture();
$res = $this->rpc('initialize', [
'protocolVersion' => '2025-06-18',
'capabilities' => new \stdClass(),
'clientInfo' => ['name' => 'ulthon-e2e-test', 'version' => '1.0'],
], $fx['plaintext']);
$this->assertSame(200, $res['status'], 'body: ' . $res['raw']);
$this->assertNotSame('', $res['session_id'], '缺少 Mcp-Session-Id 响应头');
$result = $res['body']['result'] ?? null;
$this->assertIsArray($result, '缺少 resultbody: ' . $res['raw']);
$this->assertNotSame('', (string) ($result['protocolVersion'] ?? ''), '缺少 protocolVersion');
$this->assertSame('ulthon_admin', $result['serverInfo']['name'] ?? '', 'serverInfo.name 应为配置默认值');
$this->assertNotSame('', (string) ($result['serverInfo']['version'] ?? ''), '缺少 serverInfo.version');
$this->assertArrayHasKey('capabilities', $result, '缺少 capabilities有授权工具时必须出现 tools 能力)');
$this->assertArrayHasKey('tools', $result['capabilities'], 'capabilities 应含 toolsfixture 已授权 2 个工具)');
}
public function test_initialize_rejects_missing_or_invalid_bearer_with_401(): void
{
// 缺失 Authorization
$missing = $this->rpc('initialize', ['protocolVersion' => '2025-06-18'], null);
$this->assertSame(401, $missing['status'], 'body: ' . $missing['raw']);
$this->assertSame(401, $missing['body']['code'] ?? null, 'body: ' . $missing['raw']);
$this->assertStringContainsString('无效的 MCP 密钥', (string) ($missing['body']['msg'] ?? ''), 'body: ' . $missing['raw']);
// 错误密钥
$this->createFixture(); // 确保库里有合法密钥存在(认证逻辑真实跑过一次比对)
$invalid = $this->rpc('initialize', ['protocolVersion' => '2025-06-18'], 'sk-mcp-e2e-wrong-bearer');
$this->assertSame(401, $invalid['status'], 'body: ' . $invalid['raw']);
$this->assertStringContainsString('无效的 MCP 密钥', (string) ($invalid['body']['msg'] ?? ''), 'body: ' . $invalid['raw']);
}
public function test_disabled_endpoint_returns_403(): void
{
// mcp.enable=false 由 tests/router.php 识别 X-E2E-Mcp-Disabled 头注入(生产代码零改动)
$fx = $this->createFixture();
$res = $this->rpc('initialize', ['protocolVersion' => '2025-06-18'], $fx['plaintext'], null, null, [
'X-E2E-Mcp-Disabled' => '1',
]);
$this->assertSame(403, $res['status'], 'body: ' . $res['raw']);
$this->assertSame('MCP 服务未启用', (string) ($res['body']['msg'] ?? ''), 'body: ' . $res['raw']);
}
// =========================================================================
// 2. tools/list与授权集一致
// =========================================================================
public function test_tools_list_matches_authorized_set(): void
{
$fx = $this->createFixture();
[$sessionId, $protocol] = $this->initialize($fx['plaintext']);
$res = $this->rpc('tools/list', [], $fx['plaintext'], $sessionId, $protocol);
$this->assertSame(200, $res['status'], 'body: ' . $res['raw']);
$this->assertArrayNotHasKey('error', $res['body'], 'body: ' . $res['raw']);
$tools = $res['body']['result']['tools'] ?? null;
$this->assertIsArray($tools, 'body: ' . $res['raw']);
$names = array_column($tools, 'name');
// 白名单 [quick/index, mcp_key/index, auth/toggleUser] ∩ 创建者角色(前两者)
// —— 漂移节点 auth/toggleUser 未注册;顺序保持白名单顺序
$this->assertSame(
['system-quick--index', 'system-mcp_key--index'],
$names,
'工具集应等于授权集(编码名),漂移节点不得出现: ' . $res['raw']
);
foreach ($names as $name) {
$this->assertMatchesRegularExpression('/^[a-zA-Z0-9_-]{1,64}$/', $name);
}
// 描述里携带原始节点串(工具可读性契约)
$this->assertStringContainsString('【' . self::NODE_A . '】', (string) ($tools[0]['description'] ?? ''));
}
// =========================================================================
// 3. tools/call成功code=0 信封)/ 越权SDK -32601/ 审计
// =========================================================================
public function test_tools_call_success_returns_code0_envelope_and_audits(): void
{
$fx = $this->createFixture();
[$sessionId, $protocol] = $this->initialize($fx['plaintext']);
$res = $this->rpc('tools/call', [
'name' => 'system-quick--index',
'arguments' => new \stdClass(),
], $fx['plaintext'], $sessionId, $protocol);
$this->assertSame(200, $res['status'], 'body: ' . $res['raw']);
$this->assertArrayNotHasKey('error', $res['body'], 'body: ' . $res['raw']);
$result = $res['body']['result'] ?? [];
$this->assertFalse((bool) ($result['isError'] ?? true), 'isError 应为 false: ' . $res['raw']);
$text = (string) ($result['content'][0]['text'] ?? '');
$this->assertNotSame('', $text, 'content[0].text 不应为空');
// 信封判成功text 本身是 JSON 且 code=0任务验收口径
$decoded = json_decode($text, true);
$this->assertIsArray($decoded, 'content[0].text 应为 JSON: ' . $text);
$this->assertSame(0, $decoded['code'] ?? null, '信封 code 应为 0: ' . substr($text, 0, 300));
// 审计行:成功调用落 is_success=1 日志(服务端进程提交,响应返回后即可见)
$this->assertSame(1, (int) Db::name('system_mcp_log')
->where('key_id', $fx['key_id'])
->where('node', self::NODE_A)
->where('is_success', 1)
->count(), '应存在成功审计行');
// use_num 自增
$this->assertGreaterThanOrEqual(1, (int) Db::name('system_mcp_key')
->where('id', $fx['key_id'])->value('use_num'));
}
public function test_tools_call_unauthorized_tool_rejected_by_sdk_32601(): void
{
$fx = $this->createFixture();
[$sessionId, $protocol] = $this->initialize($fx['plaintext']);
// 漂移节点在白名单内但创建者无权限 -> 未注册为工具 -> SDK 层第一道防线 -32601
$drifted = $this->rpc('tools/call', [
'name' => 'system-auth--toggleUser',
'arguments' => new \stdClass(),
], $fx['plaintext'], $sessionId, $protocol);
$this->assertSame(200, $drifted['status'], 'JSON-RPC 错误仍应 HTTP 200, body: ' . $drifted['raw']);
$this->assertSame(-32601, $drifted['body']['error']['code'] ?? null, 'SDK 应以 Tool not found 拒绝: ' . $drifted['raw']);
// 完全乱造的工具名同样 -32601
$garbage = $this->rpc('tools/call', [
'name' => 'totally_unknown_tool',
'arguments' => new \stdClass(),
], $fx['plaintext'], $sessionId, $protocol);
$this->assertSame(-32601, $garbage['body']['error']['code'] ?? null, '乱造工具名应以 -32601 拒绝: ' . $garbage['raw']);
}
public function test_dispatch_layer_rejects_and_audits_unauthorized_call(): void
{
// 纵深防御第二道防线SDK 层之外dispatch 对「不可还原为授权节点」的工具名
// 直接拒绝并落 is_success=0 审计(进程内直调,不经 HTTP
$fx = $this->createFixture();
$auth = (new McpService())->authenticate($fx['plaintext']);
$this->assertNotNull($auth);
$text = (new McpDispatch())->call($auth, 'totally_unknown_tool', ['probe' => 1]);
$this->assertStringStartsWith('[ERROR]', $text);
$this->assertStringContainsString('无权限或权限已回收', $text);
$this->assertSame(1, (int) Db::name('system_mcp_log')
->where('key_id', $fx['key_id'])
->where('node', 'totally_unknown_tool')
->where('is_success', 0)
->count(), '越权调用应落失败审计行');
}
}