节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
6.67 KiB
Stardust
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization;
using System.Text.Json;
using NewLife;
using Stardust.Mcp;
using Xunit;

namespace ServerTest.Mcp;

/// <summary>
/// MCP 工具契约特征测试。覆盖本轮修复:
/// <list type="bullet">
///   <item>D-3: 所有工具 InputSchema 有实质 properties(不是空 object)</item>
///   <item>风险粒度: deploy_node_operate / app_process 为 L1(不是 L2);node_command 为 L2</item>
///   <item>S4: config_publish 工具已移除,tools/list 不含该名</item>
///   <item>S2: config_get 描述提到脱敏;config_set 描述不引用旧方案 config_publish</item>
/// </list>
/// 纯元数据测试,不连数据库。
/// </summary>
public class McpToolContractTests
{
    /// <summary>获取所有 IMcpTool 实现类型</summary>
    private static List<Type> GetToolTypes()
    {
        var assembly = typeof(IMcpTool).Assembly;
        return assembly.GetTypes()
            .Where(t => t is { IsClass: true, IsAbstract: false } && typeof(IMcpTool).IsAssignableFrom(t))
            .ToList();
    }

    /// <summary>创建未初始化实例(元数据均为硬编码返回值,不依赖实例字段)</summary>
    private static IMcpTool Create(Type type) => (IMcpTool)FormatterServices.GetUninitializedObject(type);

    private static IMcpTool FindTool(String name)
    {
        var t = GetToolTypes().FirstOrDefault(x => Create(x).Name == name);
        Assert.NotNull(t);
        return Create(t!);
    }

    /// <summary>真正无需参数的工具:whoami/project_list/audit_list/now_changes</summary>
    private static readonly HashSet<String> NoParamTools = new(StringComparer.OrdinalIgnoreCase)
    {
        "stardust_token_whoami",
        "stardust_project_list",
        "stardust_audit_list",
        "stardust_now_changes",
    };

    [Fact(DisplayName = "D-3: 所有带参工具 InputSchema 有 properties")]
    public void AllTools_InputSchema_HasProperties()
    {
        var bad = new List<String>();
        foreach (var type in GetToolTypes())
        {
            var tool = Create(type);
            if (NoParamTools.Contains(tool.Name)) continue;

            var schema = tool.InputSchema;
            if (schema.ValueKind == JsonValueKind.Undefined || schema.ValueKind == JsonValueKind.Null)
            {
                bad.Add($"{tool.Name}: InputSchema 为默认空");
                continue;
            }

            // 解析 properties
            if (!schema.TryGetProperty("properties", out var props) || props.ValueKind != JsonValueKind.Object || !props.EnumerateObject().Any())
            {
                bad.Add($"{tool.Name}: InputSchema 缺少 properties(空 object)");
            }
        }

        Assert.True(bad.Count == 0, $"以下工具 InputSchema 不完整:{Environment.NewLine}{String.Join(Environment.NewLine, bad)}");
    }

    [Fact(DisplayName = "风险粒度: deploy_node_operate 为 L1(可逆写,可回滚)")]
    public void DeployNodeOperate_RiskIsL1()
    {
        var tool = FindTool("stardust_deploy_node_operate");
        Assert.Equal(McpRiskLevel.L1, tool.Risk);
    }

    [Fact(DisplayName = "风险粒度: app_process 为 L1(可逆写)")]
    public void AppProcess_RiskIsL1()
    {
        var tool = FindTool("stardust_app_process");
        Assert.Equal(McpRiskLevel.L1, tool.Risk);
    }

    [Fact(DisplayName = "风险粒度: node_command 为 L2(高危,需 confirm)")]
    public void NodeCommand_RiskIsL2()
    {
        var tool = FindTool("stardust_node_command");
        Assert.Equal(McpRiskLevel.L2, tool.Risk);
    }

    [Fact(DisplayName = "风险粒度: app_command 为 L2(高危)")]
    public void AppCommand_RiskIsL2()
    {
        var tool = FindTool("stardust_app_command");
        Assert.Equal(McpRiskLevel.L2, tool.Risk);
    }

    [Fact(DisplayName = "S4: tools/list 不含 stardust_config_publish")]
    public void ToolsList_DoesNotContainConfigPublish()
    {
        var names = GetToolTypes().Select(t => Create(t).Name).ToHashSet();
        Assert.DoesNotContain("stardust_config_publish", names, StringComparer.OrdinalIgnoreCase);
    }

    [Fact(DisplayName = "S4: 确认 config 域只有 List/Get/Set/Rollback 四个工具")]
    public void ConfigTools_HasOnlyFourTools()
    {
        var names = GetToolTypes()
            .Select(t => Create(t).Name)
            .Where(n => n.StartsWith("stardust_config_"))
            .OrderBy(n => n)
            .ToList();

        Assert.Contains("stardust_config_list", names);
        Assert.Contains("stardust_config_get", names);
        Assert.Contains("stardust_config_set", names);
        Assert.Contains("stardust_config_rollback", names);
        Assert.Equal(4, names.Count);
    }

    [Fact(DisplayName = "S2: config_get 描述提到自动脱敏")]
    public void ConfigGet_Description_MentionsMasking()
    {
        var tool = FindTool("stardust_config_get");
        var desc = tool.Description;
        Assert.True(
            desc.Contains("mask", StringComparison.OrdinalIgnoreCase) || desc.Contains("***", StringComparison.Ordinal),
            $"config_get 描述未提及脱敏:{desc}");
    }

    [Fact(DisplayName = "S4: config_set 描述不引用已移除的 config_publish")]
    public void ConfigSet_Description_NoPublishReference()
    {
        var tool = FindTool("stardust_config_set");
        Assert.DoesNotContain("config_publish", tool.Description, StringComparison.OrdinalIgnoreCase);
    }

    [Fact(DisplayName = "S2: config_set 描述不引用已废弃的 config:secret scope")]
    public void ConfigSet_Description_NoSecretScope()
    {
        var tool = FindTool("stardust_config_set");
        Assert.DoesNotContain("config:secret", tool.Description, StringComparison.OrdinalIgnoreCase);
    }

    [Fact(DisplayName = "每个工具 Name 匹配 stardust_域_动作 规范")]
    public void AllTools_Name_FollowsConvention()
    {
        var bad = new List<String>();
        foreach (var type in GetToolTypes())
        {
            var tool = Create(type);
            if (!tool.Name.StartsWith("stardust_"))
                bad.Add(tool.Name);
        }
        Assert.True(bad.Count == 0, $"以下工具名不规范:{String.Join(", ", bad)}");
    }

    [Fact(DisplayName = "每个工具 ScopeDomain 非空")]
    public void AllTools_ScopeDomain_NonEmpty()
    {
        var bad = new List<String>();
        foreach (var type in GetToolTypes())
        {
            var tool = Create(type);
            // whoami 不要求 scope,但 ScopeDomain 仍应有值
            if (tool.ScopeDomain.IsNullOrEmpty())
                bad.Add(tool.Name);
        }
        Assert.True(bad.Count == 0, $"以下工具 ScopeDomain 为空:{String.Join(", ", bad)}");
    }
}