更新
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* AI 后台浏览器会话(/mcp/console/open|close)测试:权限点门禁、专用终端(terminal=8)不影响电脑端登录、
|
||||
* 换来的令牌能直接访问后台(按浏览器自己的 IP 建立登录缓存)、复用、关闭即失效、撤销 AI 授权时一并作废、
|
||||
* 授权失效或账号失去 ai.mcp/access 后也能收回、审计。
|
||||
*
|
||||
* 需要一次性测试库(库名以 _test 结尾,已执行 2026_09_24_ai_mcp.sql 和 2026_09_24_ai_mcp_console.sql)和指向它的运行实例:
|
||||
* AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpConsoleTest.php
|
||||
* 夹具 ID 段:账号 93001-93002、角色 293-294。
|
||||
*/
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use think\App;
|
||||
use think\facade\Db;
|
||||
|
||||
function aiMcpConsoleExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
fwrite(STDERR, "FAIL: {$message}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$base = rtrim((string) getenv('AI_MCP_TEST_BASE_URL'), '/');
|
||||
if (getenv('AI_MCP_TEST_MYSQL') !== '1' || $base === '') {
|
||||
echo "AiMcpConsoleTest SKIP (set AI_MCP_TEST_MYSQL=1, AI_MCP_TEST_BASE_URL and PHP_DATABASE_* for 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');
|
||||
aiMcpConsoleExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)");
|
||||
aiMcpConsoleExpect((int) Db::name('system_menu')->where('perms', 'ai.mcp/console')->count() === 1, 'run 2026_09_24_ai_mcp_console.sql on the test database first');
|
||||
|
||||
// ---------------------------------------------------------------- 夹具
|
||||
$now = time();
|
||||
$pwd = create_password('Test@123456', (string) config('project.unique_identification'));
|
||||
Db::name('system_role')->whereIn('id', [293, 294])->delete();
|
||||
foreach ([293 => '仅 AI 查询', 294 => 'AI 查询 + 后台浏览器'] as $id => $name) {
|
||||
Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-console-test', 'sort' => 0, 'data_scope' => 4, 'create_time' => $now, 'update_time' => $now]);
|
||||
}
|
||||
Db::name('system_role_menu')->whereIn('role_id', [293, 294])->delete();
|
||||
$menu = static fn (string $perm): int => (int) Db::name('system_menu')->where('perms', $perm)->value('id');
|
||||
foreach ([293 => ['ai.mcp/access'], 294 => ['ai.mcp/access', 'ai.mcp/console']] as $role => $perms) {
|
||||
foreach ($perms as $perm) {
|
||||
Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $menu($perm)]);
|
||||
}
|
||||
}
|
||||
$admins = [93001 => ['c_plain', 293], 93002 => ['c_console', 294]];
|
||||
Db::name('admin')->whereIn('id', array_keys($admins))->delete();
|
||||
Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete();
|
||||
Db::name('admin_session')->whereIn('admin_id', array_keys($admins))->delete();
|
||||
Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete();
|
||||
Db::name('ai_access_log')->whereIn('admin_id', array_keys($admins))->delete();
|
||||
foreach ($admins as $id => [$account, $role]) {
|
||||
// 关闭“多处登录”:若 AI 浏览器用的是电脑端终端,就会把下面这条电脑端会话挤掉
|
||||
Db::name('admin')->insert(['id' => $id, 'root' => 0, 'name' => $account, 'avatar' => '', 'account' => $account, 'password' => $pwd, 'multipoint_login' => 0,
|
||||
'is_paw' => 1, 'work_wechat_userid' => '', 'disable' => 0, 'phone' => '1360000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]);
|
||||
Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $role]);
|
||||
}
|
||||
$pcToken = substr(md5('console-test-pc-' . $now), 0, 32);
|
||||
Db::name('admin_session')->insert(['admin_id' => 93002, 'terminal' => 1, 'token' => $pcToken, 'update_time' => $now, 'expire_time' => $now + 3600]);
|
||||
\think\facade\Cache::clear();
|
||||
|
||||
// ---------------------------------------------------------------- HTTP 工具
|
||||
function aiMcpConsoleHttp(string $method, string $url, ?array $body, array $headers): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$lines = ['Content-Type: application/json'];
|
||||
foreach ($headers as $k => $v) {
|
||||
$lines[] = $k . ': ' . $v;
|
||||
}
|
||||
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_TIMEOUT => 60]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
$raw = (string) curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
return [$status, json_decode($raw, true)];
|
||||
}
|
||||
|
||||
$grant = static function (string $account) use ($base): string {
|
||||
[, $body] = aiMcpConsoleHttp('POST', $base . '/mcp/auth/grant', ['account' => $account, 'password' => 'Test@123456', 'client' => 'xingzhi', 'client_instance' => 'console-test'], []);
|
||||
aiMcpConsoleExpect(($body['code'] ?? null) === 1, "grant for {$account}: " . json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
return (string) $body['data']['token'];
|
||||
};
|
||||
$console = static function (string $action, string $grantToken) use ($base): array {
|
||||
[, $body] = aiMcpConsoleHttp('POST', $base . '/mcp/console/' . $action, [], ['Authorization' => 'Bearer ' . $grantToken, 'X-Xingzhi-Task-Id' => 'console-task']);
|
||||
return (array) $body;
|
||||
};
|
||||
$backOffice = static function (string $sessionToken) use ($base): array {
|
||||
[, $body] = aiMcpConsoleHttp('GET', $base . '/adminapi/auth.admin/mySelf', null, ['token' => $sessionToken]);
|
||||
return (array) $body;
|
||||
};
|
||||
$session = static fn (int $adminId, int $terminal) => Db::name('admin_session')->where(['admin_id' => $adminId, 'terminal' => $terminal])->find();
|
||||
|
||||
// ---------------------------------------------------------------- 门禁
|
||||
[$status] = aiMcpConsoleHttp('GET', $base . '/mcp/console/open', null, []);
|
||||
aiMcpConsoleExpect($status === 405, 'console endpoints are POST only');
|
||||
$body = $console('open', 'zyt_ai_' . str_repeat('0', 64));
|
||||
aiMcpConsoleExpect(($body['code'] ?? null) === -1, 'an invalid AI grant cannot open a console session');
|
||||
$plain = $grant('c_plain');
|
||||
$body = $console('open', $plain);
|
||||
aiMcpConsoleExpect(($body['code'] ?? null) === 0 && ($body['data']['reason'] ?? '') === 'no_console_permission', 'accounts without ai.mcp/console are refused: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
aiMcpConsoleExpect($session(93001, 8) === null, 'no session is created when refused');
|
||||
|
||||
// ---------------------------------------------------------------- 签发与使用
|
||||
$grantToken = $grant('c_console');
|
||||
$body = $console('open', $grantToken);
|
||||
aiMcpConsoleExpect(($body['code'] ?? null) === 1 && strlen((string) ($body['data']['token'] ?? '')) === 32 && ($body['data']['terminal'] ?? 0) === 8, 'console session issued: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
$token = $body['data']['token'];
|
||||
$stored = json_decode((string) ($body['data']['local_storage']['like_admin_token'] ?? ''), true);
|
||||
aiMcpConsoleExpect(($stored['value'] ?? '') === $token, 'local storage entry is what the admin front end reads');
|
||||
$row = $session(93002, 8);
|
||||
aiMcpConsoleExpect($row && $row['token'] === $token && abs((int) $row['expire_time'] - ($now + 7200)) < 120, 'session row on the AI browser terminal with a two hour lifetime');
|
||||
aiMcpConsoleExpect(($session(93002, 1)['token'] ?? '') === $pcToken, 'the account\'s own PC login is untouched even with multipoint login off');
|
||||
$me = $backOffice($token);
|
||||
aiMcpConsoleExpect(($me['code'] ?? null) === 1 && (int) ($me['data']['user']['id'] ?? 0) === 93002, 'the console token works against the back office (login cache built for the caller\'s IP): ' . json_encode($me, JSON_UNESCAPED_UNICODE));
|
||||
aiMcpConsoleExpect(($console('open', $grantToken)['data']['token'] ?? '') === $token, 'opening again reuses the live session');
|
||||
|
||||
// ---------------------------------------------------------------- 关闭与撤销
|
||||
$body = $console('close', $grantToken);
|
||||
aiMcpConsoleExpect(($body['code'] ?? null) === 1 && ($body['data']['closed'] ?? null) === true, 'close ends the session');
|
||||
aiMcpConsoleExpect(($backOffice($token)['code'] ?? null) === -1, 'a closed console token is rejected by the back office');
|
||||
aiMcpConsoleExpect(($console('close', $grantToken)['data']['closed'] ?? null) === false, 'closing twice is harmless');
|
||||
$second = $console('open', $grantToken)['data']['token'] ?? '';
|
||||
aiMcpConsoleExpect($second !== '' && $second !== $token && ($backOffice($second)['code'] ?? null) === 1, 'a new session gets a new token');
|
||||
[, $body] = aiMcpConsoleHttp('POST', $base . '/mcp/auth/revoke', [], ['Authorization' => 'Bearer ' . $grantToken]);
|
||||
aiMcpConsoleExpect(($body['code'] ?? null) === 1, 'AI grant revoked');
|
||||
aiMcpConsoleExpect(($backOffice($second)['code'] ?? null) === -1, 'revoking the AI grant also ends its console session');
|
||||
aiMcpConsoleExpect(($session(93002, 1)['token'] ?? '') === $pcToken && (int) $session(93002, 1)['expire_time'] > time(), 'the PC login still stands after all of it');
|
||||
|
||||
// ---------------------------------------------------------------- 授权失效、失去权限后也能收回
|
||||
aiMcpConsoleExpect(($console('close', 'zyt_ai_' . str_repeat('0', 64))['code'] ?? null) === -1, 'an unknown token cannot close anything');
|
||||
$third = $grant('c_console');
|
||||
$live = $console('open', $third)['data']['token'] ?? '';
|
||||
aiMcpConsoleExpect($live !== '' && ($backOffice($live)['code'] ?? null) === 1, 'a new binding opens a new session');
|
||||
Db::name('ai_grant')->where(['admin_id' => 93002, 'status' => 1])->update(['status' => 2, 'revoke_reason' => 'test']); // 失效但没经过 close()
|
||||
$body = $console('close', $third);
|
||||
aiMcpConsoleExpect(($body['code'] ?? null) === 1 && ($body['data']['closed'] ?? null) === true, 'close still logs out with a grant that is no longer valid: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
aiMcpConsoleExpect(($backOffice($live)['code'] ?? null) === -1, 'and the session is gone');
|
||||
$fourth = $grant('c_console');
|
||||
$live = $console('open', $fourth)['data']['token'] ?? '';
|
||||
aiMcpConsoleExpect($live !== '' && ($backOffice($live)['code'] ?? null) === 1, 'reopened with a fresh binding');
|
||||
Db::name('system_role_menu')->where(['role_id' => 294, 'menu_id' => $menu('ai.mcp/access')])->delete();
|
||||
$body = $console('open', $fourth);
|
||||
aiMcpConsoleExpect(($body['code'] ?? null) === -1 && ($body['data']['reason'] ?? '') === 'no_ai_permission', 'losing ai.mcp/access refuses the grant: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
aiMcpConsoleExpect(($backOffice($live)['code'] ?? null) === -1, 'losing ai.mcp/access also ends the live console session');
|
||||
aiMcpConsoleExpect((int) Db::name('ai_grant')->where('admin_id', 93002)->order('id', 'desc')->value('status') === 1, 'the grant itself stays (usable again once the permission is back)');
|
||||
Db::name('system_role_menu')->insert(['role_id' => 294, 'menu_id' => $menu('ai.mcp/access')]);
|
||||
|
||||
// ---------------------------------------------------------------- 审计
|
||||
$logged = Db::name('ai_access_log')->where('admin_id', 93002)->whereIn('tool', ['console.open', 'console.close'])->column('tool');
|
||||
aiMcpConsoleExpect(count(array_filter($logged, static fn ($t) => $t === 'console.open')) >= 3 && in_array('console.close', $logged, true), 'console sessions are audited');
|
||||
aiMcpConsoleExpect(Db::name('ai_access_log')->where(['admin_id' => 93002, 'tool' => 'console.open'])->where('client_task_id', 'console-task')->count() >= 1, 'the 行知 task id is recorded');
|
||||
aiMcpConsoleExpect(Db::name('ai_access_log')->where(['admin_id' => 93001, 'tool' => 'console.open', 'status' => 'denied'])->count() === 1, 'refusals are audited');
|
||||
|
||||
echo "AiMcpConsoleTest OK\n";
|
||||
Reference in New Issue
Block a user