*/ 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(), '越权调用应落失败审计行'); } }