fix(plugin): 拒收 env/args 的非字符串值 + 保留键收成内置 id 集合 + 启用落盘失败文案
r1 复审 P2-1 + 两条顺手件。
P2-1(必修):`actionStringMap` / `actionStringList` 之前对非字符串值 `continue`,于是
前端 kv 控件里最自然的写法 `{"PORT":8080}` 会被**静默丢掉**、安装仍报 `installed:true`。
现在非字符串值、空键、非数组一律 400 invalid_config,文案只点名**键与类型**(不回显值);
盘上零变更、注册表零工具、快照零行(测试同时断言)。
顺手件 1:`isReservedPanelKey` 从「只挡 mcp」改成内置提供者闭集(mcp / web-search /
browser,写成 switch,新增内置页时漏补一眼可见)。之前一台名为 web-search 的 server 被
卸载时,`forgetPanels → MarkUninstalled` 会把内置「搜索提供商」页在重启前整页摘掉。
顺手件 2:启用「已连上但启用态未落盘」的文案改成事实(原来那句「它仍处于停用状态」与
事实相反)。为此新增 `ErrPluginStatePersist` 哨兵,把「磁盘没记住」与「连不上」分开;
`state=connected` 的派生口径不动。停用失败文案同时补上「server 仍在运行」。
测试:三条新用例,均已验「修前红 → 修后绿」(见 review-request 的取证)。
This commit is contained in:
1 parent
2421a8dac9
commit
2142de92f2
4 files changed
+297
-46
No files matched your search
@@ -10,11 +10,15 @@ 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 的形状校验。
|
||||
@@ -272,3 +276,155 @@ func containsString(items []string, want string) bool {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user