package plugin // 本文件锁定「MCP 服务器」页(切片 M20)的三件事: // ① 声明本身合法(形状按 paneldecl 校验通过 —— 前端按声明泛化渲染,形状错就是半渲染); // ② 声明与登记**成对**(每个声明的动作都有处理器:只有声明 ⇒ 每个动作都 501); // ③ 动作面端到端可用:装 → 看 → 重连 → 停 → 启用 → 卸,每一步都推 panels_changed, // 失败按结构化错误码如实分类(install_failed / server_not_found / server_disabled)。 import ( "context" "net/http" "net/http/httptest" "os" "runtime" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // TestMCPPanelDeclarationIsValid 锁定 ①:整份声明过 paneldecl 的形状校验。 func TestMCPPanelDeclarationIsValid(t *testing.T) { t.Parallel() if err := paneldecl.Validate(MCPPanelPluginID, MCPAdminPanels()); err != nil { t.Fatalf("「MCP 服务器」页声明非法:%v", err) } } // TestMCPPanelActionsWired 锁定 ②:声明的动作与登记的处理器必须一一对应。 // // 这条断言的价值是把「声明里写了、没人实现」从「用户点出一个 501」提前到测试里:少一个 // 处理器,本测试直接红。 func TestMCPPanelActionsWired(t *testing.T) { t.Parallel() mgr, hub, _ := newPanelManager(t) defer mgr.Close(context.Background()) if err := mgr.AttachMCPPanel(); err != nil { t.Fatalf("AttachMCPPanel: %v", err) } declared := hub.DeclaredActions(MCPPanelPluginID) handled := hub.ActionHandlerNames(MCPPanelPluginID) if len(declared) == 0 || len(handled) == 0 { t.Fatalf("声明 %v / 处理器 %v;两者都不该为空", declared, handled) } set := make(map[string]bool, len(handled)) for _, name := range handled { set[name] = true } for _, name := range declared { if !set[name] { t.Fatalf("动作 %q 已声明但没有处理器(点了会 501)", name) } } // 候选来源(`optionsFrom: plugin:mcpTransports`)必须能被解析成已登记的动作名, // 否则前端的传输下拉会静默降级成文本框。 if name := OptionsActionName(OptionsSourceTransports); !set[name] { t.Fatalf("候选来源 %q 映射到 %q,但没有处理器", OptionsSourceTransports, name) } } // TestMCPPanelInstallAndList 锁定 ③ 的前半:安装(表单载荷)→ 看(表格行)。 func TestMCPPanelInstallAndList(t *testing.T) { t.Parallel() srv := newFakeMCP(t, "", "add", "mul") mgr, hub, _ := newPanelManager(t) defer mgr.Close(context.Background()) if err := mgr.AttachMCPPanel(); err != nil { t.Fatalf("AttachMCPPanel: %v", err) } ctx := context.Background() installViaAction(t, hub, ctx, "calc", srv.URL) assertInstalledConfig(t, mgr, "calc") row := mustRows(t, hub, ctx)[0] if row.Name != "calc" || row.State != ServerStateConnected || row.ToolCount == nil || *row.ToolCount != 2 { t.Fatalf("行 = %+v,期望 calc/connected/2 工具", row) } if row.ToolsText != "add、mul" { t.Fatalf("toolsText = %q,期望 add、mul", row.ToolsText) } if row.Target != srv.URL { t.Fatalf("target = %q,期望地址 %q", row.Target, srv.URL) } } // TestMCPPanelToggleUninstallAndFrames 锁定 ③ 的后半:重连 / 停用 / 启用 / 卸载, // 以及每一步都推 `panels_changed`(前端据此热更新,不必刷新页面)。 func TestMCPPanelToggleUninstallAndFrames(t *testing.T) { t.Parallel() srv := newFakeMCP(t, "", "add", "mul") mgr, hub, rec := newPanelManager(t) defer mgr.Close(context.Background()) if err := mgr.AttachMCPPanel(); err != nil { t.Fatalf("AttachMCPPanel: %v", err) } ctx := context.Background() installViaAction(t, hub, ctx, "calc", srv.URL) if _, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc"); err != nil { t.Fatalf("reconnect 动作: %v", err) } if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil { t.Fatalf("disable 动作: %v", err) } if names := toolNames(mgr.reg); len(names) != 0 { t.Fatalf("停用后工具仍在注册表 = %v", names) } if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateDisabled || row.Enabled { t.Fatalf("停用后行 = %+v", row) } _, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc") if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerDisabled || ae.Status != http.StatusConflict { t.Fatalf("对停用的 server 重连 = %v,期望 %s/409", err, mcpCodeServerDisabled) } if _, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc"); err != nil { t.Fatalf("enable 动作: %v", err) } if names := toolNames(mgr.reg); !names["add"] || !names["mul"] { t.Fatalf("启用后工具 = %v,期望 add+mul", names) } if _, err := dispatchRow(t, hub, ActionUninstallMCPServer, "calc"); err != nil { t.Fatalf("uninstall 动作: %v", err) } if rows := mustRows(t, hub, ctx); len(rows) != 0 { t.Fatalf("卸载后仍有行:%+v", rows) } reasons := changeReasons(rec.all()) for _, want := range []string{ PanelChangeInstalled, PanelChangeDisabled, PanelChangeEnabled, PanelChangeUninstalled, } { if !containsString(reasons, want) { t.Fatalf("变更原因 = %v,缺 %q", reasons, want) } } } // TestMCPPanelInstallFailureIsClassified 锁定失败分类:装不上 ≠ 找不到 ≠ 已停用。 func TestMCPPanelInstallFailureIsClassified(t *testing.T) { t.Parallel() dead := httptest.NewServer(http.NotFoundHandler()) deadURL := dead.URL dead.Close() mgr, hub, _ := newPanelManager(t) defer mgr.Close(context.Background()) if err := mgr.AttachMCPPanel(); err != nil { t.Fatalf("AttachMCPPanel: %v", err) } ctx := context.Background() _, err := hub.Dispatch(ctx, ActionRequest{ PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer, Payload: map[string]any{"values": map[string]any{"name": "down", "url": deadURL}}, }) if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInstallFailed || ae.Status != http.StatusBadGateway { t.Fatalf("连接失败 = %v,期望 %s/502", err, mcpCodeInstallFailed) } // 配置不落盘,但失败要留在快照里(面板要说得出为什么挂了)。 if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 { t.Fatalf("失败后落盘 = %+v(err=%v),期望空", cfgs, err) } if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateError || row.LastError == "" { t.Fatalf("失败行 = %+v,期望 error 且带 lastError", row) } // 缺字段 ⇒ invalid_config / 400(不是 502:请求本身写错了)。 _, err = hub.Dispatch(ctx, ActionRequest{ PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer, Payload: map[string]any{"values": map[string]any{"transport": "stdio"}}, }) if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest { t.Fatalf("缺 name/command = %v,期望 %s/400", err, mcpCodeInvalidConfig) } // 卸载一个不存在的名字 ⇒ server_not_found / 404。 _, err = dispatchRow(t, hub, ActionUninstallMCPServer, "ghost") if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerNotFound || ae.Status != http.StatusNotFound { t.Fatalf("卸载不存在的 server = %v,期望 %s/404", err, mcpCodeServerNotFound) } } // TestMCPPanelReservedNamespaceIsGuarded 锁定护栏:一台恰好叫 mcp 的 server 的卸载/停用 // 不许把内置「MCP 服务器」页整页摘掉。 func TestMCPPanelReservedNamespaceIsGuarded(t *testing.T) { t.Parallel() srv := newFakeMCP(t, "", "add") mgr, hub, _ := newPanelManager(t) defer mgr.Close(context.Background()) if err := mgr.AttachMCPPanel(); err != nil { t.Fatalf("AttachMCPPanel: %v", err) } installFake(t, mgr, MCPPanelPluginID, srv.URL) if _, err := mgr.Uninstall(context.Background(), MCPPanelPluginID); err != nil { t.Fatalf("Uninstall: %v", err) } entries, err := hub.List(auth.RoleOwner) if err != nil { t.Fatalf("List: %v", err) } if len(entries) != 1 || entries[0].PluginID != MCPPanelPluginID { t.Fatalf("内置 MCP 页被热装插件的生命周期改动了:%+v", entries) } } // mustRows 取表格行(`{rows}` 载荷)并断言载荷形状。 func mustRows(t *testing.T, hub *Panels, ctx context.Context) []ServerRow { t.Helper() res, err := hub.Dispatch(ctx, ActionRequest{PluginID: MCPPanelPluginID, Action: ActionListMCPServers}) if err != nil { t.Fatalf("list 动作: %v", err) } body, ok := res.(map[string]any) if !ok { t.Fatalf("list 结果 = %T,期望对象", res) } rows, ok := body["rows"].([]ServerRow) if !ok { t.Fatalf("list 结果的 rows 类型 = %T", body["rows"]) } return rows } // dispatchRow 以行内动作的形状(`{row:{name}}`)分发一次动作。 func dispatchRow(t *testing.T, hub *Panels, action, name string) (any, error) { t.Helper() return hub.Dispatch(context.Background(), ActionRequest{ PluginID: MCPPanelPluginID, Action: action, Payload: map[string]any{"row": map[string]any{FieldServerName: name}}, }) } // installViaAction 以表单载荷(`{values}`)经动作面安装一台 server。 func installViaAction(t *testing.T, hub *Panels, ctx context.Context, name, url string) { t.Helper() if _, err := hub.Dispatch(ctx, ActionRequest{ PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer, Payload: map[string]any{"values": map[string]any{ "name": name, "transport": "http", "url": url, "args": []any{"--flag", "value"}, "env": map[string]any{"TOKEN": "t0ken"}, }}, }); err != nil { t.Fatalf("install 动作: %v", err) } } // assertInstalledConfig 断言表单字段确实映射进了 plugins.json(args/env 这类列表/键值 // 最容易在「前端提交了、后端读错键」这一步静默丢失)。 func assertInstalledConfig(t *testing.T, mgr *Manager, name string) { t.Helper() cfgs, err := runtimeconfig.LoadPlugins(mgr.path) if err != nil || len(cfgs) != 1 { t.Fatalf("落盘 = %+v(err=%v),期望一条", cfgs, err) } if cfgs[0].Name != name { t.Fatalf("落盘名字 = %q", cfgs[0].Name) } if len(cfgs[0].Args) != 2 || cfgs[0].Args[1] != "value" { t.Fatalf("args 没映射进配置:%+v", cfgs[0].Args) } if cfgs[0].Env["TOKEN"] != "t0ken" { t.Fatalf("env 没映射进配置:%+v", cfgs[0].Env) } } // containsString 报告 items 里是否有 want。 func containsString(items []string, want string) bool { for _, item := range items { if item == want { return true } } return false } // TestMCPPanelInstallRejectsNonStringEnv 锁定 P2-1:`env` / `args` 里的非字符串值必须 // 变成 400,**不许静默丢键**(`{"PORT":8080}` 是前端 kv 控件最自然的写法:装完提示成功、 // server 连上了、env 却没生效 —— 比直接失败更坏)。 func TestMCPPanelInstallRejectsNonStringEnv(t *testing.T) { t.Parallel() srv := newFakeMCP(t, "", "add") mgr, hub, _ := newPanelManager(t) defer mgr.Close(context.Background()) if err := mgr.AttachMCPPanel(); err != nil { t.Fatalf("AttachMCPPanel: %v", err) } ctx := context.Background() cases := []struct { name string mutate func(values map[string]any) wantKey string }{ {"env 值是数字", func(v map[string]any) { v["env"] = map[string]any{"PORT": 9182.0} }, "env.PORT"}, {"env 值是布尔", func(v map[string]any) { v["env"] = map[string]any{"DEBUG": true} }, "env.DEBUG"}, {"args 元素是数字", func(v map[string]any) { v["args"] = []any{"-y", 42.0} }, "args"}, } for _, tc := range cases { values := map[string]any{ "name": "calc", "transport": "http", "url": srv.URL, "env": map[string]any{"TOKEN": "abc"}, "args": []any{"--flag"}, } tc.mutate(values) _, err := hub.Dispatch(ctx, ActionRequest{ PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer, Payload: map[string]any{"values": values}, }) ae, ok := AsActionError(err) if !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest { t.Fatalf("%s:err = %v,期望 %s/400", tc.name, err, mcpCodeInvalidConfig) } if !strings.Contains(ae.Message, tc.wantKey) { t.Fatalf("%s:文案没点名出错的键(%q 不含 %q)", tc.name, ae.Message, tc.wantKey) } // 文案里不许回显**收到的值**(env 的值可能就是凭据):9182 是本用例特意挑的 // 独特数字,文案里的 "8080" 只是「要加引号」的示例,不是回显。 if strings.Contains(ae.Message, "9182") { t.Fatalf("%s:文案回显了值:%q", tc.name, ae.Message) } } // 三次都失败 ⇒ 盘上零变更、注册表零工具、列表零行。 if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 { t.Fatalf("盘上 = %+v(err=%v),期望空", cfgs, err) } if names := toolNames(mgr.reg); len(names) != 0 { t.Fatalf("注册表里有工具 = %v", names) } if rows := mustRows(t, hub, ctx); len(rows) != 0 { t.Fatalf("快照里有行 = %+v", rows) } } // TestReservedPanelKeysProtectBuiltinProviders 锁定顺手件 1:三台内置提供者的命名空间 // 都不许被一台同名 server 的安装/停用/启用/卸载改写(否则内置页在重启前就消失)。 func TestReservedPanelKeysProtectBuiltinProviders(t *testing.T) { t.Parallel() for _, id := range []string{MCPPanelPluginID, "web-search", "browser"} { id := id t.Run(id, func(t *testing.T) { t.Parallel() srv := newFakeMCP(t, "", "add") mgr, hub, _ := newPanelManager(t) defer mgr.Close(context.Background()) ctx := context.Background() // 模拟该内置提供者在装配期声明的那一页。 if _, err := hub.Declare(id, []sdk.Panel{samplePanel()}); err != nil { t.Fatalf("Declare(%s): %v", id, err) } installFake(t, mgr, id, srv.URL) assertBuiltinPageAlive(t, hub, id, "安装") if err := mgr.SetEnabled(ctx, id, false); err != nil { t.Fatalf("SetEnabled(false): %v", err) } assertBuiltinPageAlive(t, hub, id, "停用") if err := mgr.SetEnabled(ctx, id, true); err != nil { t.Fatalf("SetEnabled(true): %v", err) } if _, err := mgr.Uninstall(ctx, id); err != nil { t.Fatalf("Uninstall: %v", err) } assertBuiltinPageAlive(t, hub, id, "卸载") }) } } // assertBuiltinPageAlive 断言该内置提供者的页仍在目录里。 func assertBuiltinPageAlive(t *testing.T, hub *Panels, id, step string) { t.Helper() entries, err := hub.List(auth.RoleOwner) if err != nil { t.Fatalf("List: %v", err) } for _, e := range entries { if e.PluginID == id { return } } t.Fatalf("%s一台同名 server 之后,内置页 %q 不见了:%+v", step, id, entries) } // TestMCPPanelEnablePersistFailureReportsAccurately 锁定顺手件 2:server **已连上**但 // 启用态没落盘时,文案必须说的是这件事(「仍处于停用状态」是相反的陈述)。 // // 只在本平台跑:靠「把 plugins.json 置成只读」让 rename 失败(Unix 下 rename 由目录权限 // 决定,同法不成立)。 func TestMCPPanelEnablePersistFailureReportsAccurately(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("只读位只在 windows 的 rename 上生效") } t.Parallel() srv := newFakeMCP(t, "", "add") mgr, hub, _ := newPanelManager(t) defer mgr.Close(context.Background()) if err := mgr.AttachMCPPanel(); err != nil { t.Fatalf("AttachMCPPanel: %v", err) } ctx := context.Background() installViaAction(t, hub, ctx, "calc", srv.URL) if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil { t.Fatalf("disable: %v", err) } if err := os.Chmod(mgr.path, 0o444); err != nil { t.Fatalf("chmod: %v", err) } t.Cleanup(func() { _ = os.Chmod(mgr.path, 0o600) }) _, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc") ae, ok := AsActionError(err) if !ok || ae.Code != mcpCodeEnableFailed || ae.Status != http.StatusBadGateway { t.Fatalf("启用(落盘失败)= %v,期望 %s/502", err, mcpCodeEnableFailed) } // 断言的是**文案与事实一致**(下面两条),不是某个内部哨兵:这句话是给主人看的。 if !strings.Contains(ae.Message, "未落盘") || strings.Contains(ae.Message, "仍处于停用状态") { t.Fatalf("文案与事实不符:%q", ae.Message) } // 事实是「已经连上、工具可用」,快照也这么说(state 派生口径不变)。 if names := toolNames(mgr.reg); !names["add"] { t.Fatalf("启用后工具 = %v,期望已连上并注册", names) } if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateConnected || row.Enabled { t.Fatalf("快照 = %+v,期望 state=connected 且 enabled=false(盘上仍是停用)", row) } }