package plugin // 本文件锁定 MCP 服务器状态快照的三条硬要求(切片 M20): // ① 三态:缺席 = 未知,不用零值冒充「未连接」; // ② 凭据绝不出现(apiKey 及其任意片段),带变异自证; // ③ 连接失败如实给 lastError + connected=false,且失败的那台也出现在快照里 // (否则「为什么挂了」在面板上根本无处可看)。 import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // statusOfOne 返回快照里某台 server 的那一条(没有就 fail)。 func statusOfOne(t *testing.T, mgr *Manager, name string) Status { t.Helper() for _, st := range mgr.Statuses() { if st.Name == name { return st } } t.Fatalf("快照里没有 %q:%+v", name, mgr.Statuses()) return Status{} } // TestStatusesTriState 锁定 ①:三种「知道」程度各自长什么样。 func TestStatusesTriState(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "plugins.json") srv := newFakeMCP(t, "", "add") mgr, _ := newEnabledManager(t, path, srv.URL) // 连着:connected=true、工具数/明细/最近连接都在。 live := statusOfOne(t, mgr, "calc") if live.State != ServerStateConnected { t.Fatalf("state = %q,期望 connected", live.State) } if live.Connected == nil || !*live.Connected || live.ToolCount == nil || *live.ToolCount != 1 { t.Fatalf("连接态快照不对 = %+v", live) } if live.LastSeenAt == "" || live.LastError != "" { t.Fatalf("活着时应当有 lastSeenAt 且没有 lastError:%+v", live) } if live.Endpoint != srv.URL { t.Fatalf("endpoint = %q,期望 %q", live.Endpoint, srv.URL) } // 停用:connected 是**确认的 false**(有意没连),工具则是未知。 if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil { t.Fatalf("SetEnabled(false): %v", err) } off := statusOfOne(t, mgr, "calc") if off.State != ServerStateDisabled || off.Connected == nil || *off.Connected { t.Fatalf("停用快照 = %+v", off) } if off.ToolCount != nil || off.Tools != nil { t.Fatalf("停用时工具应当是**未知**(缺席),得到 %+v / %+v", off.ToolCount, off.Tools) } // 未知:文件里有这一条,但本次进程从没连过也没失败过(手工改文件就是这样)。 handEdited := runtimeconfig.PluginConfig{Name: "manual", Kind: runtimeconfig.PluginKindMCP, Transport: runtimeconfig.TransportHTTP, Endpoint: "https://example.invalid/mcp"} if err := runtimeconfig.SavePlugins(path, []runtimeconfig.PluginConfig{handEdited}); err != nil { t.Fatalf("SavePlugins: %v", err) } unknown := statusOfOne(t, mgr, "manual") if unknown.State != ServerStateUnknown { t.Fatalf("state = %q,期望 unknown", unknown.State) } if unknown.Connected != nil { t.Fatalf("未知的 connected 必须**缺席**(不得用零值冒充未连接),得到 %v", *unknown.Connected) } if unknown.ToolCount != nil || unknown.LastSeenAt != "" || unknown.LastError != "" { t.Fatalf("未知时不该有工具/时间/错误:%+v", unknown) } } // TestStatusesFailureIsVisible 锁定 ③:连不上的**新装**attempt 也要出现在快照里。 func TestStatusesFailureIsVisible(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "plugins.json") dead := httptest.NewServer(http.NotFoundHandler()) deadURL := dead.URL dead.Close() mgr := NewManager(registry.New(), path, nil) defer mgr.Close(context.Background()) if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{ Name: "down", Transport: runtimeconfig.TransportHTTP, Endpoint: deadURL, }); err == nil { t.Fatal("对关掉的 server 安装应当失败") } st := statusOfOne(t, mgr, "down") if st.State != ServerStateError || st.Connected == nil || *st.Connected { t.Fatalf("失败快照 = %+v,期望 error 且 connected=false", st) } if st.LastError == "" { t.Fatal("失败快照没有 lastError:面板说不出病因") } if st.Enabled != true { t.Fatalf("失败的那台是「想启用但没连上」,enabled 应为 true:%+v", st) } } // TestStatusesNeverLeakAPIKey 锁定 ②:快照 JSON 里不得出现 apiKey 或它的任意片段。 // // 覆盖两条最容易漏的路:① 正常快照里的配置字段;② 失败文本(MCP 客户端/上游可能把 // Bearer 令牌写进错误里)。片段检查是「变异自证」:把密钥切成若干 8 字符片段再各查一次。 func TestStatusesNeverLeakAPIKey(t *testing.T) { t.Parallel() const key = "sk-live-SECRET-9f3c2b1d" path := filepath.Join(t.TempDir(), "plugins.json") srv := newFakeMCP(t, key, "add") mgr := NewManager(registry.New(), path, nil) defer mgr.Close(context.Background()) if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{ Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: srv.URL, APIKey: key, }); err != nil { t.Fatalf("Install: %v", err) } cfgs, err := runtimeconfig.LoadPlugins(path) if err != nil || len(cfgs) != 1 { t.Fatalf("LoadPlugins = %+v, %v", cfgs, err) } cfgWithKey := cfgs[0] // 失败文本里塞进整把钥匙和它的中段(这正是「上游把凭据回显进 401」的形状)。 mgr.noteFailure(cfgWithKey, errors.New("401 Unauthorized: bearer "+key+" rejected; echo=SECRET-9f3")) mgr.noteFailure(cfgWithKey, errors.New("dial "+srv.URL+"?api_key="+key+" failed")) raw, err := json.Marshal(mgr.Statuses()) if err != nil { t.Fatalf("marshal statuses: %v", err) } body := string(raw) assertNoSecret(t, body, key) if !strings.Contains(body, "failed") { t.Fatalf("抹凭据不该把病因整条抹掉:%s", body) } } // TestRedactPluginSecrets 直接钉住抹除函数:整把、前缀、中段、后缀都不许留下。 func TestRedactPluginSecrets(t *testing.T) { t.Parallel() const key = "tok_ABCDEF123456" cfg := runtimeconfig.PluginConfig{APIKey: key, Env: map[string]string{"GITHUB_TOKEN": "ghp_ZZZZZZ9999", "PLAIN": "keep-me"}} cases := []string{ "authorization: Bearer " + key, "prefix " + key[:8] + " suffix", "middle ABCDEF123456 and more", "env dump GITHUB_TOKEN=ghp_ZZZZZZ9999", } for _, in := range cases { out := runtimeconfig.RedactPluginSecrets(cfg, in) assertNoSecret(t, out, key) assertNoSecret(t, out, "ghp_ZZZZZZ9999") } // 非敏感值不该被连带抹掉(否则排障信息会被无谓地毁掉)。 if out := runtimeconfig.RedactPluginSecrets(cfg, "plain keep-me"); !strings.Contains(out, "keep-me") { t.Fatalf("非敏感值被误抹:%q", out) } } // assertNoSecret 断言 text 不含 secret 本身,也不含它的任意 8 字符片段。 func assertNoSecret(t *testing.T, text, secret string) { t.Helper() if strings.Contains(text, secret) { t.Fatalf("文本里出现了完整凭据 %q:%s", secret, text) } const frag = 8 for i := 0; i+frag <= len(secret); i++ { if part := secret[i : i+frag]; strings.Contains(text, part) { t.Fatalf("文本里出现了凭据片段 %q:%s", part, text) } } }