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, '')); } }