test(mcp): MCP 端点、CSRF 豁免与回归全量测试

This commit is contained in:
augushong
2026-08-16 23:33:39 +08:00
parent adc10d1157
commit bba7dec278
4 changed files with 891 additions and 0 deletions

View File

@@ -13,5 +13,10 @@
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="mcp">
<file>tests/McpServiceTest.php</file>
<file>tests/McpCsrfExemptionTest.php</file>
<file>tests/McpEndpointTest.php</file>
</testsuite>
</testsuites>
</phpunit>

View File

@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace tests;
use app\admin\middleware\CsrfMiddleware;
use PHPUnit\Framework\TestCase;
use think\exception\HttpResponseException;
use think\facade\Cache;
use think\Request;
/**
* CsrfMiddleware 的 MCP 内部分发豁免单元测试mcp_internal 标记 token.
*
* ===== IS_CSRF=true 注入方案与理由(重要)=====
* CsrfMiddlewareBase 读 env('adminsystem.IS_CSRF', true)——即 think\Env 实例的
* $data['ADMINSYSTEM_IS_CSRF']。本项目 .env [ADMINSYSTEM] 段显式 IS_CSRF=false
* 不注入则豁免分支外的一切拦截逻辑根本不会被执行。
*
* 曾评估并放弃的方案:
* - putenv / $_ENVthink\Env 的 get() 先查 $data.env 已把键载入 $data
* OS 环境变量只是 $data 缺键时的 fallback——对已存在键无效learnings Task 1 结论)
* - 临时改容器 .env 再还原sync_env 每 3s 从宿主单向覆盖,时序不可控且污染共享栈
*
* 采用方案think\Env::set('adminsystem.IS_CSRF', true) 直接写 $data
* Env::set 是 public API键名规范化与 get() 完全一致),对同进程内的
* env() 读取立即生效tearDown 恢复原值。本测试进程与被测中间件同一容器进程,
* 这是唯一可靠、无副作用、无时序竞争的注入点。
*
* ===== 中间件调用方式 =====
* 直接 new CsrfMiddlewareapp 层空壳)+ 构造 think\RequestwithServer/withHeader
* 模拟 POST + Authorization 头并把构造请求绑定进容器JumpTrait 的 error()
* 经 request() 取当前请求判定响应类型,生产行为中中间件永远跑在容器当前请求上)。
* login 缓存预置带/不带 mcp_internal 标记的 token断言 next 放行(豁免)与
* HttpResponseException 拦截(错误文案)。
*
* 无 DB 写入(只读/写 login 文件缓存),无需事务隔离,不继承 app TestCase。
*/
class McpCsrfExemptionTest extends TestCase
{
private const HOST_NAME = '127.0.0.1';
private const SAME_ORIGIN_REFERER = 'http://127.0.0.1/admin/system.quick/index';
private const CROSS_ORIGIN_REFERER = 'http://evil.example/attack.html';
/** @var array<int, string> 本测试写入 login 缓存的 tokentearDown 清理) */
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=500json() 助手默认 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 也不拦截');
}
}

560
tests/McpEndpointTest.php Normal file
View File

@@ -0,0 +1,560 @@
<?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(), '越权调用应落失败审计行');
}
}

51
tests/router.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
/**
* MCP e2e 专用 HTTP 入口php -S 路由脚本,仅测试使用,绝不能被生产引用).
*
* 用法(容器内,由 tests/McpEndpointTest::setUpBeforeClass 自动拉起):
* PHP_CLI_SERVER_WORKERS=4 php -S 127.0.0.1:8127 -t public tests/router.php
*
* 文件名必须是 router.php关键约束
* php -S + 路由脚本时 $_SERVER['SCRIPT_FILENAME'] 指向本文件,
* think-multi-app 的 MultiApp::getScriptName() 用它的文件名做「入口绑定应用」
* 判定——白名单仅 ['index','router','think'] 豁免;叫别的名字(如 e2e_server
* 会被当成应用名绑定pathinfo 首段不再做应用识别,/admin/* 全部 404。
*
* 为什么不用容器 nginx127.0.0.1:8000
* nginx/fpm 栈读 .env 指向开发库ulthon而 e2e 断言要求 fixture 自包含
* 且严格落在测试库ulthon_testulthon-testing 技能铁律 + 任务 MUST NOT
* 事务内的 fixture 对独立 HTTP 连接永远不可见,因此 e2e 只能:
* a) 把 fixture 提交进开发库(违反测试库/开发库分离,禁止);或
* b) 起一个与 PHPUnit 同样做 config 级测试库覆盖的独立 HTTP 服务(本方案)。
* 本入口与 tests/bootstrap.php 共享同一套覆盖逻辑require 复用,防漂移),
* 走完整 HTTP 内核(多应用路由 / 中间件 / Session是真实 HTTP 栈 e2e。
*
* 测试专用请求头(生产代码零改动的注入点):
* X-E2E-Mcp-Disabled: 1 -> config mcp.enable=false覆盖 403 分支;
* mcp.enable 在控制器内经 config() 读取e2e 期望它默认 true
* 又不能从 PHPUnit 进程改另一个进程的 Config故由本入口代劳
*
* 自请求并发说明tools/call 分发会从本服务回环 POST /admin/*McpDispatchBase
* php -S 默认单 worker 会死锁,必须 PHP_CLI_SERVER_WORKERS>1LinuxPHP>=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);