Files
ulthon_admin/tests/McpServiceTest.php

407 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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