255 lines
19 KiB
PHP
255 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* AI 助手(MCP)HTTP 契约测试:授权门禁、协议、权限与数据范围、脱敏、撤销/改密/停用/闲置失效、审计、后台管理接口。
|
|
*
|
|
* 需要:
|
|
* 1. 一次性测试库(库名以 _test 结尾,含 zyt 表结构并执行过 2026_09_24_ai_mcp.sql),用 PHP_DATABASE_* 环境变量指定;
|
|
* 2. 一个指向同一个库、已开启 AI_MCP 的运行实例,例如:
|
|
* PHP_AI_MCP_ENABLED=true php -S 127.0.0.1:8099 -t public public/router.php
|
|
* 运行:
|
|
* AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpHttpContractTest.php
|
|
* 测试会写入 ID 段 91001-91020(账号)、95001-95010(诊单)等夹具数据,结束后不清理,便于排查。
|
|
*/
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
use think\App;
|
|
use think\facade\Db;
|
|
|
|
function aiMcpHttpExpect(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 "AiMcpHttpContractTest 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');
|
|
aiMcpHttpExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)");
|
|
|
|
// ---------------------------------------------------------------- 夹具
|
|
$now = time();
|
|
$salt = (string) config('project.unique_identification');
|
|
$pwd = create_password('Test@123456', $salt);
|
|
$roles = [91 => ['医生', 4], 92 => ['医助', 4], 93 => ['经理', 2], 96 => ['下单', 1]];
|
|
Db::name('system_role')->whereIn('id', array_keys($roles))->delete();
|
|
foreach ($roles as $id => [$name, $scope]) {
|
|
Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-test', 'sort' => 0, 'data_scope' => $scope, 'create_time' => $now, 'update_time' => $now]);
|
|
}
|
|
Db::name('dept')->whereIn('id', [9901, 9902, 9903])->delete();
|
|
Db::name('dept')->insertAll([
|
|
['id' => 9901, 'name' => 'AI测试总部', 'pid' => 0, 'sort' => 0, 'leader' => '', 'mobile' => '', 'status' => 1, 'create_time' => $now, 'update_time' => $now],
|
|
['id' => 9902, 'name' => 'AI测试一部', 'pid' => 9901, 'sort' => 0, 'leader' => '', 'mobile' => '', 'status' => 1, 'create_time' => $now, 'update_time' => $now],
|
|
['id' => 9903, 'name' => 'AI测试二部', 'pid' => 9901, 'sort' => 0, 'leader' => '', 'mobile' => '', 'status' => 1, 'create_time' => $now, 'update_time' => $now],
|
|
]);
|
|
$admins = [
|
|
91001 => ['t_root', 1, null, 9901, 0, 1], 91002 => ['t_doc_a', 0, 91, 9902, 0, 1], 91003 => ['t_doc_b', 0, 91, 9903, 0, 1],
|
|
91004 => ['t_asst_c', 0, 92, 9902, 0, 1], 91005 => ['t_mgr_m', 0, 93, 9901, 0, 1], 91006 => ['t_ops_d', 0, 96, 9901, 0, 1],
|
|
91007 => ['t_dis_e', 0, 92, 9902, 1, 1], 91008 => ['t_new_f', 0, 92, 9902, 0, 0], 91009 => ['t_asst_g', 0, 92, 9903, 0, 1],
|
|
91010 => ['t_lock_h', 0, 92, 9903, 0, 1],
|
|
];
|
|
Db::name('admin')->whereIn('id', array_keys($admins))->delete();
|
|
Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete();
|
|
Db::name('admin_dept')->whereIn('admin_id', array_keys($admins))->delete();
|
|
Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete();
|
|
foreach ($admins as $id => [$account, $root, $role, $dept, $disable, $isPaw]) {
|
|
Db::name('admin')->insert(['id' => $id, 'root' => $root, 'name' => $account, 'avatar' => '', 'account' => $account, 'password' => $pwd,
|
|
'multipoint_login' => 1, 'is_paw' => $isPaw, 'work_wechat_userid' => '', 'disable' => $disable, 'phone' => '1390000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]);
|
|
if ($role) {
|
|
Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $role]);
|
|
}
|
|
Db::name('admin_dept')->insert(['admin_id' => $id, 'dept_id' => $dept]);
|
|
}
|
|
$menuId = static function (string $perm) use ($now): int {
|
|
$id = (int) Db::name('system_menu')->where('perms', $perm)->value('id');
|
|
return $id ?: (int) Db::name('system_menu')->insertGetId(['pid' => 0, 'type' => 'A', 'name' => 'AI测试 ' . $perm, 'icon' => '', 'sort' => 0, 'perms' => $perm,
|
|
'paths' => '', 'component' => '', 'selected' => '', 'params' => '', 'is_cache' => 0, 'is_show' => 0, 'is_disable' => 0, 'create_time' => $now, 'update_time' => $now]);
|
|
};
|
|
aiMcpHttpExpect((int) Db::name('system_menu')->where('perms', 'ai.mcp/access')->count() === 1, 'run 2026_09_24_ai_mcp.sql on the test database first');
|
|
$grantsByRole = [
|
|
91 => ['doctor.appointment/lists', 'ai.mcp/access'],
|
|
92 => ['doctor.appointment/lists', 'ai.mcp/access'],
|
|
93 => ['doctor.appointment/lists', 'ai.mcp/access', 'tcm.diagnosis/phonePlain'],
|
|
96 => ['doctor.appointment/lists'],
|
|
];
|
|
Db::name('system_role_menu')->whereIn('role_id', array_keys($grantsByRole))->delete();
|
|
foreach ($grantsByRole as $role => $perms) {
|
|
foreach ($perms as $perm) {
|
|
Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $menuId($perm)]);
|
|
}
|
|
}
|
|
Db::name('tcm_diagnosis')->whereIn('id', [95001, 95002, 95003, 95004])->delete();
|
|
foreach ([95001 => ['甲一', 91004], 95002 => ['乙二', 91004], 95003 => ['丙三', 91004], 95004 => ['丁四', 91009]] as $id => [$name, $assistant]) {
|
|
Db::name('tcm_diagnosis')->insert(['id' => $id, 'patient_id' => $id + 1000, 'patient_name' => $name, 'phone' => '1381111' . substr((string) $id, -4),
|
|
'id_card' => '11010119900101' . substr((string) $id, -4), 'gender' => 1, 'age' => 40, 'status' => 1, 'assistant_id' => $assistant, 'create_time' => $now, 'update_time' => $now]);
|
|
}
|
|
Db::name('doctor_appointment')->whereIn('id', [96101, 96102, 96103, 96104])->delete();
|
|
foreach ([96101 => [95001, 91002, 91004, 3], 96102 => [95002, 91002, 91004, 3], 96103 => [95003, 91003, 91004, 3], 96104 => [95004, 91003, 91009, 2]] as $id => [$diag, $doctor, $assistant, $status]) {
|
|
Db::name('doctor_appointment')->insert(['id' => $id, 'patient_id' => $diag, 'doctor_id' => $doctor, 'assistant_id' => $assistant, 'roster_id' => 0,
|
|
'appointment_date' => '2031-01-15', 'period' => 'morning', 'appointment_time' => '09:00:00', 'appointment_type' => 'video', 'status' => $status,
|
|
'remark' => '', 'channel_source' => '', 'create_time' => $now, 'update_time' => $now]);
|
|
}
|
|
\think\facade\Cache::clear();
|
|
|
|
// ---------------------------------------------------------------- HTTP 工具
|
|
function aiMcpHttp(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_HEADER => 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);
|
|
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
|
curl_close($ch);
|
|
return [$status, json_decode(substr($raw, $headerSize), true), substr($raw, 0, $headerSize)];
|
|
}
|
|
|
|
$grant = static function (string $account, string $password = 'Test@123456') use ($base): array {
|
|
[, $body] = aiMcpHttp('POST', $base . '/mcp/auth/grant', ['account' => $account, 'password' => $password, 'client' => 'xingzhi', 'client_instance' => 'contract-test']);
|
|
return (array) $body;
|
|
};
|
|
$rpc = static function (string $token, string $method, array $params = [], array $headers = []) use ($base): array {
|
|
static $id = 0;
|
|
return aiMcpHttp('POST', $base . '/mcp', ['jsonrpc' => '2.0', 'id' => ++$id, 'method' => $method, 'params' => $params],
|
|
array_merge(['Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json, text/event-stream', 'X-Xingzhi-Task-Id' => 'contract-task'], $headers));
|
|
};
|
|
$tool = static function (string $token, string $name, array $args) use ($rpc): array {
|
|
[$status, $body] = $rpc($token, 'tools/call', ['name' => $name, 'arguments' => $args]);
|
|
aiMcpHttpExpect($status === 200 && isset($body['result']), "tools/call {$name} should return a result, got HTTP {$status}");
|
|
return $body['result'];
|
|
};
|
|
$ids = static fn (array $result): array => array_map('intval', array_column($result['structuredContent']['rows'] ?? [], 'id'));
|
|
|
|
// ---------------------------------------------------------------- 授权门禁
|
|
$tokens = [];
|
|
foreach (['t_root', 't_doc_a', 't_doc_b', 't_asst_c', 't_asst_g', 't_mgr_m'] as $account) {
|
|
$body = $grant($account);
|
|
aiMcpHttpExpect(($body['code'] ?? null) === 1 && str_starts_with((string) ($body['data']['token'] ?? ''), 'zyt_ai_'), "grant for {$account}: " . json_encode($body, JSON_UNESCAPED_UNICODE));
|
|
$tokens[$account] = $body['data']['token'];
|
|
}
|
|
foreach ([['t_ops_d', 'Test@123456', 'no_ai_permission'], ['t_dis_e', 'Test@123456', 'disabled'], ['t_new_f', 'Test@123456', 'need_change_password'],
|
|
['t_doc_a', 'wrong-password', 'invalid_credentials'], ['no_such_account', 'Test@123456', 'invalid_credentials']] as [$account, $password, $reason]) {
|
|
$body = $grant($account, $password);
|
|
aiMcpHttpExpect(($body['code'] ?? null) === 0 && ($body['data']['reason'] ?? '') === $reason, "grant for {$account} should fail with {$reason}: " . json_encode($body, JSON_UNESCAPED_UNICODE));
|
|
}
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$grant('t_lock_h', 'bad');
|
|
}
|
|
$body = $grant('t_lock_h');
|
|
aiMcpHttpExpect(($body['data']['reason'] ?? '') === 'locked', 'account locks after repeated failures even with the right password');
|
|
aiMcpHttpExpect((int) Db::name('ai_grant')->where('admin_id', 91002)->where('status', 1)->count() === 1, 'grant stored once for the account');
|
|
aiMcpHttpExpect(Db::name('ai_grant')->where('admin_id', 91002)->value('token_hash') === hash('sha256', $tokens['t_doc_a']), 'only the token hash is stored');
|
|
|
|
// ---------------------------------------------------------------- 协议
|
|
[$status, $body] = $rpc($tokens['t_doc_a'], 'initialize', ['protocolVersion' => '2025-06-18', 'capabilities' => new stdClass(), 'clientInfo' => ['name' => 'contract', 'version' => '1']]);
|
|
aiMcpHttpExpect($status === 200 && ($body['result']['protocolVersion'] ?? '') === '2025-06-18', 'initialize negotiates the requested version');
|
|
aiMcpHttpExpect(isset($body['result']['capabilities']['tools']), 'tools capability advertised');
|
|
[$status] = aiMcpHttp('POST', $base . '/mcp', ['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], ['Authorization' => 'Bearer ' . $tokens['t_doc_a']]);
|
|
aiMcpHttpExpect($status === 202, 'notifications return 202');
|
|
[$status] = aiMcpHttp('GET', $base . '/mcp', null, ['Authorization' => 'Bearer ' . $tokens['t_doc_a']]);
|
|
aiMcpHttpExpect($status === 405, 'GET /mcp is 405 (no SSE stream)');
|
|
[$status] = $rpc($tokens['t_doc_a'], 'ping', [], ['MCP-Protocol-Version' => '1999-01-01']);
|
|
aiMcpHttpExpect($status === 400, 'unsupported MCP-Protocol-Version is rejected');
|
|
[$status, , $headers] = $rpc('zyt_ai_' . str_repeat('0', 64), 'tools/list');
|
|
aiMcpHttpExpect($status === 401 && preg_match('/WWW-Authenticate:\s*Bearer/i', $headers) === 1, 'invalid token is 401 with WWW-Authenticate');
|
|
[$status] = $rpc($tokens['t_doc_a'], 'tools/list', [], ['Origin' => 'https://evil.example']);
|
|
aiMcpHttpExpect($status === 403, 'foreign Origin is rejected');
|
|
[$status, $body] = $rpc($tokens['t_doc_a'], 'no/such/method');
|
|
aiMcpHttpExpect(($body['error']['code'] ?? 0) === -32601, 'unknown method is -32601');
|
|
[, $body] = $rpc($tokens['t_doc_a'], 'tools/list');
|
|
$names = array_column($body['result']['tools'] ?? [], 'name');
|
|
aiMcpHttpExpect(in_array('zyt_query', $names, true) && in_array('zyt_stats_appointments', $names, true), 'tools/list includes generic and permitted preset tools');
|
|
aiMcpHttpExpect(!in_array('zyt_stats_orders', $names, true), 'presets for resources the account cannot use are hidden');
|
|
foreach ($body['result']['tools'] as $definition) {
|
|
aiMcpHttpExpect(($definition['annotations']['readOnlyHint'] ?? false) === true, $definition['name'] . ' is annotated read-only');
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 数据范围与脱敏
|
|
$range = ['start_date' => '2031-01-01', 'end_date' => '2031-01-31'];
|
|
$expected = ['t_doc_a' => [96101, 96102], 't_doc_b' => [96103, 96104], 't_asst_c' => [96101, 96102, 96103], 't_asst_g' => [96104],
|
|
't_mgr_m' => [96101, 96102, 96103, 96104], 't_root' => [96101, 96102, 96103, 96104]];
|
|
foreach ($expected as $account => $wanted) {
|
|
$result = $tool($tokens[$account], 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => $range, 'page_size' => 50]);
|
|
aiMcpHttpExpect(empty($result['isError']), "{$account} appointment query succeeds: " . ($result['content'][0]['text'] ?? ''));
|
|
$got = array_values(array_intersect($ids($result), [96101, 96102, 96103, 96104]));
|
|
sort($got);
|
|
aiMcpHttpExpect($got === $wanted, "{$account} sees exactly its appointments: expected " . json_encode($wanted) . ' got ' . json_encode($got));
|
|
$count = $tool($tokens[$account], 'zyt_stats_appointments', $range);
|
|
aiMcpHttpExpect(empty($count['isError']) && (int) ($count['structuredContent']['total'] ?? -1) >= count($wanted), "{$account} appointment stats agree with the list");
|
|
}
|
|
$row = $tool($tokens['t_doc_a'], 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => $range])['structuredContent']['rows'][0];
|
|
aiMcpHttpExpect(str_contains((string) $row['patient_phone'], '****'), 'doctor sees masked patient phone');
|
|
$row = $tool($tokens['t_mgr_m'], 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => $range])['structuredContent']['rows'][0];
|
|
aiMcpHttpExpect(!str_contains((string) $row['patient_phone'], '****'), 'account with tcm.diagnosis/phonePlain sees the full phone');
|
|
$result = $tool($tokens['t_asst_c'], 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => ['progress_board' => 1]]);
|
|
aiMcpHttpExpect(!empty($result['isError']) && str_contains($result['content'][0]['text'], 'progress_board'), 'scope-widening parameter is rejected');
|
|
$result = $tool($tokens['t_doc_a'], 'zyt_query', ['resource' => 'order.order/lists']);
|
|
aiMcpHttpExpect(!empty($result['isError']), 'resource without permission is denied');
|
|
$result = $tool($tokens['t_doc_a'], 'zyt_query', ['resource' => 'no.such/lists']);
|
|
aiMcpHttpExpect(!empty($result['isError']), 'unknown resource is denied');
|
|
$result = $tool($tokens['t_doc_a'], 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => ['start_date' => '2020-01-01', 'end_date' => '2031-01-01']]);
|
|
aiMcpHttpExpect(!empty($result['isError']) && str_contains($result['content'][0]['text'], '天'), 'overlong date range is rejected');
|
|
$catalog = $tool($tokens['t_doc_a'], 'zyt_catalog', []);
|
|
aiMcpHttpExpect((int) ($catalog['structuredContent']['total'] ?? 0) >= 1, 'catalog lists the permitted resources');
|
|
[$status, $body] = aiMcpHttp('GET', $base . '/mcp/auth/whoami', null, ['Authorization' => 'Bearer ' . $tokens['t_asst_c']]);
|
|
aiMcpHttpExpect($status === 200 && ($body['data']['admin']['account'] ?? '') === 't_asst_c', 'whoami returns the bound account');
|
|
|
|
// ---------------------------------------------------------------- 审计
|
|
$log = Db::name('ai_access_log')->where(['admin_id' => 91002, 'client_task_id' => 'contract-task', 'resource' => 'doctor.appointment/lists', 'status' => 'ok'])->order('id', 'desc')->find();
|
|
aiMcpHttpExpect($log && str_contains((string) $log['record_ids'], '96101'), 'audit log records the task id and returned record ids');
|
|
aiMcpHttpExpect((int) Db::name('ai_access_log')->where(['admin_id' => 91004, 'status' => 'invalid'])->count() >= 1, 'rejected calls are audited');
|
|
|
|
// ---------------------------------------------------------------- 失效
|
|
[$status] = aiMcpHttp('POST', $base . '/mcp/auth/revoke', [], ['Authorization' => 'Bearer ' . $tokens['t_doc_b']]);
|
|
aiMcpHttpExpect($status === 200, 'revoke succeeds');
|
|
[$status] = $rpc($tokens['t_doc_b'], 'tools/list');
|
|
aiMcpHttpExpect($status === 401, 'revoked token is rejected');
|
|
Db::name('admin')->where('id', 91003)->update(['password' => create_password('Changed@123', $salt)]);
|
|
$fresh = $grant('t_doc_b', 'Changed@123')['data']['token'] ?? '';
|
|
Db::name('admin')->where('id', 91003)->update(['password' => $pwd]);
|
|
[$status, $body] = $rpc($fresh, 'tools/list');
|
|
aiMcpHttpExpect($status === 401 && ($body['error']['data']['reason'] ?? '') === 'password_changed', 'password change invalidates the grant');
|
|
Db::name('admin')->where('id', 91009)->update(['disable' => 1]);
|
|
[$status] = $rpc($tokens['t_asst_g'], 'tools/list');
|
|
Db::name('admin')->where('id', 91009)->update(['disable' => 0]);
|
|
aiMcpHttpExpect($status === 401, 'disabling the account invalidates the grant');
|
|
Db::name('ai_grant')->where('admin_id', 91005)->update(['last_used_time' => $now - 40 * 86400]);
|
|
[$status, $body] = $rpc($tokens['t_mgr_m'], 'tools/list');
|
|
aiMcpHttpExpect($status === 401 && ($body['error']['data']['reason'] ?? '') === 'expired', 'idle grant expires');
|
|
Db::name('system_role_menu')->where(['role_id' => 91, 'menu_id' => $menuId('ai.mcp/access')])->delete();
|
|
\think\facade\Cache::clear();
|
|
[$status] = $rpc($tokens['t_doc_a'], 'tools/list');
|
|
aiMcpHttpExpect($status === 401, 'removing the AI permission from the role takes effect immediately');
|
|
|
|
// ---------------------------------------------------------------- 后台管理接口(后台登录令牌)
|
|
$sessionToken = 'aimcptest' . bin2hex(random_bytes(8));
|
|
Db::name('admin_session')->where('admin_id', 91001)->delete();
|
|
Db::name('admin_session')->insert(['admin_id' => 91001, 'terminal' => 1, 'token' => $sessionToken, 'update_time' => $now, 'expire_time' => $now + 3600]);
|
|
[$status, $body] = aiMcpHttp('GET', $base . '/mcp/admin/grants?page_size=50', null, ['token' => $sessionToken]);
|
|
aiMcpHttpExpect(($body['code'] ?? null) === 1 && (int) ($body['data']['count'] ?? 0) >= 5, 'root sees all grants in the admin page');
|
|
[$status, $body] = aiMcpHttp('GET', $base . '/mcp/admin/logs?client_task_id=contract-task', null, ['token' => $sessionToken]);
|
|
aiMcpHttpExpect(($body['code'] ?? null) === 1 && (int) ($body['data']['count'] ?? 0) >= 1, 'root sees the access log');
|
|
[$status, $body] = aiMcpHttp('GET', $base . '/mcp/admin/catalog?page_size=5', null, ['token' => $sessionToken]);
|
|
aiMcpHttpExpect(($body['code'] ?? null) === 1 && isset($body['data']['extend']['counts']['open']), 'catalog status page works');
|
|
[$status, $body] = aiMcpHttp('GET', $base . '/mcp/admin/grants', null, ['token' => 'not-a-session']);
|
|
aiMcpHttpExpect(($body['code'] ?? null) === -1, 'admin endpoints require a back-office session');
|
|
|
|
echo "AiMcpHttpContractTest OK\n";
|