From bba7dec2784af2223195f508c87ef725b7b661aa Mon Sep 17 00:00:00 2001 From: augushong Date: Sun, 16 Aug 2026 23:33:39 +0800 Subject: [PATCH] =?UTF-8?q?test(mcp):=20MCP=20=E7=AB=AF=E7=82=B9=E3=80=81C?= =?UTF-8?q?SRF=20=E8=B1=81=E5=85=8D=E4=B8=8E=E5=9B=9E=E5=BD=92=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phpunit.xml.dist | 5 + tests/McpCsrfExemptionTest.php | 275 ++++++++++++++++ tests/McpEndpointTest.php | 560 +++++++++++++++++++++++++++++++++ tests/router.php | 51 +++ 4 files changed, 891 insertions(+) create mode 100644 tests/McpCsrfExemptionTest.php create mode 100644 tests/McpEndpointTest.php create mode 100644 tests/router.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 56fdb34..61d4e01 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -13,5 +13,10 @@ tests/Unit + + tests/McpServiceTest.php + tests/McpCsrfExemptionTest.php + tests/McpEndpointTest.php + diff --git a/tests/McpCsrfExemptionTest.php b/tests/McpCsrfExemptionTest.php new file mode 100644 index 0000000..e4e79ef --- /dev/null +++ b/tests/McpCsrfExemptionTest.php @@ -0,0 +1,275 @@ + 本测试写入 login 缓存的 token(tearDown 清理) */ + private array $cacheTokens = []; + + private mixed $origIsCsrf = null; + + protected function setUp(): void + { + // 注入 IS_CSRF=true(方案见类注释);先存原值 + $env = app('env'); + $this->origIsCsrf = $env->get('adminsystem.IS_CSRF'); + $env->set('adminsystem.IS_CSRF', true); + + // 注入必须真实生效,否则后续断言测的是「开关关闭全放行」的空路径 + $this->assertTrue( + env('adminsystem.IS_CSRF', false), + 'IS_CSRF 注入未生效(Env::set 应压过 .env 载入值),后续拦截断言无意义' + ); + } + + protected function tearDown(): void + { + // 恢复原值(.env 默认 false),避免污染同进程后续测试 + app('env')->set('adminsystem.IS_CSRF', $this->origIsCsrf === null ? false : $this->origIsCsrf); + + foreach ($this->cacheTokens as $token) { + Cache::store('login')->delete($token); + } + $this->cacheTokens = []; + } + + // ========================================================================= + // 构造工具 + // ========================================================================= + + /** + * 构造 POST 请求(可选 Bearer / referer). + */ + private function makePostRequest(?string $bearer, string $referer): Request + { + $server = [ + 'REQUEST_METHOD' => 'POST', + 'HTTP_HOST' => self::HOST_NAME, + 'SERVER_NAME' => self::HOST_NAME, + 'SERVER_PORT' => '80', + 'REQUEST_URI' => '/admin/system.quick/index', + 'QUERY_STRING' => '', + ]; + if ($referer !== '') { + $server['HTTP_REFERER'] = $referer; + } + + $headers = []; + if ($bearer !== null) { + $server['HTTP_AUTHORIZATION'] = 'Bearer ' . $bearer; + $headers['authorization'] = 'Bearer ' . $bearer; + } + if ($referer !== '') { + $headers['referer'] = $referer; + } + + $request = new Request(); + $request + ->withServer($server) + ->withHeader($headers) + ->withSession(new \think\Session(app())); + + return $request; + } + + /** + * 构造 GET 请求(方法白名单应直接放行). + */ + private function makeGetRequest(): Request + { + $request = $this->makePostRequest(null, ''); + $request->withServer([ + 'REQUEST_METHOD' => 'GET', + 'HTTP_HOST' => self::HOST_NAME, + 'SERVER_NAME' => self::HOST_NAME, + 'SERVER_PORT' => '80', + 'REQUEST_URI' => '/admin/system.quick/index', + 'QUERY_STRING' => '', + ]); + + return $request; + } + + /** + * 预置一条 login 缓存 token(模拟服务端写入的会话/内部分发 token). + */ + private function cacheToken(array $payload): string + { + $token = 'csrf_e2e_' . bin2hex(random_bytes(8)); + Cache::store('login')->set($token, $payload, 60); + $this->cacheTokens[] = $token; + + return $token; + } + + /** + * 以「中间件的真实运行姿势」执行:把请求绑定进容器(JumpTrait error() 经 + * request() 判定响应类型),finally 恢复原容器请求. + * + * @return mixed next 闭包的返回值(哨兵) + */ + private function runMiddleware(Request $request, \Closure $next): mixed + { + $app = app(); + $origRequest = $app->request; + $app->instance('request', $request); + + try { + return (new CsrfMiddleware())->handle($request, $next); + } finally { + $app->instance('request', $origRequest); + } + } + + // ========================================================================= + // 豁免:带 mcp_internal 标记 token + // ========================================================================= + + public function test_mcp_internal_token_bypasses_csrf(): void + { + // McpDispatch 生成的一次性 creator token 形态(mcp_internal 标记是唯一豁免依据) + $token = $this->cacheToken([ + 'id' => 12345, + 'username' => 'e2e_creator', + 'expire_time' => time() + 300, + 'mcp_internal' => true, + ]); + + $result = $this->runMiddleware( + $this->makePostRequest($token, self::SAME_ORIGIN_REFERER), + fn (Request $r) => 'NEXT_CALLED' + ); + + $this->assertSame('NEXT_CALLED', $result, '带 mcp_internal 标记的 token 必须放行(next 被调用)'); + } + + // ========================================================================= + // 不豁免:无标记 token / MCP 密钥明文 / 无 Authorization + // ========================================================================= + + public function test_plain_login_token_without_marker_is_blocked(): void + { + // 普通登录 token(缓存存在但无 mcp_internal 标记):同源 referer + 无 __token__ -> 拦截 + $token = $this->cacheToken([ + 'id' => 12345, + 'username' => 'e2e_user', + 'expire_time' => time() + 300, + ]); + + try { + $this->runMiddleware( + $this->makePostRequest($token, self::SAME_ORIGIN_REFERER), + fn (Request $r) => 'NEXT_CALLED' + ); + $this->fail('无 mcp_internal 标记的 token 应被 CSRF 拦截'); + } catch (HttpResponseException $e) { + $data = $e->getResponse()->getData(); + $this->assertSame('请求验证失败,请重新刷新页面!', $data['msg'] ?? null, '应命中 __token__ 校验失败文案'); + // error() 信封 code=500;json() 助手默认 HTTP 200,两者语义不同 + $this->assertSame(500, $data['code'] ?? null); + } + } + + public function test_mcp_key_plaintext_bearer_is_not_exempt(): void + { + // MCP 密钥明文不是 login 缓存 token(缓存查不到)——豁免只认服务端写入的 + // mcp_internal 标记,密钥 Bearer 不构成豁免通道(T8 实测结论的回归锁) + $bearer = 'sk-mcp-e2e-' . bin2hex(random_bytes(8)); // 不预置任何缓存 + + try { + $this->runMiddleware( + $this->makePostRequest($bearer, self::SAME_ORIGIN_REFERER), + fn (Request $r) => 'NEXT_CALLED' + ); + $this->fail('MCP 密钥明文 Bearer 不应享受 CSRF 豁免'); + } catch (HttpResponseException $e) { + $data = $e->getResponse()->getData(); + $this->assertSame('请求验证失败,请重新刷新页面!', $data['msg'] ?? null); + } + } + + public function test_cross_origin_post_without_marker_is_blocked(): void + { + $token = $this->cacheToken(['id' => 12345, 'mcp_internal' => false]); + + try { + $this->runMiddleware( + $this->makePostRequest($token, self::CROSS_ORIGIN_REFERER), + fn (Request $r) => 'NEXT_CALLED' + ); + $this->fail('外域 referer 的 POST 应被跨站校验拦截'); + } catch (HttpResponseException $e) { + $data = $e->getResponse()->getData(); + $this->assertSame('当前请求不合法!', $data['msg'] ?? null, '应命中跨域校验文案'); + } + } + + // ========================================================================= + // 开关语义:IS_CSRF=false 全放行 / GET 方法白名单 + // ========================================================================= + + public function test_is_csrf_disabled_passes_all_posts(): void + { + // 生产默认(.env IS_CSRF=false):开关关闭时即使无 token 无 referer 也放行 + app('env')->set('adminsystem.IS_CSRF', false); + + $result = $this->runMiddleware( + $this->makePostRequest(null, ''), + fn (Request $r) => 'NEXT_CALLED' + ); + + $this->assertSame('NEXT_CALLED', $result, 'IS_CSRF=false 时不应有任何拦截'); + } + + public function test_get_request_bypasses_csrf_even_when_enabled(): void + { + $result = $this->runMiddleware( + $this->makeGetRequest(), + fn (Request $r) => 'NEXT_CALLED' + ); + + $this->assertSame('NEXT_CALLED', $result, 'GET 在方法白名单内,IS_CSRF=true 也不拦截'); + } +} diff --git a/tests/McpEndpointTest.php b/tests/McpEndpointTest.php new file mode 100644 index 0000000..b940f72 --- /dev/null +++ b/tests/McpEndpointTest.php @@ -0,0 +1,560 @@ + */ + private static array $createdAdminIds = []; + + /** @var array */ + private static array $createdRoleIds = []; + + /** @var array */ + 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) + ); + } + + /** + * 硬删 fixture(Db::name 直删不触发软删模型;顺序兼顾引用关系). + * + * @param array $adminIds + * @param array $roleIds + * @param array $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>1:tools/call 的 /admin 自请求回环需要并发 worker(Linux/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 只送达到 master:PHP_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 应 200,body: ' . $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, '缺少 result,body: ' . $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 应含 tools(fixture 已授权 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(), '越权调用应落失败审计行'); + } +} diff --git a/tests/router.php b/tests/router.php new file mode 100644 index 0000000..ca6b7ed --- /dev/null +++ b/tests/router.php @@ -0,0 +1,51 @@ + config mcp.enable=false(覆盖 403 分支; + * mcp.enable 在控制器内经 config() 读取,e2e 期望它默认 true, + * 又不能从 PHPUnit 进程改另一个进程的 Config,故由本入口代劳)。 + * + * 自请求并发说明:tools/call 分发会从本服务回环 POST /admin/*(McpDispatchBase), + * php -S 默认单 worker 会死锁,必须 PHP_CLI_SERVER_WORKERS>1(Linux,PHP>=7.4)。 + */ + +declare(strict_types=1); + +// 复用 PHPUnit 引导:autoload + App 初始化 + config 级覆盖到测试库 + 非 test 库护栏。 +// bootstrap.php 是无 PHPUnit 依赖的纯脚本(抛 RuntimeException 拒绝非测试库), +// 这里 require 后 $app 即已就绪的容器实例。 +require __DIR__ . '/bootstrap.php'; + +// 403 分支模拟(仅测试头,见文件头注释) +if (($_SERVER['HTTP_X_E2E_MCP_DISABLED'] ?? '') === '1') { + \think\facade\Config::set(['enable' => false], 'mcp'); +} + +// 标准 HTTP 内核(与 public/index.php 同构;initialize 幂等,config 覆盖不会被冲掉) +$http = $app->http; + +$response = $http->run(); + +$response->send(); + +$http->end($response);