更新
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
<?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";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* AI 助手(MCP)模块:不依赖数据库的单元测试。
|
||||
* php server/tests/AiMcpUnitTest.php
|
||||
* 覆盖:字段脱敏、令牌格式、协议版本协商、目录自动判定规则、人工审核文件的静态一致性。
|
||||
*/
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\mcp\service\Catalog;
|
||||
use app\mcp\service\FieldPolicy;
|
||||
use app\mcp\service\McpConfig;
|
||||
use app\mcp\service\Protocol;
|
||||
use app\mcp\service\TokenService;
|
||||
|
||||
function aiMcpExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
fwrite(STDERR, "FAIL: {$message}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 字段策略 ----------
|
||||
$policy = new FieldPolicy(false, false);
|
||||
$out = $policy->apply([
|
||||
'id' => 12,
|
||||
'patient_name' => '刘一',
|
||||
'phone' => '13811110001',
|
||||
'patient_phone' => '138-1111-0002',
|
||||
'id_card' => '110101199001011234',
|
||||
'shipping_address' => '河南省郑州市金水区文化路 88 号 3 单元',
|
||||
'password' => 'x', 'salt' => 'y', 'token' => 'z', 'app_secret' => 's', 'api_key' => 'k', 'report_cipher' => 'c',
|
||||
'is_phone_verified' => 1, 'has_id_card' => 1,
|
||||
'tongue_images' => '["https://admin.zhenyangtang.com.cn/uploads/a.jpg","https://admin.zhenyangtang.com.cn/uploads/b.jpg"]',
|
||||
'report_files' => ['uploads/r1.pdf'],
|
||||
'remark' => '家属电话13722220001,身份证 110101198505052345',
|
||||
'order_no' => '202609151234567890',
|
||||
'nested' => ['doctor_signature' => 'data:image/png;base64,AAA', 'mobile' => '13900000001'],
|
||||
]);
|
||||
aiMcpExpect(!isset($out['password'], $out['salt'], $out['token'], $out['app_secret'], $out['api_key'], $out['report_cipher']), 'credential fields are dropped');
|
||||
aiMcpExpect(!isset($out['nested']['doctor_signature']), 'nested signature is dropped');
|
||||
aiMcpExpect($out['phone'] === '138****0001', 'phone masked');
|
||||
aiMcpExpect($out['patient_phone'] === '138****0002', 'formatted phone masked');
|
||||
aiMcpExpect($out['nested']['mobile'] === '139****0001', 'nested mobile masked');
|
||||
aiMcpExpect($out['id_card'] === '1101**********1234', 'id card masked');
|
||||
aiMcpExpect(str_ends_with($out['shipping_address'], '***') && !str_contains($out['shipping_address'], '88'), 'address masked');
|
||||
aiMcpExpect($out['is_phone_verified'] === 1 && $out['has_id_card'] === 1, 'flag fields are not masked');
|
||||
aiMcpExpect(str_contains((string) $out['tongue_images'], '附件×2'), 'attachment url list replaced');
|
||||
aiMcpExpect(is_string($out['report_files']) && str_contains($out['report_files'], '附件×1'), 'attachment array replaced');
|
||||
aiMcpExpect(!str_contains($out['remark'], '13722220001') && str_contains($out['remark'], '137****0001'), 'phone inside free text masked');
|
||||
aiMcpExpect(!str_contains($out['remark'], '110101198505052345'), 'id card inside free text masked');
|
||||
aiMcpExpect($out['order_no'] === '202609151234567890', 'order numbers are not mistaken for id cards');
|
||||
aiMcpExpect($out['patient_name'] === '刘一', 'names are kept');
|
||||
$paths = (new FieldPolicy(false, false))->apply(['examination_report' => 'uploads/files/20260915/report.pdf', 'link' => 'https://www.example.com/page', 'note' => 'uploads 说明']);
|
||||
aiMcpExpect(str_contains($paths['examination_report'], '附件×1'), 'storage paths are treated as attachments whatever the field name');
|
||||
aiMcpExpect($paths['link'] === 'https://www.example.com/page' && $paths['note'] === 'uploads 说明', 'ordinary links and text are kept');
|
||||
$ips = (new FieldPolicy(false, false))->apply(['login_ip' => '113.25.8.77', 'ip' => '10.0.0.5', 'tip' => 'x']);
|
||||
aiMcpExpect($ips['login_ip'] === '113.25.8.*' && $ips['ip'] === '10.0.0.*' && $ips['tip'] === 'x', 'IP addresses keep only the network part');
|
||||
aiMcpExpect(in_array('phone', $policy->maskedFields(), true) && in_array('password', $policy->maskedFields(), true), 'masked fields are reported');
|
||||
|
||||
$phoneOnly = (new FieldPolicy(true, false))->apply(['phone' => '13811110001', 'id_card' => '110101199001011234', 'note' => '电话13811110001']);
|
||||
aiMcpExpect($phoneOnly['phone'] === '13811110001' && $phoneOnly['note'] === '电话13811110001', 'phonePlain permission keeps phones');
|
||||
aiMcpExpect($phoneOnly['id_card'] === '1101**********1234', 'phonePlain permission still masks id cards');
|
||||
$full = (new FieldPolicy(true, true))->apply(['id_card' => '110101199001011234', 'tongue_images' => 'https://x/uploads/a.jpg', 'password' => 'p']);
|
||||
aiMcpExpect($full['id_card'] === '110101199001011234' && $full['tongue_images'] === 'https://x/uploads/a.jpg', 'sensitive permission shows full values');
|
||||
aiMcpExpect(!isset($full['password']), 'credentials are dropped even with sensitive permission');
|
||||
$audit = FieldPolicy::maskText(['account' => 'doc_a', 'note' => '13811110001']);
|
||||
aiMcpExpect($audit['note'] === '138****0001', 'audit arguments are always masked');
|
||||
$long = (new FieldPolicy(true, true, 10))->apply(['transcript_text' => str_repeat('问诊记录', 20)]);
|
||||
aiMcpExpect(str_contains($long['transcript_text'], '已截断'), 'long text truncated with hint');
|
||||
|
||||
// ---------- 令牌 ----------
|
||||
$token = TokenService::generate();
|
||||
aiMcpExpect(str_starts_with($token, 'zyt_ai_') && strlen($token) === 71, 'token format');
|
||||
aiMcpExpect(TokenService::hash($token) === hash('sha256', $token), 'token hash is sha256');
|
||||
aiMcpExpect(TokenService::displayPrefix($token) === substr($token, 0, 12), 'display prefix');
|
||||
$request = (new \app\Request())->withHeader(['authorization' => 'Bearer ' . $token]);
|
||||
aiMcpExpect(TokenService::fromRequest($request) === $token, 'bearer token parsed');
|
||||
aiMcpExpect(TokenService::fromRequest((new \app\Request())->withHeader(['authorization' => 'Bearer abc'])) === '', 'foreign token rejected');
|
||||
aiMcpExpect(TokenService::fromRequest((new \app\Request())->withHeader(['authorization' => 'Basic ' . $token])) === '', 'non-bearer scheme rejected');
|
||||
aiMcpExpect(TokenService::fromRequest((new \app\Request())->withHeader([])) === '', 'missing header rejected');
|
||||
|
||||
// ---------- 协议版本 ----------
|
||||
aiMcpExpect(Protocol::negotiate('2025-06-18') === '2025-06-18', 'supported version echoed');
|
||||
aiMcpExpect(Protocol::negotiate('2099-01-01') === McpConfig::PROTOCOL_VERSIONS[0], 'unknown version falls back to latest supported');
|
||||
aiMcpExpect(Protocol::negotiate(null) === McpConfig::PROTOCOL_VERSIONS[0], 'missing version falls back');
|
||||
|
||||
// ---------- 目录自动判定 ----------
|
||||
$decide = (new ReflectionClass(Catalog::class))->getMethod('decide');
|
||||
$decide->setAccessible(true);
|
||||
$base = ['kind' => 'list', 'http' => 'GET', 'writes' => [], 'external' => [], 'no_login' => false, 'registered' => true, 'perm' => 'x.y/lists', 'key' => 'x.y/lists'];
|
||||
$cases = [
|
||||
[[], Catalog::OPEN, 'registered read-only list opens'],
|
||||
[['kind' => 'write'], Catalog::EXCLUDED, 'write action excluded'],
|
||||
[['http' => 'POST'], Catalog::EXCLUDED, 'POST action excluded'],
|
||||
[['no_login' => true], Catalog::EXCLUDED, 'no-login action excluded'],
|
||||
[['key' => 'setting.storage/lists'], Catalog::EXCLUDED, 'settings excluded by default'],
|
||||
[['key' => 'channel.mnpSettings/getConfig', 'kind' => 'report'], Catalog::EXCLUDED, 'getConfig excluded by default'],
|
||||
[['external' => ['curl_exec']], Catalog::PENDING, 'external call pending'],
|
||||
[['writes' => ['->save(']], Catalog::PENDING, 'write marker pending'],
|
||||
[['kind' => 'detail'], Catalog::PENDING, 'detail without review pending'],
|
||||
[['kind' => 'other'], Catalog::PENDING, 'unknown action pending'],
|
||||
[['registered' => false], Catalog::PENDING, 'unregistered permission pending (deny by default)'],
|
||||
[['status' => 'open', 'registered' => false, 'reason' => ''], Catalog::PENDING, 'reviewed open still needs a registered permission'],
|
||||
[['status' => 'excluded', 'reason' => 'x'], Catalog::EXCLUDED, 'reviewed status wins'],
|
||||
];
|
||||
foreach ($cases as [$override, $expected, $message]) {
|
||||
[$status] = $decide->invoke(null, array_merge($base, $override));
|
||||
aiMcpExpect($status === $expected, $message . " (got {$status})");
|
||||
}
|
||||
$allowed = Catalog::allowedParams(['params' => ['patient_name', 'pending_assign', 'export', 'page_type', 'status'], 'forbid' => ['status']]);
|
||||
aiMcpExpect($allowed === ['patient_name'], 'global and resource forbids are removed from scanned params');
|
||||
$allowedReviewed = Catalog::allowedParams(['params' => ['a'], 'params_allow' => ['b' => '说明', 'scene' => 'x']]);
|
||||
aiMcpExpect($allowedReviewed === ['b'], 'params_allow replaces scanned params and still honours global forbid');
|
||||
|
||||
// ---------- 审核文件静态一致性 ----------
|
||||
$generated = require dirname(__DIR__) . '/app/mcp/catalog/generated.php';
|
||||
$reviewed = require dirname(__DIR__) . '/app/mcp/catalog/resources.php';
|
||||
$unreviewed = 0;
|
||||
foreach ($generated as $key => $entry) {
|
||||
if ($entry['kind'] !== 'write' && $entry['http'] !== 'POST' && empty($entry['no_login']) && !isset($reviewed[$key])) {
|
||||
$unreviewed++;
|
||||
}
|
||||
}
|
||||
foreach ($reviewed as $key => $entry) {
|
||||
$status = $entry['status'] ?? null;
|
||||
aiMcpExpect(in_array($status, [Catalog::OPEN, Catalog::PENDING, Catalog::EXCLUDED], true), "{$key}: status must be open/pending/excluded");
|
||||
aiMcpExpect($status === Catalog::OPEN || trim((string) ($entry['reason'] ?? '')) !== '', "{$key}: closed entries need a reason");
|
||||
aiMcpExpect(isset($generated[$key]) || !empty($entry['controller']) || !empty($entry['handler']['logic']) || !empty($entry['handler']['table']), "{$key}: unknown resource (not in generated.php and no controller/handler)");
|
||||
if (!empty($entry['handler']['table'])) {
|
||||
aiMcpExpect(!empty($entry['handler']['columns']) && ($entry['kind'] ?? '') === 'table' && !empty($entry['perm']), "{$key}: table resources need columns, kind=table and a perm");
|
||||
aiMcpExpect(($entry['handler']['scope'] ?? 'root') === 'root' || !empty($entry['handler']['scope']['owner']), "{$key}: table scope must be root or owner columns");
|
||||
foreach ((array) $entry['handler']['columns'] as $column) {
|
||||
aiMcpExpect(!preg_match('/password|salt|secret|token|cipher|session_key/i', (string) $column), "{$key}: table resource must not expose credential column {$column}");
|
||||
}
|
||||
}
|
||||
$kind = $entry['kind'] ?? ($generated[$key]['kind'] ?? 'report');
|
||||
if ($status === Catalog::OPEN && $kind === 'detail') {
|
||||
aiMcpExpect(!empty($entry['guard']), "{$key}: an open detail resource needs a guard");
|
||||
}
|
||||
foreach ((array) ($entry['params_allow'] ?? []) as $param => $doc) {
|
||||
aiMcpExpect(!in_array($param, Catalog::GLOBAL_FORBID, true), "{$key}: params_allow must not include globally forbidden {$param}");
|
||||
}
|
||||
if (!empty($entry['handler']['logic'])) {
|
||||
[$class, $method] = $entry['handler']['logic'];
|
||||
aiMcpExpect(method_exists($class, $method), "{$key}: handler {$class}::{$method} does not exist");
|
||||
if (!empty($entry['handler']['validate'])) {
|
||||
aiMcpExpect(class_exists($entry['handler']['validate'][0]), "{$key}: validator class missing");
|
||||
}
|
||||
}
|
||||
if (is_array($entry['guard'] ?? null) && isset($entry['guard']['callable'])) {
|
||||
[$class, $method] = $entry['guard']['callable'];
|
||||
aiMcpExpect(method_exists($class, $method), "{$key}: guard {$class}::{$method} does not exist");
|
||||
}
|
||||
if (is_array($entry['guard'] ?? null) && isset($entry['guard']['via'])) {
|
||||
aiMcpExpect(isset($generated[$entry['guard']['via']]) || isset($reviewed[$entry['guard']['via']]), "{$key}: guard via unknown list {$entry['guard']['via']}");
|
||||
}
|
||||
}
|
||||
|
||||
echo "AiMcpUnitTest OK (reviewed entries: " . count($reviewed) . ", read candidates without review: {$unreviewed})\n";
|
||||
Reference in New Issue
Block a user