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