feat(mcp): McpService 密钥认证与动态工具集(白名单∩创建者实时权限)

This commit is contained in:
augushong
2026-08-16 21:40:55 +08:00
parent 744a0819f2
commit 1ab276b1e9
3 changed files with 700 additions and 0 deletions

406
tests/McpServiceTest.php Normal file
View File

@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace tests;
use app\admin\model\SystemMcpKey;
use app\admin\model\SystemMcpKeyNode;
use app\common\service\McpService;
use app\common\test\TestCase;
use base\common\service\AuthServiceBase;
use think\facade\Db;
/**
* McpService 单元测试(密钥认证 / 动态工具集 / 调用审计).
*
* 全部 fixture 在测试事务内自造ulthon-testing 技能约定),不依赖开发库既有数据;
* 造新管理员/新角色避开 SystemAuthNode 60 秒查询缓存与 adminInfo autoCache 窗口
* learnings Task 4漂移断言不修改已读过的行改用全新 ID
*
* 覆盖验收矩阵:
* 1. authenticate有效 / 错误 bearer / status=0 / 软删
* 2. getTools白名单 ∩ 创建者实时权限(漂移)
* 3. 漂移矩阵:超管=全量 / auth_on=false=全量 / 创建者禁用=空
* 4. 工具名 MCP 规范正则 + encode/decode 往返(含下划线动作名、超长截断)
* 5. logCall日志落库 + use_num 原子自增
* 6. verifyNode合法编码名 / 未授权 / 乱码
*/
class McpServiceTest extends TestCase
{
/** 真实注册节点 Amcp_key 控制器 indexauth=true. */
private const NODE_A = 'system.mcp_key/index';
/** 真实注册节点 B驼峰动作名parseNodeStr 只 snake 控制器段,动作段保留原样). */
private const NODE_B = 'system.auth/toggleUser';
private McpService $service;
protected function setUp(): void
{
parent::setUp();
$this->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=1auth_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 次调用 +2last_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, ''));
}
}