Files
zyt/server/tests/AiMcpUnitTest.php
T
2026-09-24 09:45:44 +08:00

165 lines
11 KiB
PHP

<?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";