This commit is contained in:
gr
2026-09-24 09:45:44 +08:00
parent dbf474ddd7
commit bd22e5f476
38 changed files with 19152 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/**
* AI 助手(MCP)只读保护测试:以账号身份进程内调用接口时,任何写库都必须失败并回滚,调用结束后请求上下文和会话恢复原状。
* 需要一次性测试库(库名以 _test 结尾,已执行 2026_09_24_ai_mcp.sql),通过 PHP_DATABASE_* 环境变量指定:
* AI_MCP_TEST_MYSQL=1 php server/tests/AiMcpReadOnlyTest.php
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\model\auth\Admin;
use app\mcp\service\Dispatcher;
use app\mcp\service\Identity;
use think\App;
use think\facade\Db;
function aiMcpReadOnlyExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
if (getenv('AI_MCP_TEST_MYSQL') !== '1') {
echo "AiMcpReadOnlyTest SKIP (set AI_MCP_TEST_MYSQL=1 and point PHP_DATABASE_* at a disposable *_test database)\n";
exit(0);
}
$app = new App(dirname(__DIR__) . DIRECTORY_SEPARATOR);
$app->initialize();
$database = (string) config('database.connections.' . config('database.default') . '.database');
aiMcpReadOnlyExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)");
class AiMcpProbeController extends \app\BaseController
{
public function write()
{
Db::name('ai_access_log')->insert(['tool' => 'probe-write', 'create_time' => time()]);
return json(['code' => 1, 'show' => 0, 'msg' => '', 'data' => []]);
}
public function nested()
{
Db::startTrans();
Db::name('ai_access_log')->insert(['tool' => 'probe-nested', 'create_time' => time()]);
Db::commit();
return json(['code' => 1, 'show' => 0, 'msg' => '', 'data' => []]);
}
public function echo()
{
return json(['code' => 1, 'show' => 0, 'msg' => '', 'data' => [
'params' => $this->request->param(),
'post' => $this->request->post(),
'method' => $this->request->method(),
'admin_id' => $this->request->adminId,
'root' => $this->request->adminInfo['root'] ?? null,
'controller' => $this->request->controller(),
'namespace' => app()->getNamespace(),
'authorization' => (string) $this->request->header('authorization', ''),
]]);
}
}
$admin = Admin::order('id', 'asc')->findOrEmpty();
aiMcpReadOnlyExpect(!$admin->isEmpty(), 'test database needs at least one admin row');
$identity = new Identity(['id' => 0, 'expire_time' => time() + 600], $admin);
$resource = static fn (string $action) => ['key' => 'probe.test/' . $action, 'controller' => AiMcpProbeController::class, 'http' => 'GET'];
$original = $app->request;
$namespace = $app->getNamespace();
$result = Dispatcher::call($identity, $resource('write'), []);
aiMcpReadOnlyExpect($result['code'] === 0 && str_contains($result['msg'], '只读保护'), 'a write inside an AI call is blocked: ' . json_encode($result, JSON_UNESCAPED_UNICODE));
aiMcpReadOnlyExpect(Db::name('ai_access_log')->where('tool', 'probe-write')->count() === 0, 'blocked write left no row');
$result = Dispatcher::call($identity, $resource('nested'), []);
aiMcpReadOnlyExpect($result['code'] === 0, 'a write inside a nested transaction is blocked too');
aiMcpReadOnlyExpect(Db::name('ai_access_log')->where('tool', 'probe-nested')->count() === 0, 'nested blocked write left no row');
$result = Dispatcher::call($identity, $resource('echo'), ['keyword' => '刘', 'page_no' => 1]);
aiMcpReadOnlyExpect($result['code'] === 1, 'read-only call succeeds');
$data = $result['data'];
aiMcpReadOnlyExpect($data['params'] == ['keyword' => '刘', 'page_no' => '1'], 'controller sees exactly the whitelisted params (strings after the Request trim filter): ' . json_encode($data['params'], JSON_UNESCAPED_UNICODE));
aiMcpReadOnlyExpect($data['post'] === [] && $data['method'] === 'GET', 'synthetic request is a clean GET');
aiMcpReadOnlyExpect((int) $data['admin_id'] === (int) $admin['id'], 'controller sees the bound account');
aiMcpReadOnlyExpect($data['controller'] === 'probe.test' && $data['namespace'] === 'app\\adminapi', 'controller context mirrors adminapi');
aiMcpReadOnlyExpect($data['authorization'] === '', 'the MCP bearer token is not forwarded to business code');
aiMcpReadOnlyExpect($app->request === $original, 'original request restored');
aiMcpReadOnlyExpect($app->getNamespace() === $namespace, 'app namespace restored');
$pdo = Db::connect()->getPdo();
aiMcpReadOnlyExpect(!$pdo->inTransaction(), 'no transaction left open');
$id = Db::name('ai_access_log')->insertGetId(['tool' => 'probe-after', 'create_time' => time()]);
aiMcpReadOnlyExpect($id > 0, 'session is writable again after the AI call');
Db::name('ai_access_log')->where('id', $id)->delete();
echo "AiMcpReadOnlyTest OK\n";