102 lines
4.7 KiB
PHP
102 lines
4.7 KiB
PHP
<?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";
|