From 1ab276b1e9a7c8747a6940c5ad8ffdf2aaa8ef45 Mon Sep 17 00:00:00 2001 From: augushong Date: Sun, 16 Aug 2026 21:40:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20McpService=20=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E4=B8=8E=E5=8A=A8=E6=80=81=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=9B=86=EF=BC=88=E7=99=BD=E5=90=8D=E5=8D=95=E2=88=A9=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E8=80=85=E5=AE=9E=E6=97=B6=E6=9D=83=E9=99=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/service/McpService.php | 13 + extend/base/common/service/McpServiceBase.php | 281 ++++++++++++ tests/McpServiceTest.php | 406 ++++++++++++++++++ 3 files changed, 700 insertions(+) create mode 100644 app/common/service/McpService.php create mode 100644 extend/base/common/service/McpServiceBase.php create mode 100644 tests/McpServiceTest.php diff --git a/app/common/service/McpService.php b/app/common/service/McpService.php new file mode 100644 index 0000000..d1d9338 --- /dev/null +++ b/app/common/service/McpService.php @@ -0,0 +1,13 @@ + sha256 哈希比对密钥表(status=1,软删自动排除), + * 加载创建者信息(剔除 password) + * 2. getCreatorNodes:密钥白名单 ∩ 创建者实时权限(复用 AuthService::getAdminAllowedNodes, + * 严禁自行写 SQL join auth_node——超管直通 / auth_on / 黑名单等语义必须与后台一致) + * 3. getTools:按白名单顺序生成 MCP 工具定义(名称编码 + 标题描述) + * 4. verifyNode:tools/call 传入的编码工具名 -> 校验并还原为原始节点 + * 5. logCall:调用审计落库 + 密钥表原子自增(审计失败仅记录错误日志,不抛出) + * + * 本类不依赖请求上下文、不依赖 mcp/sdk、不发起 HTTP(分发是端点层职责)。 + */ +class McpServiceBase +{ + /** MCP 规范工具名最大长度(^[a-zA-Z0-9_-]{1,64}$). */ + protected const TOOL_NAME_MAX_LENGTH = 64; + + /** 超长编码名截断保留长度:56 + '_' + 7 位哈希 = 64. */ + protected const TOOL_NAME_TRUNCATE_KEEP = 56; + + /** 审计日志 arguments JSON 最大长度(字符). */ + protected const LOG_ARGUMENTS_MAX_LENGTH = 2000; + + /** + * 密钥认证. + * + * 传入 Bearer 明文,sha256 后查密钥表;命中且 status=1(软删行被 SoftDelete + * 自动排除)时加载创建者行。创建者加载方式与 AuthServiceBase::getAdminInfo + * 保持一致(Db::name 直查,不附加 delete_time 条件),避免与权限判定链语义漂移; + * 创建者不存在时无法建立权限基础,视为认证失败。 + * + * @param string $bearer 密钥明文 + * @return array|null ['key_row' => SystemMcpKey模型, 'creator' => 创建者数组(无password)],失败返回 null + */ + public function authenticate(string $bearer): ?array + { + $bearer = trim($bearer); + if ($bearer === '') { + return null; + } + + $keyRow = SystemMcpKey::where('key', hash('sha256', $bearer)) + ->where('status', 1) + ->find(); + if (empty($keyRow)) { + return null; + } + + $creator = Db::name('system_admin') + ->where('id', $keyRow->bind_admin_id) + ->find(); + if (empty($creator)) { + return null; + } + unset($creator['password']); + + return [ + 'key_row' => $keyRow, + 'creator' => $creator, + ]; + } + + /** + * 计算密钥可用节点:白名单 ∩ 创建者实时权限. + * + * 白名单顺序保持(getAdminAllowedNodes 对候选逐个判定并保序去重)。 + * 创建者权限判定完整复用 AuthService::getAdminAllowedNodes(T4): + * 超管直通 / auth_on 开关 / 动态黑名单 / 注解 auth / status / auth_ids + * 全部与后台一致,严禁在本类平行实现 SQL join 导致语义漂移。 + * + * @param array $auth authenticate() 的返回结构 + * @return array 允许的节点列表(白名单顺序) + */ + public function getCreatorNodes(array $auth): array + { + $keyRow = $auth['key_row'] ?? null; + if (empty($keyRow)) { + return []; + } + + $whitelist = SystemMcpKeyNode::where('key_id', $keyRow->id) + ->column('node'); + if (empty($whitelist)) { + return []; + } + + $adminId = (int) $keyRow->bind_admin_id; + + return $this->newAuthService($adminId) + ->getAdminAllowedNodes($whitelist, $adminId); + } + + /** + * 构建 AuthService(依赖倒置:Base 层经 app/ 入口类). + * + * 独立成 protected 钩子是为了可测性:auth_on 等判定链配置无法从外部注入 + * (AuthServiceBase::$config 硬编码),测试子类可覆写本方法返回定制实例。 + */ + protected function newAuthService(int $adminId): AuthServiceBase + { + return new AuthService($adminId); + } + + /** + * 生成 MCP 工具定义列表. + * + * 工具名 = encodeToolName(节点);描述 = 节点标题 +【原始节点】, + * 标题取自 NodeService::getNodeParis() 的 node=>title 映射(与 AuthServiceBase + * 共用同一 Cache key,60 秒缓存),未注册节点回退为节点串本身。 + * 全量生成后做工具名唯一性校验(编码对本项目节点字母表单射 + 截断名带 + * sha256 后缀,理论不可冲突;命中即数据异常,防御性抛出)。 + * + * @param array $auth authenticate() 的返回结构 + * @return array [['name' => 编码名, 'description' => 标题【节点】], ...] + */ + public function getTools(array $auth): array + { + $allowedNodes = $this->getCreatorNodes($auth); + if (empty($allowedNodes)) { + return []; + } + + $nodeParis = $this->getNodeParisMap(); + + $tools = []; + $usedNames = []; + foreach ($allowedNodes as $node) { + $name = $this->encodeToolName($node); + if (isset($usedNames[$name])) { + throw new \RuntimeException('MCP tool name collision: ' . $name); + } + $usedNames[$name] = true; + + $title = $nodeParis[$node]['title'] ?? ''; + if ($title === '' || is_null($title)) { + $title = $node; + } + + $tools[] = [ + 'name' => $name, + 'description' => $title . '【' . $node . '】', + ]; + } + + return $tools; + } + + /** + * 校验 tools/call 传入的编码工具名并还原原始节点. + * + * 优先按规范逆解码后命中创建者允许集合;超长节点的编码名经过截断 + 哈希 + * 后缀,不可逆解码,回退为对允许集合正向重编码逐一比对(集合很小,开销可忽略)。 + * + * @param array $auth authenticate() 的返回结构 + * @param string $toolName 编码后的工具名 + * @return string|null 命中返回原始节点串,未授权/乱码返回 null + */ + public function verifyNode(array $auth, string $toolName): ?string + { + $allowedNodes = $this->getCreatorNodes($auth); + if (empty($allowedNodes)) { + return null; + } + + $decoded = $this->decodeToolName($toolName); + if ($decoded !== '' && in_array($decoded, $allowedNodes, true)) { + return $decoded; + } + + foreach ($allowedNodes as $node) { + if ($this->encodeToolName($node) === $toolName) { + return $node; + } + } + + return null; + } + + /** + * 节点串编码为 MCP 工具名. + * + * MCP 工具名规范 ^[a-zA-Z0-9_-]{1,64}$,节点串(module.controller/action) + * 含 '.' 与 '/' 非法,编码规则:先 '/'->'--' 再 '.'->'-'(顺序固定, + * '--' 是 '/' 的唯一标记,保证对实际节点字母表单射可逆)。 + * 超过 64 字符时截断至 56 位 + '_' + sha256(原始节点) 前 7 位(56+1+7=64)。 + */ + public function encodeToolName(string $node): string + { + $name = str_replace('.', '-', str_replace('/', '--', $node)); + + if (strlen($name) > self::TOOL_NAME_MAX_LENGTH) { + $name = substr($name, 0, self::TOOL_NAME_TRUNCATE_KEEP) + . '_' . substr(hash('sha256', $node), 0, 7); + } + + return $name; + } + + /** + * MCP 工具名解码回节点串(encodeToolName 的逆变换). + * + * 顺序与编码相反:先 '--'->'/' 再 '-'->'.'。截断名不可逆(有损), + * 调用方(verifyNode)需对截断场景走正向重编码比对。 + */ + public function decodeToolName(string $name): string + { + return str_replace('-', '.', str_replace('--', '/', $name)); + } + + /** + * 调用审计落库. + * + * 日志行写 system_mcp_log(arguments JSON 超长截断);密钥表 use_num 用 + * Db::raw 原子自增(禁止读改写),并刷新 last_use_time。 + * 审计属于尽力而为:写失败仅 Log::error,绝不向上抛出影响主调用流程。 + */ + public function logCall(int $keyId, string $node, array $arguments, bool $success, int $costMs): void + { + try { + $argumentsJson = json_encode($arguments, JSON_UNESCAPED_UNICODE); + if ($argumentsJson === false) { + $argumentsJson = ''; + } + if (mb_strlen($argumentsJson) > self::LOG_ARGUMENTS_MAX_LENGTH) { + $argumentsJson = mb_substr($argumentsJson, 0, self::LOG_ARGUMENTS_MAX_LENGTH); + } + + SystemMcpLog::create([ + 'key_id' => $keyId, + 'node' => $node, + 'arguments' => $argumentsJson, + 'is_success' => $success ? 1 : 0, + 'cost_ms' => $costMs, + ]); + + SystemMcpKey::where('id', $keyId)->update([ + 'use_num' => Db::raw('use_num+1'), + 'last_use_time' => time(), + ]); + } catch (\Throwable $e) { + Log::error('MCP call audit failed: ' . $e->getMessage(), [ + 'key_id' => $keyId, + 'node' => $node, + ]); + } + } + + /** + * 节点 node=>信息 映射(含 title). + * + * 与 AuthServiceBase::getNodeList 共用同一 Cache key(node_paris,60 秒), + * 避免每次 tools/list 重复反射扫描控制器目录。 + * @return array + */ + protected function getNodeParisMap(): array + { + $cacheKey = 'node_paris'; + $nodeParis = Cache::get($cacheKey); + if (!$nodeParis) { + $nodeParis = (new NodeService())->getNodeParis(); + Cache::set($cacheKey, $nodeParis, 60); + } + + return $nodeParis; + } +} diff --git a/tests/McpServiceTest.php b/tests/McpServiceTest.php new file mode 100644 index 0000000..711bdca --- /dev/null +++ b/tests/McpServiceTest.php @@ -0,0 +1,406 @@ +service = new McpService(); + } + + // ========================================================================= + // fixture 工厂(事务内写入,tearDown 自动回滚) + // ========================================================================= + + private function createAdmin(int $status = 1, string $authIds = ''): int + { + return Db::name('system_admin')->insertGetId([ + 'username' => 'mcp_t_' . uniqid(), + 'password' => 'should-not-leak', + 'auth_ids' => $authIds, + 'status' => $status, + 'create_time' => time(), + 'update_time' => time(), + 'delete_time' => 0, + ]); + } + + private function createRoleWithNodes(array $nodes): int + { + $roleId = Db::name('system_auth')->insertGetId([ + 'title' => 'mt' . uniqid(), + 'status' => 2, // system_auth 枚举:1=禁用 2=启用 + 'sort' => 100, + 'create_time' => time(), + 'update_time' => time(), + 'delete_time' => 0, + ]); + foreach ($nodes as $node) { + Db::name('system_auth_node')->insert([ + 'auth_id' => $roleId, + 'node' => $node, + ]); + } + + return $roleId; + } + + /** + * 造密钥 + 白名单节点. + * @return array{id: int, plaintext: string} + */ + private function createKey(int $bindAdminId, array $nodes, int $status = 1, int $deleteTime = 0): array + { + $plaintext = 'sk-mcp-test-' . bin2hex(random_bytes(16)); + $keyId = Db::name('system_mcp_key')->insertGetId([ + 'title' => 'mt' . uniqid(), + 'key' => hash('sha256', $plaintext), + 'key_prefix' => substr($plaintext, 0, 11), + 'bind_admin_id' => $bindAdminId, + 'status' => $status, + 'remark' => '', + 'use_num' => 0, + 'last_use_time' => 0, + 'create_time' => time(), + 'update_time' => time(), + 'delete_time' => $deleteTime, + ]); + foreach ($nodes as $node) { + SystemMcpKeyNode::create(['key_id' => $keyId, 'node' => $node]); + } + + return ['id' => $keyId, 'plaintext' => $plaintext]; + } + + // ========================================================================= + // 1. authenticate + // ========================================================================= + + public function test_authenticate_valid_key_returns_creator_without_password(): void + { + $adminId = $this->createAdmin(status: 1, authIds: ''); + $key = $this->createKey($adminId, [self::NODE_A]); + + $auth = $this->service->authenticate($key['plaintext']); + + $this->assertIsArray($auth); + $this->assertArrayHasKey('key_row', $auth); + $this->assertArrayHasKey('creator', $auth); + $this->assertInstanceOf(SystemMcpKey::class, $auth['key_row']); + $this->assertSame($key['id'], $auth['key_row']->id); + $this->assertNotEmpty($auth['creator']); + $this->assertSame($adminId, (int) $auth['creator']['id']); + // 创建者行必须剔除 password + $this->assertArrayNotHasKey('password', $auth['creator']); + } + + public function test_authenticate_rejects_wrong_bearer(): void + { + $adminId = $this->createAdmin(); + $this->createKey($adminId, [self::NODE_A]); + + $this->assertNull($this->service->authenticate('sk-mcp-wrong-bearer')); + } + + public function test_authenticate_rejects_disabled_key(): void + { + $adminId = $this->createAdmin(); + $key = $this->createKey($adminId, [self::NODE_A], status: 0); + + $this->assertNull($this->service->authenticate($key['plaintext'])); + } + + public function test_authenticate_rejects_soft_deleted_key(): void + { + $adminId = $this->createAdmin(); + $key = $this->createKey($adminId, [self::NODE_A], deleteTime: time() - 60); + + $this->assertNull($this->service->authenticate($key['plaintext'])); + } + + public function test_authenticate_rejects_empty_bearer(): void + { + $this->assertNull($this->service->authenticate('')); + } + + // ========================================================================= + // 2. getTools:白名单 ∩ 创建者实时权限(漂移) + // ========================================================================= + + public function test_get_tools_intersects_whitelist_with_creator_permission(): void + { + // 创建者角色只剩 NODE_A 权限(NODE_B 已回收 = 漂移场景) + $roleId = $this->createRoleWithNodes([self::NODE_A]); + $adminId = $this->createAdmin(status: 1, authIds: (string) $roleId); + $key = $this->createKey($adminId, [self::NODE_A, self::NODE_B]); + + $auth = $this->service->authenticate($key['plaintext']); + $this->assertNotNull($auth); + + // 节点层:只剩 A + $nodes = $this->service->getCreatorNodes($auth); + $this->assertSame([self::NODE_A], $nodes); + + // 工具层:仅返回 A,名称为编码名,描述含原始节点 + $tools = $this->service->getTools($auth); + $this->assertCount(1, $tools); + $this->assertSame($this->service->encodeToolName(self::NODE_A), $tools[0]['name']); + $this->assertStringContainsString('【' . self::NODE_A . '】', $tools[0]['description']); + } + + public function test_get_tools_preserves_whitelist_order(): void + { + $roleId = $this->createRoleWithNodes([self::NODE_A, self::NODE_B]); + $adminId = $this->createAdmin(status: 1, authIds: (string) $roleId); + // 白名单故意逆序插入 [B, A],结果必须保持白名单顺序 + $key = $this->createKey($adminId, [self::NODE_B, self::NODE_A]); + + $auth = $this->service->authenticate($key['plaintext']); + $nodes = $this->service->getCreatorNodes($auth); + + $this->assertSame([self::NODE_B, self::NODE_A], $nodes); + } + + public function test_get_tools_empty_whitelist_returns_empty(): void + { + $adminId = $this->createAdmin(); + $key = $this->createKey($adminId, []); + + $auth = $this->service->authenticate($key['plaintext']); + $this->assertNotNull($auth); + $this->assertSame([], $this->service->getCreatorNodes($auth)); + $this->assertSame([], $this->service->getTools($auth)); + } + + // ========================================================================= + // 3. 漂移矩阵 + // ========================================================================= + + public function test_drift_super_admin_creator_gets_full_whitelist(): void + { + // 超管(seed 的 id=1,auth_ids 空):判定链超管直通,白名单全保留 + // (含未注册节点也放行——超管对全量节点可见) + $fakeNode = 'nonexist.fake_node/action'; + $key = $this->createKey(1, [self::NODE_A, $fakeNode, self::NODE_B]); + + $auth = $this->service->authenticate($key['plaintext']); + $this->assertNotNull($auth); + $this->assertSame(1, (int) $auth['creator']['id']); + + $nodes = $this->service->getCreatorNodes($auth); + $this->assertSame([self::NODE_A, $fakeNode, self::NODE_B], $nodes); + + $tools = $this->service->getTools($auth); + $this->assertCount(3, $tools); + } + + public function test_drift_auth_on_false_returns_full_whitelist(): void + { + // auth_on=false 是 AuthServiceBase 硬编码配置,经 newAuthService 钩子注入关闭实例 + $service = new class() extends McpService { + protected function newAuthService(int $adminId): AuthServiceBase + { + return new class($adminId) extends \app\common\service\AuthService { + public function __construct($adminId = null) + { + // 属性默认值在构造前已就位,先关闭 auth_on 再走父构造 + $this->config['auth_on'] = false; + parent::__construct($adminId); + } + }; + } + }; + + // 非超管创建者:无任何角色授权,auth_on=true 时应为空 + $adminId = $this->createAdmin(status: 1, authIds: ''); + $key = $this->createKey($adminId, [self::NODE_A, self::NODE_B]); + + $auth = $this->service->authenticate($key['plaintext']); + $this->assertNotNull($auth); + $this->assertSame([], $this->service->getCreatorNodes($auth), 'auth_on=true 基线:无角色应为空'); + + // auth_on=false:判定链在 auth_on 处短路,白名单全量放行 + $nodes = $service->getCreatorNodes($auth); + $this->assertSame([self::NODE_A, self::NODE_B], $nodes); + $this->assertCount(2, $service->getTools($auth)); + } + + public function test_drift_creator_disabled_returns_empty_toolset(): void + { + // 创建者 status=0:判定链 status 校验拒绝一切 auth=true 节点 + $roleId = $this->createRoleWithNodes([self::NODE_A, self::NODE_B]); + $adminId = $this->createAdmin(status: 0, authIds: (string) $roleId); + $key = $this->createKey($adminId, [self::NODE_A, self::NODE_B]); + + $auth = $this->service->authenticate($key['plaintext']); + $this->assertNotNull($auth); + + $this->assertSame([], $this->service->getCreatorNodes($auth)); + $this->assertSame([], $this->service->getTools($auth)); + } + + // ========================================================================= + // 4. 工具名编码:MCP 规范正则 + 往返一致 + // ========================================================================= + + public function test_tool_names_match_mcp_regex_and_roundtrip(): void + { + $key = $this->createKey(1, [self::NODE_A, self::NODE_B]); + $auth = $this->service->authenticate($key['plaintext']); + $tools = $this->service->getTools($auth); + + $this->assertNotEmpty($tools); + foreach ($tools as $tool) { + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9_-]{1,64}$/', $tool['name']); + } + + // 已知节点编码精确值(驼峰动作名 + 下划线动作名示例) + $this->assertSame('system-mcp_key--index', $this->service->encodeToolName(self::NODE_A)); + $this->assertSame('system-auth--toggleUser', $this->service->encodeToolName(self::NODE_B)); + // 任务示例中的下划线动作名(纯编码函数层往返) + $this->assertSame('system-auth--toggle_user', $this->service->encodeToolName('system.auth/toggle_user')); + $this->assertSame('system.auth/toggle_user', $this->service->decodeToolName('system-auth--toggle_user')); + + // 全部真实注册节点 encode/decode 往返一致(真实节点均短于截断阈值,可逆) + $allNodes = array_keys((new \app\admin\service\NodeService())->getNodeParis()); + $this->assertNotEmpty($allNodes); + foreach ($allNodes as $node) { + $encoded = $this->service->encodeToolName($node); + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9_-]{1,64}$/', $encoded, 'node: ' . $node); + $this->assertSame($node, $this->service->decodeToolName($encoded), 'roundtrip node: ' . $node); + } + } + + public function test_encode_tool_name_truncates_long_node_with_hash_suffix(): void + { + // 73 字符节点 -> 编码 74 字符 -> 截断 56 + '_' + sha256 前 7 位 = 64 + $longNode = 'system.' . str_repeat('x', 60) . '/index'; + $this->assertSame(73, strlen($longNode)); + + $name = $this->service->encodeToolName($longNode); + $this->assertSame(64, strlen($name)); + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9_-]{1,64}$/', $name); + $this->assertSame( + substr('system-' . str_repeat('x', 60) . '--index', 0, 56) . '_' . substr(hash('sha256', $longNode), 0, 7), + $name + ); + // 截断名有损:直接 decode 不等于原节点(verifyNode 走正向重编码兜底) + $this->assertNotSame($longNode, $this->service->decodeToolName($name)); + + // 超管白名单放行未注册长节点:verifyNode 必须能还原(覆盖截断兜底路径) + $key = $this->createKey(1, [$longNode]); + $auth = $this->service->authenticate($key['plaintext']); + $tools = $this->service->getTools($auth); + $this->assertCount(1, $tools); + $this->assertSame($name, $tools[0]['name']); + $this->assertSame($longNode, $this->service->verifyNode($auth, $name)); + } + + // ========================================================================= + // 5. logCall:审计落库 + 原子自增 + // ========================================================================= + + public function test_log_call_writes_log_and_increments_use_num(): void + { + $adminId = $this->createAdmin(); + $key = $this->createKey($adminId, [self::NODE_A]); + + $this->service->logCall($key['id'], self::NODE_A, ['a' => 1], true, 12); + $this->service->logCall($key['id'], self::NODE_B, ['q' => str_repeat('x', 3000)], false, 34); + + // 日志表有行 + $this->assertSame(2, Db::name('system_mcp_log')->where('key_id', $key['id'])->count()); + $this->assertDatabaseHas('system_mcp_log', [ + 'key_id' => $key['id'], + 'node' => self::NODE_A, + 'arguments' => '{"a":1}', + 'is_success' => 1, + 'cost_ms' => 12, + ]); + + // arguments JSON 截断至 2000 字符以内;失败行字段正确落库 + $longArgs = Db::name('system_mcp_log') + ->where('key_id', $key['id']) + ->where('node', self::NODE_B) + ->value('arguments'); + $this->assertLessThanOrEqual(2000, mb_strlen((string) $longArgs)); + $this->assertDatabaseHas('system_mcp_log', [ + 'key_id' => $key['id'], + 'node' => self::NODE_B, + 'is_success' => 0, + 'cost_ms' => 34, + ]); + + // use_num 原子自增:连续 2 次调用 +2;last_use_time 已刷新 + $this->assertDatabaseHas('system_mcp_key', ['id' => $key['id'], 'use_num' => 2]); + $this->assertGreaterThan(0, (int) Db::name('system_mcp_key')->where('id', $key['id'])->value('last_use_time')); + } + + // ========================================================================= + // 6. verifyNode + // ========================================================================= + + public function test_verify_node_accepts_authorized_encoded_name(): void + { + $roleId = $this->createRoleWithNodes([self::NODE_A]); + $adminId = $this->createAdmin(status: 1, authIds: (string) $roleId); + $key = $this->createKey($adminId, [self::NODE_A, self::NODE_B]); + + $auth = $this->service->authenticate($key['plaintext']); + $this->assertNotNull($auth); + + $encoded = $this->service->encodeToolName(self::NODE_A); + $this->assertSame(self::NODE_A, $this->service->verifyNode($auth, $encoded)); + } + + public function test_verify_node_rejects_unauthorized_and_garbage_names(): void + { + $roleId = $this->createRoleWithNodes([self::NODE_A]); + $adminId = $this->createAdmin(status: 1, authIds: (string) $roleId); + $key = $this->createKey($adminId, [self::NODE_A, self::NODE_B]); + + $auth = $this->service->authenticate($key['plaintext']); + $this->assertNotNull($auth); + + // NODE_B 在白名单内,但创建者权限已回收(漂移):编码名不可用 + $this->assertNull($this->service->verifyNode($auth, $this->service->encodeToolName(self::NODE_B))); + // 乱码名 + $this->assertNull($this->service->verifyNode($auth, 'totally_unknown_tool')); + // 空名 + $this->assertNull($this->service->verifyNode($auth, '')); + } +}