165 lines
11 KiB
PHP
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";
|